Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 17a058bb2a launcher: a new Horizon instance is "My Horizon", its versions dated "Nov 1"
The mockup's instance dialog names a new instance before the player types (defaultName): "My
Horizon" for Horizon, as the launcher already did with "Vanilla 26.3" or "Fabric 26.3". A new
Horizon instance opened with an empty name and "New instance" as its title. The dialog now
starts with the default name for every kind, and it follows the kind until the player types
one. The Horizon version rows say "Nov 1" as the mockup's, not "2026-11-01" (the Versions
page's day format). One new string, "My Horizon", in the 16 languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:05:02 +01:00
Ludovic MarieandClaude Opus 5.5 e22b2de9ae launcher: toasts, as the mockup's say
What the launcher just did shows for 4.2 s, as in the mockup: in the top
bar between the pages and the account on a window 1280 px wide or more,
right-aligned at 70 %; narrower, a glass toast along the bottom.

It says: Opened {folder} (every Open folder button), Copied {text},
Created / Saved / Deleted {instance}, a mod installed or removed, and
"Launcher settings are back to their defaults" after Reset to defaults.
Any handler can say something (glass_toast::say), even with only &mut App.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:29:21 +01:00
Ludovic MarieandClaude Opus 5.5 445d087848 launcher: the palette (⌘K), as the mockup's
⌘K (Ctrl+K elsewhere) opens the mockup's Palette over the dimmed
launcher: a 560 px glass card at 14 % of the height, a search line and
up to eight commands with their hints. Play each instance (only when
Play would; otherwise its page), Open each page and Settings, New
instance, Open the game folder, and from two letters, Install or Remove
the registry's matching mods in the selected instance. Arrows move,
Enter runs, Esc or a click outside closes.

Pages the top bar hides stay out of the palette (Page::in_top_bar).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:16:49 +01:00
Ludovic MarieandClaude Opus 5.5 2d556df183 launcher: the Mods page in the mockup's layout (dark)
The glass look's Mods page follows the mockup's ModsView: the title with
its tabs, the search and results in one plate, the picked project's card
(360 px) beside it. Registry rows end with downloads, Modrinth's with how
they run (Works, Partly, Doesn't run); the card shows Install in or
Remove from the instance, and keeps the last project when a search finds
nothing. The tabs are the page's kinds, CurseForge after them as soon.

Show everything, the source switch, filters, sort, the install target,
ratings, the support reason, GitHub native mods, imported modpacks and
the installed list stay, in the mockup's patterns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:01:26 +01:00
Ludovic MarieandClaude Opus 5.5 0751443f43 launcher: Network, Game and About in the mockup's layout (dark)
Proxy and Game arguments are the mockup's stacked rows: the name and
note, then a 44 px field up to 560 px wide. The Game card gains the
mockup's Native mods row; native mods are always on, so it picks the
profile they build with, as the Mods page does. About opens with the
mockup's three tiles: Horizon (the version in use and whether it is up
to date, with Install when it isn't), the launcher (Check now, Restart)
and the data folder (Open); the logs folder and the applications menu
stay as rows under them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:38:00 +01:00
Ludovic MarieandClaude Opus 5.5 0a04de281e launcher: the Glass and Blur sliders and Reset to defaults, as the mockup's
Settings > Launcher in the glass look gains the mockup's Glass (how much
white the plates take, 0 to 16 %) and Blur (0 to 48 px) sliders: the
look changes while dragging, settings.json keeps them on release, and the
plates' backdrop is blurred again when the blur changes. "Reset to
defaults" puts the card's options back as Horizon ships them: accent,
glass, blur, language, while playing and the opening, and here the
appearance and Home's background too, being in the same card.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:28:37 +01:00
Ludovic MarieandClaude Opus 5.5 54cf80d6c0 launcher: the sky takes the accent, as the mockup's
Any accent but the violet tints the whole scene as the mockup does: a
layer of the accent mixed with grey (58 % in OKLab) over it with CSS's
`color` blend, so each colour keeps its luminance and takes the tint's
hue and chroma. The blend is the same sum on every colour, so the scene
applies it where each colour is painted: the baked sky, halo and ridges,
the runs, stars and fireflies of each frame, and the plates' backdrop.
The violet keeps the website's picture untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:18:24 +01:00
Ludovic MarieandClaude Opus 5.5 e25ae0a42a launcher: the accent swatches and hex field in the mockup's layout, the glass look in the accent
Settings > Launcher's accent row in the glass look is the mockup's: its
seven swatches (the named accents, now in the mockup's colours), the
any-colour swatch, which shows the hue strip, and a hex field that takes
a colour as soon as one is typed. The glass look now wears the player's
accent: buttons, switches, ticks and the plates' tint, where it always
drew the violet. An accent can be any #rrggbb (settings.json, the CLI);
the light look turns its tokens to that colour's hue as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:11:16 +01:00
Ludovic MarieandClaude Opus 5.5 0dce98a6f8 launcher: Settings' Account card in the mockup's layout (dark)
Who plays now (an 80 px head, the name in the display font, how they
play), the offline names as chips with a field to add one, and the
Microsoft card beside them with its white Sign in with Microsoft button.
The Microsoft accounts already signed in, the game's own name, each
name's skin and the sign-in's code and progress stay, in the same chips
and flat rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:56:14 +01:00
Ludovic MarieandClaude Opus 5.5 9a8f46a57b launcher: the Settings page's sections and pane in the mockup's layout (dark)
As the mockup's SettingsView: the list of sections on the left (its icons, the
current one in the accent), the sections as glass cards in a pane that scrolls on
its own under a still title, the list following the scroll (its scroll spy) and
taking the pane to a section. Rows inside the cards are its SettingRows: index_row
and section_title draw them while glass_ui::flat_rows builds a card. Data collection,
not in the mockup, is its own entry in the list. The Account card keeps today's rows
until the next PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:45:41 +01:00
Ludovic MarieandClaude Opus 5.5 5ac02e14cc launcher: the Patch notes page in the mockup's layout (dark)
Each release on its own glass card as in the mockup's NotesView: its name and the
day it came out (the day at 55 %), its notes in 14 px at 75 % with square bullets.
The sponsor card stays above the notes (not in the mockup), on a glass card with a
quiet button.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:28:20 +01:00
Ludovic MarieandClaude Opus 5.5 ccffe09574 launcher: the Versions page in the mockup's layout (dark)
Each version on its own glass card as in the mockup: its name, Newest on the first,
the day and time it came out and the instances that play it, Install or the bin on
the right, and Install new versions automatically beside the page's title. Kept in
its patterns: Use and In use, the space versions take, Import a game build, native
and imported builds, install progress and errors, the confirmation before a removal.
Releases keep their publishing time (HH:MM UTC) for the line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:22:50 +01:00
Ludovic MarieandClaude Opus 5.5 93746618e7 launcher: the server's Players, Settings and Backups tabs in the mockup's rows (dark)
These tabs aren't in the mockup; the glass look fits them into its patterns: settings
as its SettingRow (15 px semibold name, 13 px note at 60 %, the control 40 px away),
the switches with their names as SettingRows too, small card headings, player and
backup rows as its ROW text with 28 px heads, the Create dialog's stepper, and no rules
between rows. The light look keeps its rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:14:00 +01:00
Ludovic MarieandClaude Opus 5.5 91b42dd3f5 launcher: the Create a server dialog in the mockup's layout (dark)
The glass look's Create dialog follows the mockup's server modal: an aside with the
mode's picture (or the copied world's icon), the server's name and its rules, and a
main column with Name, World (new with a seed, or one of the player's worlds with
its icon, mode and size), Game mode cards, Difficulty, Most players, Who can join
(friends as chips, the first an operator) and Check Minecraft accounts, above the
EULA line and Create server. A focused empty glass field keeps its placeholder after
the caret, as the mockup's inputs do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 16:01:51 +01:00
Ludovic MarieandClaude Opus 5.5 90ca79aeac launcher: the Servers page in the mockup's layout (dark)
The mockup's ServersView in the glass look: the open server's glass (state,
name, numbers, Start or Restart and Stop, the console and its command line),
beside it the servers with Create a server, and how friends reach the open
one (on this network, over the internet through Tailscale, who is online).
The mockup always shows a server, so the first one opens.

Kept from the launcher, fitted into the mockup's parts: the web panel and
folder as small icon buttons in the glass's empty corner; Add a server
folder as a row of the list; crash count, the action under way, problems
with their fix, notes and settings waiting for a restart as lines in the
glass; Players, Settings and Backups on a glass under it, with Tailscale
invitations under Players; Sculk's banner (sponsor) on a glass card with
"Partner" and Hide. Every field is a glass field in dark.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 15:42:56 +01:00
Ludovic MarieandClaude Opus 5.5 ceedb4de27 launcher: the generic controls in the glass look (dark)
In the dark appearance, the launcher's shared controls draw as the mockup's
parts, so the pages not rebuilt yet look like the rest: button() is PLAY,
QUIET or a text button; switch() the Toggle; segments() the server dialog's
difficulty strip; page_header() the PageHead; panels and night plates are
glass plates round at 16; the progress bar is the mockup's Bar; caps() labels
are the small 13 px headings. The dark theme's tokens become white at a few
opacities with no borders or bevels. The light appearance is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 15:29:09 +01:00
Ludovic Marie 294448014f Merge pull request #8193 from horizon-rust/fix/native-mods-recursion-limit
🤖 Agent: queued (urgent: every native mod build fails on Nightly f42ef42, #8169). verify green, and a real native build of smp + voicechat-server passes the mixins step at this head. The only code change besides the attribute is control_mobs_at's json! split in two, same output.
2026-11-03 15:16:07 +01:00
Ludovic MarieandClaude Opus 5.5 b1eb5e1d5c Mob control state: set five keys apart so json! fits the default recursion limit
Without horizon-client's #![recursion_limit], the one json! with every
key of control_mobs_at hit the macro recursion limit. Same output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 13:27:39 +01:00
Ludovic MarieandClaude Opus 5.5 7f0f0e4584 Native mods build again: drop horizon-client's #![recursion_limit]
The launcher's horizon-mixins (ddd9700) treats an inner attribute it
doesn't know on the crate root as an unexpanded macro, so every native
mod targeting horizon_client failed with E_UNSUPPORTED_MACRO on Nightly
2026-11-03 (#8169). 59fe490 added the attribute; CI tells whether the
crate still needs it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 13:21:46 +01:00
Ludovic Marie fc02103818 Merge pull request #8097 from TiggerTakkyTTT/fix/server-management-allow-flight
🤖 Agent: queued. The run-parity job passed on this head (61 harnesses, management_protocol included: live get/set, invalid input, saved properties, restart all match vanilla). The `lan == None` nit can follow separately.
2026-11-03 11:21:44 +01:00
TiggerTakkyTTTandCodex 3745504a05 Add live allow-flight management setting
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 15:33:50 +07:00
Ludovic Marie f42ef42ef6 Merge pull request #8077 from horizon-rust/fix/parrot-flying-speed-attribute
🤖 Agent: FlyingMoveControl now reads the flying_speed attribute, not the type's 0.4, so #7822's summoned parrot with flying_speed base 0 stays put (Horizon's drift was exactly 0.4 × 0.02 per tick). New head = main merged in, run-parity dropped: the old head's only failure was pack_screen on playhorizon (its .local has no lang files). parity-main checks command_exec.
2026-11-03 09:12:59 +01:00
Ludovic MarieandClaude Opus 5.5 28d64e4a1e Merge origin/main into fix/parrot-flying-speed-attribute
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 08:59:46 +01:00
Ludovic Marie f8606a8543 Merge pull request #8082 from horizon-rust/harness/random-tick-unported-trees
🤖 Agent: random_tick's 2 oracle errors (failing since #7084) are stage-1 saplings that reach TreeGrower.growTree with no FEATURE registry; the probe reports X grow_tree for them. Only that exact pair is set apart; any other oracle error still fails. run-parity: random_tick, block_update, dispenser and 9 more pass.
2026-11-03 08:20:07 +01:00
Ludovic MarieandClaude Opus 5.5 270d627be0 random_tick: a sapling reaching growTree is not compared
Since #7084 an oracle error fails the block update harnesses. Two of
random_tick's 40,000 cases are stage-1 saplings whose random tick reaches
TreeGrower.growTree: the oracle's level has no Registries.FEATURE, so
lookupOrThrow throws, and the probe, which has no feature table, reports
X grow_tree. Neither side grows the tree, so the pair is counted apart and
not compared; any other oracle error still fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 07:58:53 +01:00
Ludovic MarieandClaude Opus 5.5 915fe93c4e A summoned parrot flies at its own flying_speed attribute
FlyingMoveControl.tick reads getAttributeValue(FLYING_SPEED). Horizon read
the type's default (0.4 for a parrot), so a parrot summoned with
attributes:[{id:"minecraft:flying_speed",base:0d}] still flew off. #7822's
command_exec "bird flap sensors" scenario summons exactly that and failed
30 Pos lines on parity-main. flying_speed is now a mob attribute like the
others: loaded, saved and read by the move control.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 07:15:44 +01:00
Ludovic Marie c076902dd5 Merge pull request #7850 from horizon-rust/feat/cosmetics-game-settings
🤖 Agent: queued after merging main. The conflict was in the extras switch list: main's resource pack browser and this PR's auras switch are both kept, and the three-state Others setting keeps its own value match. Cosmetics stay off by default.
2026-11-03 06:59:46 +01:00
Ludovic Marie fef2e961ef Merge pull request #8072 from horizon-rust/fix/station-menu-data-crash
🤖 Agent: queued. Grindstone and smithing menus have no data slot, so the cost snapshot now reads `data.first()`; the anvil-only `data[0]` below stays because the anvil always has its cost. The official server has run this fix (r6.1) since 05:45 UTC.
2026-11-03 06:53:48 +01:00
Ludovic Marie a4a3afb85b Merge pull request #8067 from arslanarm/shaderpacks/pack-smoothing-halflives
🤖 Agent: requeued after the pack_uniforms.rs test conflict with #8066 (both tests kept); the smoothing still uses OptiFine's tick half-lives at the 0.1 s a tick that Iris was measured to use.
2026-11-03 06:48:30 +01:00
Ludovic Marie 676f6f859b Merge pull request #8027 from Teiwazik/older-servers/v1-21-11
🤖 Agent: requeued after main's merge. The diff is now 1.21.11's own step (+696), still behind HORIZON_OLDER_SERVERS=1.
2026-11-03 06:48:27 +01:00
Ludovic MarieandClaude Opus 5.5 0af227338a Merge main into feat/cosmetics-game-settings
Keeps both sides: main's resource pack browser switch and this branch's
auras switch and three-state Others setting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 06:45:27 +01:00
Ludovic Marie 54d4ec353f Merge pull request #8011 from arismoko/v2/ses_01M6FBE8DSM292BFVH973M3RPV-upstream-build-gen
🤖 Agent: requeued after the generated_host.rs conflict. The file is gone again; build.rs generates it from native_living_refs.rs, which already carries #7965's doc change.
2026-11-03 06:39:56 +01:00
Ludovic MarieandClaude Opus 5.5 122369e875 Merge origin/main into #8027 (after #8008 landed)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 06:30:17 +01:00
Ludovic MarieandClaude Opus 5.5 66f9eeec4b Merge origin/main into #8067; keep both uniform tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 06:29:30 +01:00
Ludovic Marie 44659ebbce Merge pull request #8068 from arslanarm/shaderpacks/pack-biome-ids
🤖 Agent: the biome uniform and BIOME_* constants number vanilla's biomes in Biomes' declaration order (forest 8, ocean 46), as measured in Iris with a probe pack, instead of by identifier; modded biomes follow after vanilla's 67.
2026-11-03 06:27:52 +01:00
Ludovic Marie 7044c1195c Merge pull request #8064 from arslanarm/shaderpacks/center-depth-readback
🤖 Agent: a shader pack's centreDepth readback now also runs for frames handed to the submit thread, so centerDepthSmooth tracks the current frame instead of a value several scenes old; without a pack the after-submit hook is unchanged.
2026-11-03 06:27:48 +01:00
Ludovic Marie e4d2f3806c Merge pull request #8066 from arslanarm/shaderpacks/pack-dimension-uniforms
🤖 Agent: ambientLight now reads the dimension type's ambient_light (Nether 0.1, End 0.25, as in 26.3's dimension_type data), so shader packs stop seeing 0 outside the Overworld; the Nether's worldTime and playerMood follow too.
2026-11-03 06:27:44 +01:00
Ludovic Marie f15a7591de Merge pull request #8008 from Teiwazik/older-servers/v26-1
🤖 Agent: joining 26.1 servers behind HORIZON_OLDER_SERVERS=1 (off by default): packet, registry and entity-data translation one release at a time; a 26.3 join never runs any of it.
2026-11-03 06:27:39 +01:00
Ludovic Marie 07be8ec341 Merge pull request #8039 from arslanarm/shaderpacks/entity-shadow-geometry
🤖 Agent: shader packs whose shadow program has a geometry stage now draw entities and block entities through it (Arc, Shrimple, Rethinking Voxels); shader packs only, off by default.
2026-11-03 06:27:36 +01:00
Ludovic Marie fb361528b8 Merge pull request #7988 from grkthegreat2018-bot/fix/7984-hide-server-address
🤖 Agent: with hideServerAddress on, a LAN row draws selectServer.hiddenAddress in place of host:port, as NetworkServerEntry.extractContent does.
2026-11-03 06:27:32 +01:00
Ludovic Marie ac033aedae Merge pull request #8026 from abit-foggy/fix/minecart-sound-parity
🤖 Agent: a minecart's rolling sound now loops (MixerBackend's attach always got looping=false) and a silent cart keeps its instance; the remote displacement-velocity hunk was dropped.
2026-11-03 06:27:27 +01:00
Ludovic MarieandClaude Opus 5.5 b04bd9a0e3 LAN stations: a grindstone or smithing click no longer crashes the server
Only the anvil menu has a data slot (its cost). lan_stations read data[0] for every station with a result slot, so a click in a grindstone or smithing table panicked the server thread (index out of bounds: the len is 0), which crash-looped the official server on 2026-11-03 at 05:11Z (r6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 06:25:53 +01:00
Ludovic Marie acfe636f6a Merge pull request #8058 from abit-foggy/fix/fast-shutdown-7962
🤖 Agent: leaving a world no longer waits for unvisited chunks' generation and decoration (only in-flight saves), so the server drop goes from ~3.6 s to ~20 ms. The dropped jobs are proto chunks that regenerate identically. abort_generation runs only on the exit paths, never on an autosave.
2026-11-03 06:23:42 +01:00
Ludovic MarieandClaude Opus 5.5 c973edc8e6 Merge origin/main into #8011; generated_host.rs stays generated (#7965's doc change is in native_living_refs.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 06:23:11 +01:00
Ludovic Marie 84d0e87693 Merge pull request #8057 from GabrielNichols/test/leash-save-reload
🤖 Agent: a player's lead now saves its real holder UUID, so after Save and Quit the owner (or a LAN guest) can untie it again, and the mob only follows and measures its rope against that player, not whoever is nearest. Next time, please /claim the issue first: #6876 was claimed by glitchsnake half an hour before this opened.
2026-11-03 06:21:54 +01:00
Ludovic Marie cca859cf75 Merge pull request #8035 from arismoko/v2/ses_01M6FN1T2FS6T4M8P7GFXJ6WTR-launcher-allocator
🤖 Agent: launcher-built native games compile again: the generated main.rs uses horizon_threads::ALLOCATOR like the game's own entry point, and a test pins the two lines together.
2026-11-03 06:21:50 +01:00
Ludovic Marie 02e384ef34 Merge pull request #7911 from gigamans442/fix/glsl-audit-gaps-20261102
🤖 Agent: a shader array declared with an empty [] takes its size from its initializer, so packs with such arrays translate; main's .length() rewriting still runs for sized groups.
2026-11-03 06:21:46 +01:00
Ludovic Marie 894ee78253 Merge pull request #7916 from HrafnaValkyrja/fix/tag-add-kinds
🤖 Agent: /tag and /data on rockets, tridents and falling blocks now write to the same fields tags_of reads, instead of reporting success and dropping the change; main's Cloud arm kept.
2026-11-03 06:21:42 +01:00
Ludovic Marie f0521d347b Merge pull request #7965 from JustKarkat/fix/mob-body-slot
🤖 Agent: horses and llamas keep a head item and a body item at once, as vanilla's separate equipment slots do; #7844's /item and native-mod callers now use the plain head/body indices.
2026-11-03 06:19:47 +01:00
Ludovic Marie 2a8fda355b Merge pull request #7902 from grkthegreat2018-bot/fix/7848-player-catalyst-xp
🤖 Agent: a dying player's XP now goes to a sculk catalyst in hearing range, as for mobs (no orbs then), and the killer gets kill_mob_near_sculk_catalyst; main's ServerPlayer.die stats still follow.
2026-11-03 06:19:42 +01:00
Ludovic Marie 2e5fe6cd51 Merge pull request #8021 from HrafnaValkyrja/refactor/entity-join-sequence
🤖 Agent: every entity kind keeps a join sequence, so ticking-order checks (the wind charge's push) compare join order instead of list position; main's new wind test TNT got its join slot.
2026-11-03 06:19:39 +01:00
Ludovic Marie 61c411acf2 Merge pull request #8031 from glitchsnake/fix/7997-wither-skeleton-bow-shot-test
🤖 Agent: test-only: the wither skeleton test now also matches MobEvent::BowShot, which perform_ranged_attack emits for a bow shot since #7395, so it observes the arrow again.
2026-11-03 06:19:35 +01:00
Arslan ArmanulyandClaude Opus 5.5 f66014a84f Shader packs: smoothed uniforms take Iris's half-lives
Measured frame by frame in Iris 1.11.7 with a probe pack that logs each
frame's values into a buffer it never clears: centerDepthSmooth halves its
distance to the centre depth every 0.1 s, eyeBrightnessSmooth every 1 s and
is rounded down, wetness every 60 s rising and 20 s falling. Each is the
default half-life in ticks at 0.1 s a tick, twice what Horizon used.

Fixes #8062

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 10:06:30 +05:00
Arslan ArmanulyandClaude Opus 5.5 ddfe5750f7 Shader packs: number biomes as Iris does
Iris 1.11.7 numbers vanilla's biomes (the `biome` uniform and the `BIOME_*`
constants of custom uniforms) in the order vanilla's `Biomes` class declares
them: read back with a probe pack, all 67 constants and the uniform in four
scenes (forest 8, ocean 46, soul_sand_valley 60, the_end 62). Horizon gave
the biome's registry id, which follows identifier order (forest 22).

Biomes vanilla doesn't have come after vanilla's, in registry order.

Fixes #8063

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 10:06:30 +05:00
Arslan ArmanulyandClaude Opus 5.5 7ad86306ba Shader packs: read the centre depth back on frames submitted on the submit thread
`centerDepthSmooth` follows `depthtex0`'s centre texel, copied each frame into
one of three small buffers and read back later. The readback only started in
`PackPost::submitted`, which the renderer called after an in-place submission;
frames that go to the submit thread returned before it. The three buffers then
stayed copied and unread until a frame took the in-place path (a screenshot),
so the uniform kept its first value (1) or one from the last screenshot.

The readback now comes back as a closure, run right after the submission on
whichever thread submits, as culling's readback already is.

Fixes #8059

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 10:06:30 +05:00
Arslan ArmanulyandClaude Opus 5.5 c3b9e0056e Shader packs: ambientLight, playerMood and the Nether's worldTime
Read back exactly with a probe pack in Iris 1.11.7 and Horizon, same scenes:
- ambientLight is the dimension type's (0.1 in the Nether, 0.25 in the End);
  Horizon gave 0. The synchronized dimension type now carries its
  ambient_light (vanilla's defaults when the client knows the pack).
- playerMood and constantMood are the cave mood (BiomeAmbientSoundsHandler's
  moodiness); Horizon gave 0.
- worldTime reads 0 in a dimension type with no default clock (the Nether's;
  Level.getDefaultClockTime); Horizon gave the Overworld's time.

Fixes #8061

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 10:06:30 +05:00
arismoko 00681eb16b Merge maintainer upstream refresh without rewriting history 2026-11-03 05:01:18 +00:00
Ludovic Marie 674948d082 Merge pull request #8056 from horizon-rust/fix/salmon-default-medium
🤖 Agent: a salmon now starts MEDIUM in Mob::new like Salmon.defineSynchedData (fixes mob_attributes and mob_ai since #7651); the dispense fixture prints the 1501 lava-fizz S line again (use_item_on since #7652). mob_ai 272/272 and use_item_on 4000/4000 on the Mac mini.
2026-11-03 06:00:17 +01:00
abit-foggy 3e4749c702 fix(client): eliminate 10-15s shutdown delay on world exit (#7962)
When shutting down or exiting a world, unvisited chunk generation,
decoration, and meshing jobs remained queued and actively running across
worker threads. In Streaming::save, the server stalled waiting up to
30 seconds for background decoration and finishing to drain, and when
dropping Streaming, Registration::drop blocked joining all worker
threads that were actively computing unvisited chunk generation.

Match vanilla Minecraft's deactivateTicketsOnClosing() behavior by
introducing Streaming::abort_generation(), which drains pending
non-save jobs and marks the streaming pool aborted so worker threads
do not start new generation passes. In addition, only wait for durable
chunk writes (saving > 0) during shutdown and skip the artificial
guest disconnect sleep when no remote LAN guests are connected.
2026-11-02 19:00:03 -10:00
Ludovic Marie 3a1c7b7dfd Merge remote-tracking branch 'origin/main' into fix/salmon-default-medium 2026-11-03 05:56:28 +01:00
Ludovic MarieandClaude Opus 5.5 54d042a1ff Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:55:02 +01:00
Ludovic MarieandClaude Opus 5.5 880393c3ef Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:54:15 +01:00
Ludovic MarieandClaude Opus 5.5 16b477ee66 Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:53:36 +01:00
Ludovic MarieandClaude Opus 5.5 3016046942 Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:52:41 +01:00
GabrielNicholsandCodex 072c776cfa test(leads): strengthen real save and reopen regression evidence
Use a fixed persistent cow UUID, verify an empty hand and a closer
bystander, and record every stage before checking serialized UUIDs so
the baseline reaches the actual post-reopen release attempt.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-03 01:52:37 -03:00
GabrielNicholsandCodex 38814b6168 client(leads): keep absent player holders distinct from other players
Restrict legacy owner recovery to the known offline Player sentinel.
Do not substitute a nearby player when the saved holder is absent, in
network synchronization or rope physics. Cover real holder selection,
arbitrary saved UUIDs and the physical pull outcome.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-03 01:52:37 -03:00
Ludovic MarieandClaude Opus 5.5 ee7413ce8f Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:51:57 +01:00
Ludovic MarieandClaude Opus 5.5 c5940f76fb Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:51:32 +01:00
Ludovic MarieandClaude Opus 5.5 7d22fa1a83 Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:51:14 +01:00
Ludovic MarieandClaude Opus 5.5 3791e031ee Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:50:55 +01:00
arismoko d3aae59b1f Merge upstream main after its verification fixes 2026-11-03 04:50:51 +00:00
Ludovic MarieandClaude Opus 5.5 8762cc83af Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:50:37 +01:00
Ludovic MarieandClaude Opus 5.5 6502ef8329 Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:50:18 +01:00
arismoko fec9e03f33 Merge remote-tracking branch 'upstream/main' into v2/ses_01M6FN1T2FS6T4M8P7GFXJ6WTR-launcher-allocator 2026-11-03 04:50:05 +00:00
Ludovic MarieandClaude Opus 5.5 6fe332faf9 Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:49:59 +01:00
Ludovic MarieandClaude Opus 5.5 a313ce9a34 Merge origin/main (main's CI fixed in #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:49:41 +01:00
Ludovic Marie eb545bab38 Merge pull request #8040 from horizon-rust/fix/villager-food-unknown-item
🤖 Agent: fixes main's three red checks: an item the loaded registry lacks now falls back to vanilla's villager food values (mod content tests no longer zero the bread), client_lib.rs is formatted, and six launcher tables get the Wardrobe strings.
2026-11-03 05:49:07 +01:00
arismoko 5e6d499849 Merge upstream main into build-time native API generation 2026-11-03 04:48:17 +00:00
Ludovic MarieandClaude Opus 5.5 f2ecf1e5bd Unsized salmon are medium; the dispense fixture reports the lava fizz
- Salmon.defineSynchedData starts DATA_TYPE at Variant.DEFAULT
  (MEDIUM). Horizon's Mob::new left the variant at 0 (small), so since
  #7651 sized the box by variant, a salmon finalizeSpawn never sized
  (probes, any constructor path) was half size (mob_attributes,
  mob_ai salmon scenarios).
- #7652 plays level event 1501's sound unshared (clients play it from
  the event); the dispense fixture now adds its S line from the event,
  as VanillaDispenserOracle does (use_item_on respawn anchor cases).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:47:07 +01:00
Ludovic Marie 9776bc71ad Merge remote-tracking branch 'origin/fix/launcher-ko-hr-wardrobe' into fix/villager-food-unknown-item 2026-11-03 05:31:19 +01:00
Ludovic MarieandClaude Opus 5.5 1fbc20a914 Format client_lib.rs's module list (main's verify fails rustfmt)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:30:54 +01:00
Ludovic Marie 0505267375 Merge remote-tracking branch 'origin/main' into fix/villager-food-unknown-item 2026-11-03 05:30:35 +01:00
Ludovic Marie 3d71cb9eda Merge pull request #8041 from horizon-rust/harness/save-roundtrip-egg-timer
🤖 Agent: merging: save_roundtrip compared EggLayTime, a timer that advances with every tick like the Age it already ignores; since #7881 the scene's NoAI chickens count it, so the two saves differed by the ticks between them. Only that field is added to the timing rule.
2026-11-03 05:27:43 +01:00
Ludovic Marie fab778bdab Merge pull request #7378 from yosifdheef/fix/baby-turtle-feet-z-fighting
🤖 Agent: you were right on point 3 and our review was wrong: BabyTurtleModel passes RenderTypes.entityCutoutCull (AdultTurtleModel passes entityCutout), and the entity_cutout_cull pipeline never calls withCull(false), so vanilla culls the whole baby. Culling the whole baby model through the bat's path matches that exactly. I merged main in; main's Option model needed the culls_back_faces check moved inside its map_or_else.
2026-11-03 05:26:36 +01:00
Ludovic MarieandClaude Opus 5.5 bf4190059b A villager's food falls back for items the loaded registry lacks
The villager_food table is process-wide and set by the last item registry
loaded. horizon-client-lib's mod content tests load content roots without
the vanilla items, which made bread worth 0 for the villager tests running
beside them (eating_fills_the_food_level_then_digests and two others failed
on main's verify). The table now holds every registry item, and an item the
registry doesn't know falls back to vanilla's four values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:25:49 +01:00
Loki 1e91875d43 Merge remote-tracking branch 'origin/main' into fix/7997-wither-skeleton-bow-shot-test 2026-11-03 13:25:35 +09:00
Ludovic MarieandClaude Opus 5.5 7456ee875f save_roundtrip: a chicken's egg timer advances like its Age
Since #7881 a NoAI chicken counts its EggLayTime down as vanilla's
does, so the scene's chickens are ticks apart between the two saves
(vanilla 329, Horizon 320; 7021 and 7012 for the one riding a boat),
the same timing difference Age and Time already have.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:24:32 +01:00
Ludovic MarieandClaude Opus 5.5 54550bf5b8 Launcher: Arabic, Hindi, Indonesian and Portuguese Wardrobe strings
Those four tables lack the Wardrobe page's 39 strings, so main's launcher
test fails on ar.json once Korean and Croatian are complete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:23:27 +01:00
Ludovic Marie 68b7aa1e64 Merge remote-tracking branch 'origin/main' into fix/launcher-ko-hr-wardrobe 2026-11-03 05:23:22 +01:00
Ludovic MarieandClaude Opus 5.5 a69ea2e687 Merge origin/main into the baby turtle cull
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:22:21 +01:00
Ludovic Marie d9e0eff57e Merge pull request #7839 from horizon-rust/feat/cosmetics-first-light
🤖 Agent: First Light launcher theme worn from the Wardrobe, behind the cosmetics flag; nothing changes unless a player equips it.
2026-11-03 05:21:25 +01:00
Ludovic Marie 86a60b1b96 Merge pull request #7860 from itarqos5/feat/pack-browser-screen
🤖 Agent: the browser is now an Extras switch (off = the game never contacts Modrinth), and fetched icons go through decode_image_bytes with the 4096² cap from #7856 instead of the PNG-only decoder; the add-failed line is translated (horizon.pack.addFailed). Also dropped an unused SEARCH_Y constant that failed clippy -D warnings.
2026-11-03 05:08:55 +01:00
yosifdheef 14b4dc72b0 Cull the baby turtle model like entityCutoutCull
BabyTurtleModel's render type is RenderTypes.entityCutoutCull, so vanilla culls the whole baby model. Mark turtle_baby#main with set_cull_back at load, the same path the bat takes (#7139), instead of detecting coincident opposite faces in every model.

The bat's cull path already writes SHADED_CULL_W, so the baby's feet keep their directional shading and only gain the cull. The other 202 coincident opposite faces in no-cull models stay as vanilla draws them.
2026-11-03 07:08:24 +03:00
Ludovic Marie 948a671953 Merge remote-tracking branch 'origin/main' into sweep/7860 2026-11-03 05:06:26 +01:00
arismoko 0ade9ae0e9 Respect catalog targets when generating Host helpers
Resolve the callee named by the catalog rather than deriving it from the public helper name. Keep native-method and module validation, and cover a helper that shares a hook name but targets its distinct ref method.
2026-11-03 04:05:03 +00:00
Ludovic MarieandClaude Opus 5.5 b1f26f7843 Drop the unused SEARCH_Y constant
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:04:59 +01:00
Ludovic MarieandClaude Opus 5.5 a66aa548e0 Merge the fixed #7857, #7856 and main into the pack browser screen
The Extras page gets the Resource pack browser switch, which nothing could turn on
before. Fetched icons go through decode_image_bytes, now that decode_bytes stays
PNG-only. A failed install shows horizon.pack.addFailed with the reason, not a
bare English line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 05:02:40 +01:00
Ludovic Marie 1b59a8fd36 Merge pull request #7857 from itarqos5/feat/modrinth-pack-client
🤖 Agent: I fixed four things. (1) The hand-rolled SHA-1 copied the whole download (up to about 1 GiB for a 512 MiB pack); it now uses skins::sha1 (the sha1 crate), and the FIPS vectors still pass. (2) Only https URLs from a reply are fetched, and an icon may be at most 2 MiB, not a pack's 512 MiB. (3) free_path returned the taken name after 999 numbers, which would overwrite a pack; it now reports that no name is free. (4) Windows device names (CON, AUX, COM1…) get a '_' prefix (JustKarkat's points). Clippy is clean and all 8 pack_browser tests pass.
2026-11-03 05:02:33 +01:00
Ludovic Marie 5cba26ca80 Merge pull request #8009 from glitchsnake/fix/7493-tutorial-craft-planks-hint
🤖 Agent: the planks hint now clears on the craft, through the slot packets vanilla's Tutorial.onGetItem listens to; the ContainerSetContent hunk is gone, so opening a chest of planks no longer clears it.
2026-11-03 05:02:28 +01:00
Ludovic Marie 6f015cac63 Merge pull request #7856 from itarqos5/feat/image-decoders
🤖 Agent: I made three changes. (1) decode_bytes stays PNG-only, since it also reads resource packs and vanilla's NativeImage.read rejects a JPEG or WebP texture; the new decoders sit behind decode_image_bytes, for fetched images. (2) The header alone sizes the buffer, so a fetched WebP or JPEG may claim at most 4096 x 4096 pixels (a 16k-square one would have asked for about 1 GB). (3) hud.rs had been converted to CRLF, which made the diff 7,956 lines; it's back to LF and the real change is about 150 lines. The new crates (image-webp, zune-jpeg, zune-core, byteorder-lite, quick-error) are all MIT/Apache/Zlib/Unlicense.
2026-11-03 05:01:04 +01:00
Ludovic Marie 45b73c0f10 Merge pull request #7773 from TheMrZZ/fix/aquatic-from-bucket-save
🤖 Agent: I added the other half: emptying a bucket now sets from_bucket on fish and axolotls (Bucketable.setFromBucket), and requires_custom_persistence reads it (AbstractFish and Axolotl: super || fromBucket()). A released fish now saves FromBucket:1b instead of the 0b this PR alone would have written, and a fish loaded with FromBucket:1b no longer despawns. Main is merged again.
2026-11-03 05:01:00 +01:00
Ludovic Marie d914be0e33 Merge sweep/7856 into the pack browser screen 2026-11-03 05:00:28 +01:00
Ludovic Marie a99df86864 Merge sweep/7857 into the pack browser screen 2026-11-03 05:00:28 +01:00
Ludovic Marie 1cf43d0ccc Merge pull request #8000 from grkthegreat2018-bot/fix/started-cleanly
🤖 Agent: an unclean start now resets only fullscreen and its resolution, as 26.3's Minecraft.<init> does, and keeps the resource packs; the dedicated server never writes startedCleanly.
2026-11-03 04:59:24 +01:00
Ludovic MarieandClaude Opus 5.5 f2d3c0bf74 Launcher: Korean and Croatian Wardrobe strings
The Korean and Croatian tables landed beside the Wardrobe and its cosmetics
errors, so main's launcher test finds 47 strings missing in both tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:57:57 +01:00
Ludovic MarieandClaude Opus 5.5 141b84daec Use as_chunks for the decoders' fixed-size pixel loops
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:57:12 +01:00
Ludovic MarieandClaude Opus 5.5 0eb514f9b6 Pack browser: the crate's SHA-1, https only, an icon cap, never a clobber
The hand-rolled SHA-1 copied the whole download for padding; skins::sha1 (the
sha1 crate) hashes in place. A reply names the URLs fetched next, so only https
ones are fetched, and an icon may be 2 MiB, not a pack's 512. free_path returned
the taken name after 999 numbers; it now says none is free. Windows device names
(CON, AUX, COM1...) get a prefix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:56:48 +01:00
Ludovic MarieandClaude Opus 5.5 5aa4c5260f Merge origin/main into #7902; the death's stats and scores run after the catalyst
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:55:34 +01:00
Ludovic MarieandClaude Opus 5.5 1daaf28e6f Keep pack textures PNG-only, cap fetched images, restore LF line endings
decode_bytes reads resource packs too, where vanilla's NativeImage.read takes PNG
only; the WebP and JPEG decoders now sit behind decode_image_bytes for images
fetched from the network. Their headers alone size the pixel buffer, so a fetched
image may claim at most 4096 x 4096 pixels. hud.rs had been converted to CRLF.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:55:12 +01:00
Ludovic MarieandClaude Opus 5.5 10431a035d Merge origin/main into #8021; keep fangs, give the wind test's TNT a join slot
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:54:29 +01:00
Florian ERNSTandCodex ba049fa675 Retain aquatic and summoned fang command functions
Co-Authored-By: Codex <[email protected]>
2026-11-03 04:53:57 +01:00
Ludovic Marie f105fc0d7b Merge pull request #7908 from MSedgeMC/feat/setidletimeout
🤖 Agent: /setidletimeout now changes the live timeout. Lists holds the only copy, and the tick, the management API and the command all read it, while both setters persist player-idle-timeout (DedicatedServer.setPlayerIdleTimeout). I resolved the same main merge independently, and it passed clippy and horizon-command's tests on the ThinkPad, so I'm taking your head as it is.
2026-11-03 04:53:37 +01:00
Ludovic Marie dc0c103c35 Merge pull request #7835 from TheMrZZ/fix/summoned-evoker-fangs
🤖 Agent: summoned and loaded evoker fangs now join the live fang list: they bite, save with their live Warmup, unload with their chunk, and /data and selectors see them. They also move with their level in levels.rs's swap now, so a dimension's fangs no longer tick in another one. I merged main (corpus union: all 55 cases kept).
2026-11-03 04:52:25 +01:00
Ludovic MarieandClaude Opus 5.5 af0e3b7145 multiplayer: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:52:17 +01:00
Ludovic Marie 87db06183d Merge origin/main into the FromBucket defaults 2026-11-03 04:51:55 +01:00
LokiandAntigravity 5a2e301990 mobs, test: observe BowShot in wither skeleton shooting test (fixes #7997)
Co-Authored-By: Antigravity <[email protected]>
2026-11-03 12:51:41 +09:00
Ludovic Marie f40926b31c Merge remote-tracking branch 'origin/main' into q/8026 2026-11-03 04:51:13 +01:00
Ludovic Marie 71aebbda74 Merge pull request #7869 from MSedgeMC/fix/enderman-offset-scale
🤖 Agent: a creepy enderman's shake is now 0.02 * state.scale, as EndermanRenderer.getRenderOffset has it (the old code hardcoded scale 1), so a scaled enderman shakes in proportion. This sits on #7868, which I merged in.
2026-11-03 04:51:13 +01:00
Ludovic MarieandClaude Opus 5.5 86a428c460 remote carts keep the server's motion for their sound
The server tracks minecart deltas (ServerEntity trackDelta, lan/tracking.rs)
and remote_motion sets the cart's velocity from ClientboundSetEntityMotion,
which MinecartSoundInstance reads as getDeltaMovement. Replacing it with the
lerped displacement changed the volume against vanilla; the silence came from
the dropped looping flag and the start that was never retried.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:51:13 +01:00
Ludovic MarieandClaude Opus 5.5 87cae1f6ff Merge origin/main into the live evoker fangs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:50:55 +01:00
Ludovic Marie 08ac2f1b20 Merge pull request #7847 from MSedgeMC/test/menu-quick-move-repeat
🤖 Agent: this pins vanilla's repeated quickMoveStack: one move_to pass leaves the remainder doClick's loop leaves (40 snowballs into two free slots keeps 8 in the chest, a full inventory moves nothing). I merged main beside #7840 and #7845's tests, and all 15 lan::menu tests pass.
2026-11-03 04:50:07 +01:00
Ludovic Marie ce3e7f7b30 Merge pull request #8019 from glitchsnake/fix/6903-number-format-half-up
🤖 Agent: format_fixed rounds the shortest decimal half-up, as Java's Formatter does, and the float yaw, pitch and difficulty are widened with as f64 first. That matches Formatter.print(float), which formats (double) value, so 0.125 → 0.13 holds for them too. It only runs while F3 or /data get is shown.
2026-11-03 04:49:58 +01:00
Ludovic Marie 3f4892420b Merge the model scale (main merged) into the enderman offset 2026-11-03 04:49:31 +01:00
Ludovic MarieandClaude Opus 5.5 0a5ac49f4c Merge main into mob body slots; #7844's call sites drop head_is_body
/item's worn-armour index and native_living_set_equipment still asked
head_is_body: with head and body kept apart, a mob's head is always armour
slot 0, and set_equipment no longer refuses a horse's head or a zombie's
body (vanilla's EntityEquipment has every slot). API docs follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:49:23 +01:00
Ludovic Marie 2a61fd1247 Merge origin/main into the model scale 2026-11-03 04:48:55 +01:00
Ludovic Marie 5eeee179a7 Merge remote-tracking branch 'origin/main' into q/7965 2026-11-03 04:48:36 +01:00
Ludovic Marie 48c10c6ad3 Merge pull request #7865 from TheMrZZ/fix/raid-captain-head-drop-chance
🤖 Agent: wear_banner now records head = 2.0 through set_drop_chance (Raid.setLeader and PatrollingMonster.finalizeSpawn), so a captain's drop_chances save like vanilla's. The other slots' explicit chances survive, and drop_chance still takes one value per slot, so the banner can't drop twice.
2026-11-03 04:46:48 +01:00
Ludovic MarieandClaude Opus 5.5 73eb5f84cf Merge main into setidletimeout; one idle timeout in Lists, persisted
Main keeps the live minutes for the management protocol (DedicatedServer.
setPlayerIdleTimeout, which also writes player-idle-timeout). /setidletimeout
and the management call now share Lists.player_idle_timeout through
set_player_idle_timeout, so both persist, as vanilla's command does through
the same method.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:46:24 +01:00
Florian ERNSTandCodex 5568bc9837 Retain aquatic and bird flap command functions
Co-Authored-By: Codex <[email protected]>
2026-11-03 04:46:17 +01:00
Ludovic Marie d4096af324 Merge pull request #7885 from jedev3095/fix/filesystem-safety
🤖 Agent: backup, copy and delete now take the same File::try_lock on session.lock that SessionLock::acquire takes, so a world open in a running game can't be copied mid-write. The external .mcc payload is now synced before its rename. Follow-up (JustKarkat's point): refusing a locked world shows the raw OS error, and it should be a translated launcher message.
2026-11-03 04:45:26 +01:00
Ludovic Marie 1fd1dcbc2c Merge remote-tracking branch 'origin/main' into q/7911
# Conflicts:
#	crates/horizon-shaders/src/translate/rewrite.rs
2026-11-03 04:44:17 +01:00
Ludovic Marie 5cd693c492 Merge pull request #7909 from gigamans442/fix/core-line-matrix-inputs-20261102
🤖 Agent: the source is fine: the matrix names come from the MIT shaders.properties docs at 98f84cbe456e, the same commit the clean-room spec cites as [IR]. The translator change binds only undeclared or mat4-declared projectionMatrix/modelViewMatrix, so a pack's own local or global of that name keeps its value; the GPU test covers both shadowing forms. It costs nothing per frame, since the binding happens at translate time.
2026-11-03 04:44:10 +01:00
Ludovic Marie 7d69a1b567 Merge pull request #7822 from TheMrZZ/fix/bird-flap-game-events
🤖 Agent: chickens and parrots now emit minecraft:flap after onFlap (strict flyDist > nextFlap), silent ones included, as Entity.processFlappingMovement does, so sculk sensors hear birds. The species re-check only scans mobs on flap events, not on steps.
2026-11-03 04:44:06 +01:00
Ludovic MarieandClaude Opus 5.5 626a0752c7 Merge origin/main into the quick-move spread test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:43:19 +01:00
Ludovic Marie c69188d7c8 Merge pull request #7963 from Joansitoh/fix/7876-spawn-rotation
🤖 Agent: the order matches vanilla's PrepareSpawnTask (loadedPosition.rotation().orElse(respawnData yaw/pitch)): face_world_spawn returns for a known player, so only a first join takes the angle, and head_yaw follows yaw as snapTo sets it.
2026-11-03 04:42:47 +01:00
Ludovic Marie 0b26a1568a Merge remote-tracking branch 'origin/main' into q/7916
# Conflicts:
#	crates/horizon-client/src/game/commands/data.rs
#	tools/lan_e2e.py
2026-11-03 04:42:20 +01:00
Ludovic Marie e8ba8ae2e5 Merge pull request #7844 from MSedgeMC/fix/scale-dims
🤖 Agent: every living entity's box and eye height now take getScale() through sanitizeScale (shulker capped at 3, happy ghast at 1), as LivingEntity.getDimensions does. The rider seat height now divides the scale back out for every mob, not only avatars, so seats on a scaled mob don't double-count it. This builds on #7827.
2026-11-03 04:42:14 +01:00
Ludovic MarieandClaude Opus 5.5 a0c532be2f Set FromBucket when a bucket lets a fish or axolotl out, and keep such mobs
AbstractFish and Axolotl.requiresCustomPersistence are super || fromBucket():
the bucket's setFromBucket(true) is what keeps them, and what a save writes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:41:50 +01:00
Ludovic Marie f91b3fe862 Merge origin/main into the FromBucket defaults 2026-11-03 04:41:19 +01:00
Ludovic Marie c06a83b1ca Merge pull request #7827 from MSedgeMC/fix/shulker-peek-dims
🤖 Agent: this matches Shulker.getDefaultDimensions for the floor face (scale(1, 1 + currentPeekAmount)), so the eye height is now exact. Remaining gap: vanilla's collision box is makeBoundingBox's progress box (1 + getPhysicalPeek in the open direction, on every face), while ours is built from these dimensions. A floor shell a quarter open is 1.25 tall here and 1.15 in vanilla, and the side and ceiling faces don't grow at all. Worth a follow-up. I merged main and fixed the wording: attach face DOWN is a shell sitting on the floor, not hanging under a block.
2026-11-03 04:40:26 +01:00
Ludovic Marie f4853229f4 Merge pull request #7991 from Teiwazik/older-servers/translate-entities
🤖 Agent: adapter-only (translate.rs). Reading 26.2's three-short move_entity layout and turning hand swings in animate into swing_animation keeps Horizon's 26.3 packet types unchanged for everything downstream.
2026-11-03 04:40:22 +01:00
Ludovic Marie 5fa2a59f90 Merge pull request #7967 from Teiwazik/older-servers/translate-world
🤖 Agent: everything here runs in adapter::translate, which only a 26.2 session calls; the 26.3 path is untouched. The global-palette repack goes from 15 to 16 bits because 26.3 has 35723 states against 26.2's 32366 (ceillog2 crosses 2^15).
2026-11-03 04:40:18 +01:00
Ludovic Marie 2c7186b8dc Merge pull request #7959 from Teiwazik/older-servers/registry-ids
🤖 Agent: this one touches the shared readers (item stacks, components, particles, entity data) for every connection, so I checked their cost on 26.3: ids::item and friends read a const thread_local Cell that is None unless a 26.2 session set it, one TLS load per id. DelimitedSlot re-encodes its stored patch only while renumbering, so a plain re-send still copies the bytes.
2026-11-03 04:40:15 +01:00
Ludovic Marie 2292b2ac58 Merge pull request #7828 from xFedeT/fix/villager-food-component
🤖 Agent: a villager's food now comes from the villager_food component in the loaded item registry, so data-pack and mod items count. I moved use_speed's doc line back onto use_speed (the new field had taken it) and merged main. Note: the table is process-wide, so the last registry loaded wins.
2026-11-03 04:40:11 +01:00
Ludovic Marie e0b55362b3 Merge pull request #7834 from MSedgeMC/fix/creaking-hurt-sound
🤖 Agent: this matches Creaking.getHurtSound (isHeartBound() ? creaking.sway : super): an unbound creaking now gets Monster's entity.hostile.hurt, and a giant gets the hostile hurt and death pair. I merged main and kept its can_use_portal beside the new tests.
2026-11-03 04:40:08 +01:00
Ludovic Marie ae8c4e5d17 Merge remote-tracking branch 'origin/main' into q/8011
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-03 04:40:06 +01:00
Ludovic Marie bc5cc36aed Merge pull request #7845 from MSedgeMC/fix/menu-may-pickup
🤖 Agent: a guest's pickup, quick move, swap, throw and double-click now ask Slot.mayPickup (a cursed helmet stays on outside creative), and drops ask Player.canDropItems. I merged main: pickup_all keeps doClick's 'clicked slot holds a stack it may take' early return and main's button order (0 from the first slot, 1 from the last).
2026-11-03 04:40:04 +01:00
Ludovic Marie d21194541a Merge the shulker peek height (main merged) into the scale dimensions 2026-11-03 04:38:42 +01:00
Ludovic MarieandClaude Opus 5.5 d74f58a23d Say floor for a shulker attached below (attach face DOWN)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:38:22 +01:00
Ludovic Marie d620a2c7e5 Merge pull request #7954 from Teiwazik/older-servers/wiring-pr
🤖 Agent: off unless HORIZON_OLDER_SERVERS=1, and with it off the reader and writer pay one None check per packet, nothing else. When on, older_server costs one extra status ping (5 s timeout) per join, and it skips friend codes. The session's state switches follow Connection's codec changes in each direction separately, which the second test pins.
2026-11-03 04:38:15 +01:00
Ludovic Marie 86e15a7309 Merge pull request #8006 from grkthegreat2018-bot/fix/8005-a11y-help-link
🤖 Agent: the footer order matches AccessibilityOptionsScreen.addFooter (link button, then Done), and the in-game path goes through ConfirmLinkScreen back to Options as vanilla's confirmLink does. The startup menu opens the link without confirmation, a known gap shared with that menu's other links.
2026-11-03 04:38:11 +01:00
Ludovic Marie 2e97d09997 Merge origin/main into the shulker peek height 2026-11-03 04:37:45 +01:00
Ludovic Marie d45d35a213 Merge pull request #7975 from GabrielNichols/mods/spawn-biome-modifiers
🤖 Agent: checked the output against 26.3's plains.json: spawners land in attributes[minecraft:gameplay/natural_mob_spawns].argument.spawns_by_category with count as an int or a minecraft:uniform object, the shape biome.rs already reads. One edge: an entity type missing from entity-types.json falls back to the misc category, but the entity_reg check skips such a spawner first, so that fallback never runs.
2026-11-03 04:37:41 +01:00
Ludovic Marie c4121518fd Merge pull request #7978 from rdbtCVS/t3/match-vanilla-brand-identity
🤖 Agent: checked the server side of the handshake: lan/conn.rs keeps the last minecraft:brand it receives before SelectKnownPacks, and the client's follow-up "horizon" goes out before its known-packs reply, so Horizon servers still turn on LOD, mod checks and horizon_clients_only. The test covers vanilla, horizon, Paper and a malformed brand.
2026-11-03 04:37:37 +01:00
Ludovic Marie 5b4aa54363 Merge pull request #7970 from jedev3095/fix/modpack-extract-size-cap
🤖 Agent: take(MAX_OVERRIDE + 1) is what tells an over-size entry from an exact 64 MiB one, and the running total now counts inflated bytes, not the zip's declared sizes. On failure the written files are removed, though directories create_dir_all made stay behind (harmless, empty).
2026-11-03 04:37:34 +01:00
Ludovic Marie 32ed42a18b Merge pull request #7971 from jedev3095/fix/relay-poisoned-locks
🤖 Agent: every lock in the relay now goes through locked(), including the per-byte total bucket in copy(). A panic can still leave inner.hosts or pending half-updated, but those entries go away on their own disconnect paths, which is better than every later connection panicking.
2026-11-03 04:37:31 +01:00
Ludovic Marie e4f8d9c34e Merge pull request #7972 from jedev3095/fix/server-token-os-rng
🤖 Agent: the token is still 32 hex digits, so same_token and stored tokens don't change. getrandom 0.3 was already in the lockfile through horizon-relay (MIT/Apache), so only a dependency edge is new.
2026-11-03 04:37:26 +01:00
arismoko 246f2a22e5 Fix native game allocator initialization 2026-11-03 03:36:34 +00:00
Ludovic Marie 95f86dffc6 Merge origin/main into the villager_food component 2026-11-03 04:35:56 +01:00
Ludovic MarieandClaude Opus 5.5 5165ff570b Keep use_speed's doc on use_speed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:35:56 +01:00
Ludovic Marie dc5ee0b5c1 Merge pull request #7973 from grkthegreat2018-bot/fix/7880-block-entity-validity
🤖 Agent: the wanted filter in block_edits.rs and replace_block_entities now test the same condition (name changed, or the entity there doesn't fit), so make_block_entities' order still lines up with the positions that take them. fits is an exhaustive match, so a new BlockEntity kind has to name its blocks. A same-name write over a missing entity now creates one, as LevelChunk.setBlockState's createBlockEntity does.
2026-11-03 04:35:53 +01:00
abit-foggy 63fea11740 Minecart sounds: interpolate remote cart velocity and retain silent instances
In vanilla Minecraft, minecart sounds (both riding and ambient cart rolling)
are continuously active sound instances whose volume scales with horizontal
speed squared, silencing when stopped or stationary.

In Horizon:
1. tick_remote_objects interpolated cart position when !lerp_cart(c)
   without deriving displacement velocity. Derive velocity from position delta
   so remote minecarts calculate speed for sound attenuation.
2. In start_cart_sounds, avoid permanently discarding minecart sound
   tracking if PlayResult::NotStarted is returned (e.g., audio engine busy
   or channels initializing), allowing retries.
3. In crates/horizon-client-render/src/audio.rs, ensure looping parameter
   is properly forwarded when caching/queuing sound decodes.
4. Added unit tests for minecart sound volume scaling, stopping on removal/
   dismount, and surface/underwater sound switching.

Fixes part of #7833 (minecart sounds).
2026-11-02 17:35:03 -10:00
Ludovic Marie 67dda9308c Merge pull request #7981 from grkthegreat2018-bot/fix/7977-in-world-mod-guard
🤖 Agent: the freeze is right to scope to content mods: mod_content.rs appends their block states in id order, so a mid-world toggle on the next stage_pack would shift every later mod's ids under loaded chunks. Native mods still toggle live through native_on, and the test covers both rows.
2026-11-03 04:34:44 +01:00
Ludovic Marie 17abc21c07 Merge pull request #7983 from grkthegreat2018-bot/fix/7982-ungray-working-options
🤖 Agent: checked that each of the six options has a reader on main: chunkFade feeds gfx fade_millis, improvedTransparency the OIT path, chatDelay remote.rs chat_delay_ms, saveChatDrafts chat.rs should_discard_draft, musicToast the now-playing toast, ctrlClickEmulatesRightClick the macOS-only click in game.rs. ChunkBuilder and the chat/Realms ones stay greyed: nothing reads them yet.
2026-11-03 04:34:40 +01:00
Ludovic MarieandClaude Opus 5.5 0262fa964b Merge origin/main into the guest menu's mayPickup and canDropItems
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:34:33 +01:00
Ludovic MarieandClaude Opus 5.5 11da34a239 Merge origin/main into the creaking and giant hurt sounds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:33:32 +01:00
Ludovic Marie 1c97d1e804 Merge pull request #7840 from MSedgeMC/fix/menu-offhand-armor
🤖 Agent: this matches InventoryMenu.quickMoveStack: slot 45 now reaches the equipment check, so off-hand armour is worn, and anything else takes the final else (9..45, main first). The fix is in the server's menu, so single player, LAN and dedicated servers all get it.
2026-11-03 04:33:26 +01:00
Ludovic Marie 77360cabfd Merge remote-tracking branch 'origin/main' into q/7988 2026-11-03 04:33:05 +01:00
Ludovic MarieandClaude Opus 5.5 d843195247 multiplayer: a hidden LAN address reads (Hidden), as NetworkServerEntry
Vanilla's extractContent draws HIDDEN_ADDRESS_TEXT (selectServer.hiddenAddress)
in the address line while hideServerAddress is on; it never leaves it blank.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:33:05 +01:00
Ludovic Marie b9a9e89ee7 Merge remote-tracking branch 'origin/main' into q/8000 2026-11-03 04:30:47 +01:00
Ludovic MarieandClaude Opus 5.5 606f4330eb startedCleanly: an unclean start leaves fullscreen, as Minecraft.<init>
Vanilla reads lastStartWasClean to reset fullscreen (and the graphics API);
resource packs roll back only when their load throws (rollbackResourcePacks),
not after an unclean start. The flag is a client's: a dedicated server writes
no options.txt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:30:46 +01:00
Ludovic Marie 117f48b2ef Merge pull request #7750 from levzzz5154/native/mob-split-baby-spawn
🤖 Agent: mob_split runs after vanilla's count draw, so the slime's random sequence is unchanged with or without mods. baby_entity_spawn is gated on a non-empty mod list, so breeding pays nothing without mods. I merged main and regenerated the hook API (both hooks stay pending, mod-api --check clean).
2026-11-03 04:30:27 +01:00
arismoko 72396f7ecb Merge upstream main into build-time native API generation 2026-11-03 03:29:20 +00:00
Ludovic MarieandClaude Opus 5.5 0c685576ea Merge origin/main into the mob split and baby spawn hooks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:28:28 +01:00
Ludovic Marie 4fb8de5153 Merge pull request #7641 from dontstopgorkingit/codex/memory-dedicated-models
🤖 Agent: Checked that nothing on the server reads mob geometry: every `kind.model`/`layer` user in `game/` and `mobs/` draws (item-in-hand mobs, perspective, CEM), so an empty `Part` there changes no gameplay. Single player is unaffected: the window loads with `draws` true.
2026-11-03 04:28:00 +01:00
Ludovic Marie 77b083a4c5 Merge pull request #7770 from Joansitoh/fix/6901-6902-math-round-float
🤖 Agent: /time's argument and /compute round now use one exact Math.round(float) (jmath::round_f32), so 0.49999997t gives 0 ticks, not the 1 that x + 0.5 in f32 gave. I merged main and kept its f64 jmath::round beside it.
2026-11-03 04:26:50 +01:00
Ludovic Marie 1bad2f188c Merge remote-tracking branch 'origin/main' into q/8009 2026-11-03 04:26:30 +01:00
Ludovic MarieandClaude Opus 5.5 6103b636b9 tutorial: leave ContainerSetContent alone, as vanilla's handleContainerContent
Vanilla calls Tutorial.onGetItem only from handleContainerSetSlot,
handleSetCursorItem and handleSetPlayerInventory: a full content sync (opening a
chest that holds planks) must not end the CraftPlanks step. Drop the two tests
that only re-ran their own loop over a local tag set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:26:29 +01:00
Ludovic MarieandClaude Opus 5.5 fe535ea52a Merge origin/main into the dedicated server's model memory
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:25:43 +01:00
Ludovic MarieandClaude Opus 5.5 e4ee23fab7 Merge origin/main into the exact Math.round(float)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:24:30 +01:00
Ludovic Marie b7330a07f7 Merge pull request #7819 from TheMrZZ/fix/h126-peaceful-summon
🤖 Agent: plain and NBT summons now both go through allowed_in_peaceful (EntityType.isAllowedInPeaceful), not the monster category. Peaceful now accepts what vanilla accepts and rejects what it rejects, on the dedicated server too. The new harness compares this against a real vanilla server.
2026-11-03 04:23:46 +01:00
Ludovic Marie 520cccb1de Merge pull request #7812 from HrafnaValkyrja/fix/wind-gaps
🤖 Agent: when an orb, falling block or TNT joined before the wind charge, its push is now held until its own tick (after tick_tnt), as vanilla's join-order tick list does. The per-tick cost is one empty-Vec check when no charge burst. Nothing else found.
2026-11-03 04:23:41 +01:00
Ludovic Marie bc8ac34aaf Merge pull request #8023 from horizon-rust/fix/sound-resolve-each-play
🤖 Agent: merging: fixes parity-main's sound_engine and music, both broken by #7479. A replayed sound instance resolves again as vanilla's getOrResolve does (it kept its first variant's volume), and with no track turned off the music manager runs vanilla's startPlaying as it is (it left no current music on an empty pick). Hot-path cost: none.
2026-11-03 04:23:04 +01:00
Ludovic Marie 0a02a4b0ab Merge pull request #7820 from xFedeT/fix/boat-coplanar-depth
🤖 Agent: the entity depth bias (128) fixes the boat floor flickering over a trapdoor and only offsets entity passes, not terrain. Nothing else found.
2026-11-03 04:23:00 +01:00
Ludovic Marie e1b5b49678 Merge pull request #7807 from ClickingMaster/fix/slot7-a
🤖 Agent: the drowned's go-to-water goal now picks only water blocks (block_name == minecraft:water), as vanilla's getWaterPos does (Blocks.WATER), so kelp, seagrass and waterlogged blocks no longer count. Nothing else found.
2026-11-03 04:22:56 +01:00
Ludovic Marie 158a6a2605 Merge pull request #7741 from oxi-does-dev/fix/container-hotbar-rebind-double-translation
🤖 Agent: the container hotbar check now asks about the raw key, so with off hand on Left Alt and hotbar 7 on F, pressing Left Alt no longer swaps slot 7. I fixed the key-release fixture to use the new 4-argument mapped_key and merged main.
2026-11-03 04:22:51 +01:00
Ludovic Marie 0cea4c3b09 Merge pull request #7777 from horizon-rust/feat/cosmetics-wardrobe-client
🤖 Agent: Wardrobe page in the top bar, flag off; the one conflict with main (extras.rs, Music page) was resolved keeping both sides. The risk is a missing tr() string; the launcher's i18n check covers it.
2026-11-03 04:22:47 +01:00
Ludovic Marie 0e9ad09d14 Merge pull request #7701 from HrafnaValkyrja/fix/stand-fluids
🤖 Agent: merged main beside #7552's climbing: tick_settle now takes a StandMedium (water or air) so a stand sinks in water and falls in air; clippy and the entity tests pass on ThinkPad.
2026-11-03 04:21:08 +01:00
Ludovic Marie 207a163488 Merge origin/main into the raw-key hotbar check 2026-11-03 04:20:19 +01:00
Ludovic MarieandClaude Opus 5.5 cc95214a42 Give the key-release fixture's mapped_key the raw key argument
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:20:19 +01:00
Ludovic Marie 3bb5f26cc0 Merge pull request #7789 from dontstopgorkingit/game-ur-lang
🤖 Agent: Lands the whole stack (#7718 to #7789). Kept only real translations: game tables without English copies; launcher tables with every string main asks for, English where untranslated. A script check removed 45 entries with letters from another script, 43 of them in ur_pk (Kannada, Bengali and Devanagari letters inside Urdu).
2026-11-03 04:16:08 +01:00
Ludovic MarieandClaude Opus 5.5 70290876ec Translations: keep only what's translated, and in the language's own script
- Game tables (hi_in, id_id, pt_pt, ur_pk): English copies dropped (the
  game falls back to en_us per key).
- Launcher tables (ar, hi, id, pt): every string main asks for, English
  where untranslated (the launcher test needs them all).
- Entries with letters from another script dropped, or reset to English
  in the launcher: 43 in ur_pk (Kannada, Bengali and Devanagari letters
  inside Urdu), one each in hi_in and the launcher's hi (Arabic letters).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:14:32 +01:00
Ludovic Marie 6c84152234 Merge pull request #7646 from kaicsm/main
🤖 Agent: Removed the server-side half of the random-model rules: `living.rs` read the rule-species lock and the block name for each mob each tick on the server thread, whose mobs never draw. The window's remote tick already keeps `standing_on` fresh. Also dropped the unrelated `/resourcepacks/` .gitignore line. Without a CEM pack, `posed_body` and the hand anchors fall back to the vanilla model and anchor.
2026-11-03 04:14:27 +01:00
LokiandAntigravity 8a2a0ee2f9 debug, data: format floats with Java half-up rounding (fixes #6903)
In vanilla Minecraft Java Edition 26.3, numbers displayed in F3 debug overlay lines and `/data get ... <scale>` are formatted with `String.format(Locale.ROOT, "%.Nf", value)`, which rounds the shortest decimal string representation half-up. Rust's standard `{:.N}` rounds the exact binary IEEE-754 value (ties to even), causing discrepancies on half-way ties.

Use `java_text::format_fixed` in `DebugEntryPosition`, `local_difficulty_line`, `DebugEntryPlayerSpeed`, and `/data get` `scale_text`.

Co-Authored-By: Antigravity <[email protected]>
2026-11-03 12:13:11 +09:00
Ludovic Marie a5c0893129 Merge remote-tracking branch 'origin/main' into sweep/7789 2026-11-03 04:13:04 +01:00
Ludovic Marie 41b9a8ac6e Merge pull request #7698 from senjinthedragon/shader-lines
🤖 Agent: Only packs with `gbuffers_line` or `gbuffers_basic` take the new path; without one the game draws its own outline, as before. Each edge is widened in screen space after the pack's transform, so a pack that scales its world buffers (SEUS PTGI HRR 3) no longer leaves a stray box in the corner. Merged main beside `clouds=off` and the depth copies.
2026-11-03 04:12:19 +01:00
TeiwazikandClaude Opus 5.5 c23a2c48ea Older servers: join 1.21.11 servers
1.21.11 (protocol 774) gets its packet and registry tables and a step to
26.1's layout: chunk sections gain 26.1's fluid count, set_time's day
time becomes the overworld's world clock (with a world_clock registry
added at the end of configuration), entity data of AgeableMob types moves
past the entry 26.1 inserted, and entity data serializers are renumbered.
Serverbound, interact goes out as INTERACT_AT then INTERACT, attack as
interact's ATTACK, and punch as 1.21.11's swing. The session can now
queue packets for Horizon to read next and bodies to send after the
current one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 04:11:37 +01:00
Ludovic Marie 2aa89cb2e4 Merge pull request #7714 from hello23113/fix/dedicated-operator-stop-save
🤖 Agent: Moving `stop`/`autosave` into the shared `Lists` lets the console, the management protocol and an operator's command flip the same switch. A guest's `/save-all` waits for the end of their turn, so their swapped-in state never gets written to their own file. Merged main beside `/transfer`, #7279's live settings and the save-failed reply; the tree order is vanilla's: save-all, save-off, save-on, stop, transfer.
2026-11-03 04:10:33 +01:00
Ludovic Marie ec12e927b8 Merge pull request #7716 from levzzz5154/native/player-sleep-xp-enchant
🤖 Agent: Merged main, where `/experience add … levels` now goes through `give_levels`, so only points pass `xp_change`. That matches NeoForge, where `PlayerXpEvent.XpChange` fires in `giveExperiencePoints` and levels have their own LevelChange. `try_sleep` reports vanilla's first problem in vanilla's order before the hook can overrule it.
2026-11-03 04:10:28 +01:00
Ludovic MarieandClaude Opus 5.5 38822899a8 Merge origin/main into the pack's block outline (beside clouds=off and the depth copies)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:09:57 +01:00
Ludovic Marie dcb76e77d0 Merge pull request #7715 from levzzz5154/native/living-death-conversion
🤖 Agent: `living_death` fires once health is gone and before loot, exp and the death message, as NeoForge's `LivingDeathEvent` does, for mobs (`mob_died`) and server players (`guest_die`) alike. A mod that cancels has to restore health itself, as on NeoForge. Merged main and regenerated the Host files.
2026-11-03 04:08:31 +01:00
Ludovic Marie 19e83c3174 Merge remote-tracking branch 'origin/main' into fix/sound-resolve-each-play 2026-11-03 04:08:17 +01:00
Ludovic MarieandClaude Opus 5.5 358cd7b741 Music starts as vanilla's startPlaying when no track is turned off
Since #7479 the music manager picked the track itself and, when the
pick was empty, left no current music and drew a new delay; vanilla
sets the instance as current whatever it resolves to and the delay to
its maximum (music parity: warped_forest current, delay MAX). The Music
page's own pick now runs only for a row's play-now or when tracks are
off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:08:16 +01:00
Ludovic MarieandClaude Opus 5.5 29ed2d4c65 Merge origin/main into can_player_sleep and player_xp_change (levels via main's give_levels, points through xp_change)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:07:56 +01:00
Ludovic MarieandClaude Opus 5.5 0b7d43f575 Merge origin/main into the living death and conversion hooks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:06:31 +01:00
HrafnaValkyrja 062242390a Merge remote-tracking branch 'origin/main' into refactor/entity-join-sequence
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-02 22:06:18 -05:00
Ludovic MarieandClaude Opus 5.5 24b8135d5f Merge origin/main into the armor stand fluids (beside #7552's climbing)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:05:42 +01:00
Ludovic Marie e7f2e016ab Merge pull request #7671 from MSedgeMC/fix/container-destroyed-spills
🤖 Agent: The spill uses `BlockItem.onDestroyed`'s order: stacks come out in slot order, not the order they're stored in (the test feeds 20, 0, 3), and the box is left with an empty `container`. Merged main after #7645: the bundle path and the container path now share `ItemEntity::hurt`'s one dispatch.
2026-11-03 04:05:38 +01:00
Florian ERNSTandCodex aee5f68e3e Retain fang and loot command registrations from main
Co-Authored-By: Codex <[email protected]>
2026-11-03 04:05:18 +01:00
Ludovic MarieandClaude Opus 5.5 6ed62982f8 Merge origin/main into the dedicated server's /stop and /save-* (beside /transfer, #7279's settings and the save reply)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:04:05 +01:00
Ludovic Marie 2d1d9898fe Merge pull request #7999 from horizon-rust/sweep/7588-level-misc
🤖 Agent: Carries #7588 unchanged plus merges with main. Free without mods: `spawn_hooks()` is false, so `mobs_at` keeps borrowing the biome's list. Regenerated the Host files after #8003's note block hook.
2026-11-03 04:03:51 +01:00
Ludovic MarieandClaude Opus 5.5 7b33731271 Sound instances resolve their sound again at each play
#7479 made SoundEngine.resolve skip any instance that already had a
sound and an event, so a replayed instance kept its first variant and
volume (sound_engine parity: gain 1.0 where vanilla's replay picked a
0.4 variant). Vanilla's getOrResolve picks again every time; only the
Music page, which chooses the track itself, now keeps its sound
(SoundInstance.chosen).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:03:50 +01:00
Loki 9a74c0dcc9 Merge remote-tracking branch 'origin/main' into fix/7493-tutorial-craft-planks-hint 2026-11-03 12:03:47 +09:00
LokiandAntigravity 612053182d tutorial: clear craft planks hint when crafting planks (fixes #7493)
Co-Authored-By: Antigravity <[email protected]>
2026-11-03 12:03:44 +09:00
Florian ERNSTandCodex b04829a1ed Retain aquatic and villager naming test registrations
Co-Authored-By: Codex <[email protected]>
2026-11-03 04:03:24 +01:00
Ludovic MarieandClaude Opus 5.5 1e41e6f8a3 Merge origin/main into #7999 (after #8003)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:02:03 +01:00
Ludovic Marie e1ccd8969d Merge pull request #7683 from Joansitoh/fix/7623-own-fire-flag
🤖 Agent: Holding `fire_ticks` at ≥1 while the server's flag 0 is set matches `Entity.isOnFire`, which reads the synched flag on a client. Clearing on a flag drop and on respawn/login keeps a stale flag from burning the window forever.
2026-11-03 04:01:50 +01:00
Ludovic Marie af1cac1517 Merge pull request #7994 from horizon-rust/harness/deterministic-flakes
🤖 Agent: merging: boat_submerged, command_exec's piglin barter and fishing_hook_entities depended on timing or vanilla's unseeded random. Each now checks the same rule deterministically, and a vanilla run the harness itself rejects is redone. Horizon's side is never retried.
2026-11-03 04:01:45 +01:00
Ludovic Marie cdff607655 Merge pull request #8007 from horizon-rust/fix/main-green-mobai
🤖 Agent: merging: fixes parity-main's mob_ai (272/272 scenarios with it, 267 on main). A NoAI horse now runs AbstractHorse.aiStep's draws as vanilla does, and the breeze's inhaling/sliding particle arms are back in vanilla's order (#7573 had swapped them). Hot-path cost: none, only NoAI horses do the extra work.
2026-11-03 04:01:42 +01:00
Ludovic Marie e9af0fbde0 Merge pull request #7705 from fy2ne/fix/7660-uuid-from-text-multibyte-panic
🤖 Agent: The byte-level `is_ascii_hexdigit` check removes the panic from slicing multi-byte text at byte 32, and it rejects the `+` that `from_str_radix` accepted. JustKarkat's note stands: undashed 32-hex input is still accepted, unlike `UUID.fromString`, and that's left for another PR.
2026-11-03 04:01:38 +01:00
Ludovic Marie 027ec56759 Merge remote-tracking branch 'origin/main' into sweep/7646 2026-11-03 04:01:35 +01:00
Ludovic MarieandClaude Opus 5.5 15bbf5c3fe CEM rules: only the window looks up the block under a mob
The server's mobs never draw, so the server thread no longer reads the
rule species lock and the block name for each mob each tick; the
window's remote tick already keeps standing_on fresh. Also drops an
unrelated .gitignore line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:01:35 +01:00
Ludovic Marie 925ed105c2 Merge pull request #7651 from MSedgeMC/fix/salmon-box-size
🤖 Agent: The salmon scale (0.5 / 1.0 / 1.5) is applied in `dims()`, so collision, the hitbox and the client's box all follow the variant, as `Salmon.getDefaultDimensions` does.
2026-11-03 04:01:32 +01:00
Ludovic Marie 62c898b2fa Merge pull request #7708 from testatanucleare13-create/fix/7654-mouse-inputs
🤖 Agent: Fixed verify: `tests/test_world_input_capture.py` compiles `set_capture` against a stub `Game`, which needed the new `capture_mode` field (and a `Copy` `CursorGrabMode`). The re-grab is Linux-only and runs on clicks and wheel events while playing, never per frame. Merged main.
2026-11-03 04:01:27 +01:00
Ludovic MarieandClaude Opus 5.5 f93c928d72 Merge origin/main into the shulker box spill (after #7645)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 04:00:52 +01:00
grkthegreat2018-botandDevin 681854c5b0 Accessibility page gains the Accessibility Help footer link
`AccessibilityOptionsScreen.addFooter`: vanilla''s footer has an
"Accessibility Help" button (`options.accessibility.link`) opening
`CommonLinks.ACCESSIBILITY_HELP` through `ConfirmLinkScreen`. The page
takes the left footer slot the Mods and Shader Packs pages use; the
event drives the same `confirm_trusted_link` path as the pause
screen''s bugs and feedback links, returning to the options screen.
The pre-game Loading menu has no screen stack for the confirm, so it
opens the link directly, as its other links do.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 21:59:51 -05:00
Florian ERNSTandCodex 232a6cce42 Retain bird flap and loot command registrations from main
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:59:05 +01:00
Ludovic Marie 279729932a Merge remote-tracking branch 'origin/main' into sweep/7708 2026-11-03 03:58:41 +01:00
Ludovic MarieandClaude Opus 5.5 90b9f401ae Input capture test: the fixture's Game has capture_mode
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:58:40 +01:00
Ludovic Marie d79184927e Merge pull request #7598 from HrafnaValkyrja/fix/cart-portal-player
🤖 Agent: The proof is a real-protocol comparison against a vanilla 26.3 dedicated server, and the arrival speed is clamped to ±0.4 like `OldMinecartBehavior.getKnownMovement`. The change sits in `server_travel.rs`, so single player, LAN and dedicated all get it.
2026-11-03 03:58:12 +01:00
Ludovic Marie fb1bb011ad Merge pull request #7612 from masa-masa-0314/fix/single-biome-generator
🤖 Agent: This now reads every world's saved level stem, not just single-biome ones. That is safe for default worlds: their stems are references (`minecraft:overworld` noise settings, the multi_noise preset) that resolve as the preset did, and a data pack's stem still wins first. One effect to know: a Horizon world created with a single biome before this fix gets that biome only in chunks generated from now on.
2026-11-03 03:58:08 +01:00
Ludovic Marie d48ca3696e Merge pull request #7609 from TheMrZZ/fix/signed-in-love-save
🤖 Agent: The one-line clamp removal is right: `Animal.addAdditionalSaveData` writes `inLove` as is, and the reader already kept the signed value, so a save round-trips -32769.
2026-11-03 03:58:04 +01:00
Ludovic Marie ec563ddec0 Merge pull request #7601 from TheMrZZ/fix/villager-command-name
🤖 Agent: An unemployed villager gets `entity.minecraft.villager.none`, which en_us reads as "Villager", so only employed villagers change name; CustomName still wins. Folding the Mob arm into `command_name` also removes the double scan of `mobs` for a named mob.
2026-11-03 03:57:59 +01:00
Ludovic Marie bb4f5939d1 Merge pull request #7606 from TheMrZZ/fix/h040-loot-set-damage
🤖 Agent: The new `set_damage` arm matches horizon-world's `loot.rs` formula (float + add base, clamp, floor of `(1 - remaining) * max`). It also checks unbreakable/max_damage before drawing, so an ineligible stack leaves the random sequence untouched, as `SetItemDamageFunction.run` does.
2026-11-03 03:57:55 +01:00
Ludovic Marie d166b1e473 Merge pull request #8003 from horizon-rust/sweep/7597-note-block-change
🤖 Agent: Carries #7597 unchanged plus a merge with main. A replacement outside 0–24 keeps vanilla's proposed note, so a buggy mod can't write an invalid NOTE state.
2026-11-03 03:57:51 +01:00
Ludovic Marie d766cce5fb Merge pull request #7776 from horizon-rust/feat/cosmetics-wardrobe-api
🤖 Agent: launcher-only client for the founder set (claim, equip, Show my cosmetics) with an offline change queue in <data>/wardrobe/<uuid>.json; the Minecraft token goes to Mojang only, the service's session is kept in memory for the call. pr_ready --quick passes on this head with main merged in.
2026-11-03 03:57:47 +01:00
Ludovic Marie f9cf0e77ec Merge pull request #8002 from horizon-rust/feat/cosmetics-worn-cache
🤖 Agent: worn.json is written only by the fetch thread when an answer changes it and read only after a failed request, so the frame path is untouched; the new test fails if the restore step is removed. pr_ready --quick passes on this head.
2026-11-03 03:57:44 +01:00
TeiwazikandClaude Opus 5.5 294766b52a Older servers: join 26.1 servers
26.1, 26.1.1 and 26.1.2 (protocol 775) get their packet and registry
tables, and translation goes a release at a time: a 26.1 packet is
brought to 26.2's layout first, then on through 26.2's translation, with
ids mapped straight to 26.3's. The 26.1 step adds login_finished's
session id and login's account check, reorders team parameters (an
optional TeamColor in 26.2), and tells a 26.1 server the entity a
spectator picked by its UUID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 03:52:07 +01:00
Ludovic Marie 32d6954fb0 Merge pull request #7568 from UMCEKO/models/obj-loader
🤖 Agent: Closed a path escape: an .obj's `mtllib` names its .mtl as raw text, so `mtllib ../..:x.mtl` reached files outside the content root, because `RootAssets` checked the path and not the namespace. Both parts must now be resource-location characters with no `.`/`..` step; there's a test for it. Merged main after #7567.
2026-11-03 03:51:00 +01:00
Ludovic MarieandClaude Opus 5.5 2dc683c693 Merge origin/main into #7999; the nearest-players test player gets a uuid
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:50:59 +01:00
Ludovic Marie a0231c458f Merge remote-tracking branch 'origin/main' into sweep/7588 2026-11-03 03:50:47 +01:00
Ludovic MarieandClaude Opus 5.5 3fbd97383a Merge origin/main into the note block change hook
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:49:23 +01:00
Arslan ArmanulyandClaude Opus 5.5 7538517d9d Shader packs: entities and block entities cast shadows through a geometry stage
A shadow program with a geometry stage loaded for terrain only: shadow_entities
and shadow_block (or `shadow` as their fallback) were skipped with "uses a
geometry stage (supported for terrain's shadow only)". Iris 1.11.7 draws
entities through it too (observed with a probe pack of our own).

Entities now get geometry inputs of their own: the vertex stage pulls each
corner of the run's quads from the entity vertices it already reads at set 1,
walking them as the quad indices do, and the run draws without indices, one
instance per triangle the stage can emit. Set 5 (terrain's draw indices) is
bound only for programs that read it.

Part of #6812

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 07:48:51 +05:00
Florian ERNSTandCodex f9aa01a38e Retain complete naming and fire resistance command groups
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:48:40 +01:00
Ludovic Marie ccf8f453fb Merge pull request #7980 from horizon-rust/ci/nightly-newest-green
🤖 Agent: merging. The Nightly pick now takes the newest commit a parity-main run passed on (whose checks passed), so a later failed or timed-out batch holds only its own merges instead of every Nightly. Risk: none for players: a failing merge still can't reach a Nightly, it only stops blocking the green commit before it.
2026-11-03 03:47:12 +01:00
grkthegreat2018-botandDevin 64bc1b0983 an unclean start rolls the pack selection back
Minecraft.<init> arms `startedCleanly` false while it builds and
onGameLoadFinished writes it back true; a start that still reads
false died mid-load, so rollbackResourcePacks drops the configured
packs instead of crashing the same way forever. Horizon read the key
but never wrote it, so a pack set that broke startup broke every
launch. Arm the flag after Settings::load, clear it on both success
paths, and roll the selection back only on an explicit false: an
options file that never had the key is a clean start, as the field
defaults true.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 21:47:10 -05:00
Florian ERNSTandCodex cb94a65bb3 Retain signed love and fire resistance command functions
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:46:48 +01:00
Ludovic MarieandClaude Opus 5.5 b9570161cd Merge origin/main into the potential spawns and village siege hooks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:46:03 +01:00
Ludovic MarieandClaude Opus 5.5 f1be113aa3 Obj assets: a location's namespace can't leave the content root
An .obj names its .mtl as raw text (mtllib), so a namespace such as
../.. reached files outside the content root. Both parts of the location
now need a resource location's characters and no . or .. step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:45:07 +01:00
Ludovic Marie 31837ce8bb Merge pull request #7581 from MSedgeMC/fix/thrown-projectile-fire
🤖 Agent: Works on the server thread too: a server's client ticks thrown projectiles through the same `ProjWorld` (`remote_projectile_tick`), so the window ignites its own copy and draws the flame without a synced flag. Not covered: a vanilla client joining a Horizon server, because the tracker sends no on-fire flag (bit 0) for projectiles yet. Merged main: `fire_tick` now runs before main's new first-tick shoot event.
2026-11-03 03:45:07 +01:00
Ludovic Marie 74939fd1ff Merge pull request #7579 from TheMrZZ/fix/mob-fire-resistance
🤖 Agent: The guard sits where `LivingEntity.hurtServer` checks `IS_FIRE` + Fire Resistance, after the species callbacks. Returning `reflected` keeps Ghast.hurtServer's true for a reflected fireball, and the test checks that a refused fire hit leaves no hurt cooldown behind.
2026-11-03 03:45:03 +01:00
Ludovic Marie 83f465c687 Merge remote-tracking branch 'origin/main' into sweep/7568 2026-11-03 03:44:26 +01:00
Ludovic Marie d0acf13caa Merge remote-tracking branch 'origin/main' into sweep/7581 2026-11-03 03:43:41 +01:00
Ludovic Marie 29d4c19b83 Merge pull request #7561 from levzzz5154/modapi/entity-data
🤖 Agent: Fixed both of Dyna's points. `add_tag`/`remove_tag` now return false for kinds with no tag storage yet (ominous item spawners, falling blocks, rockets) instead of a false success. An item entity's name is now its CustomName, else the stack's `item_name` (`ItemEntity.getName`), which also fixes `/say @e[type=item]`. Merged main again after #7559 and #7563.
2026-11-03 03:43:32 +01:00
Florian ERNSTandCodex be9d6143e7 Retain naming and item lifetime command functions
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:42:35 +01:00
Ludovic Marie e47b06db84 Merge remote-tracking branch 'origin/main' into fix/main-green-mobai 2026-11-03 03:42:27 +01:00
Ludovic Marie e652515adf Merge pull request #7586 from Xmilon/native/item-model-property
🤖 Agent: No cost without mods: vanilla's `minecraft:` properties never reach the hook, and with no trait mod loaded the custom condition reads false without a call. Merged main and updated #7509's test, which expected a mod's condition to collapse to `on_false` at decode time; now it stays a condition its mod answers per stack.
2026-11-03 03:42:04 +01:00
Ludovic Marie 0feba7364e Merge pull request #7583 from Xmilon/native/damage-host
🤖 Agent: Kept as `living_incoming_damage_v2` (fold reverted), so the shipped hook's contract is unchanged. v2 now fires after the victim's invulnerability guard (`Mob::refuses_damage`), so a mob in its hurt cooldown no longer reports a hit. Contains #7582.
2026-11-03 03:41:59 +01:00
Ludovic Marie 722975dddc Merge pull request #7582 from Xmilon/native/knockback-before-resistance
🤖 Agent: Kept as `living_knockback_v2` (fold reverted) so `living_knockback`'s shipped contract and mixin target stay unchanged, per the docs' versioning rule. With the attacker scope opened for mob hits and mob projectiles (`f98a0c6c1`), a zombie's sword now reaches `attacker_main`.
2026-11-03 03:41:55 +01:00
Ludovic MarieandClaude Opus 5.5 b2da6f2120 fishing_hook_entities: up to 5 vanilla runs, and a known flake
FishingHook's throw is randomised: on the loaded runner vanilla's own
cast missed its target three times in a row once. one_player now
reruns a rejected vanilla run up to 5 times, and the harness gets the
known-flake retry (#2451).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:41:50 +01:00
Ludovic MarieandClaude Opus 5.5 56e1959729 Merge origin/main into the EntityRef data (after #7559 and #7563)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:41:47 +01:00
Ludovic MarieandClaude Opus 5.5 1ef5e04dc1 Item model test: a mod's condition stays a condition its mod answers
#7509's test expected a mod's condition property to collapse to on_false
at decode; with the item_model_property hook it is kept and on_false is
what draws when no mod answers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:39:59 +01:00
Ludovic MarieandClaude Opus 5.5 26fb5ee9a4 Merge origin/main into the conditional item model property
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:38:59 +01:00
Florian ERNSTandCodex 67d4f6e2b5 Retain fire resistance and item lifetime command cases
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:37:58 +01:00
Ludovic MarieandClaude Opus 5.5 6ca7684542 Back to living_incoming_damage_v2; merge #7582's restored v2
Reverting the fold keeps living_incoming_damage's contract unchanged. The
contract rule in docs/native-mods.md says a contract never changes and an
incompatible change adds a version. The invulnerability guard from
f152b155a stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:37:07 +01:00
GabrielNichols 663e6711a5 client(leads): preserve player leash holder UUID across save and reopen (#6876) 2026-11-02 23:36:28 -03:00
Ludovic Marie c50f82568b Merge pull request #7563 from levzzz5154/modapi/living-refs
🤖 Agent: Fixed both of Dyna's points. An air stack now empties the slot (`ItemStack.isEmpty`, as `/item` filters it). Setting a mob's head, chest, legs or feet now also replaces the worn armour stack `equipment_stacks` prefers, so a damaged iron helmet no longer reads back after a golden one is set; this covers `/item` too. Merged main and regenerated the Host files.
2026-11-03 03:36:23 +01:00
Ludovic Marie c605803fb6 Revert "Native mods: one living_incoming_damage hook with the attacker, weapon and geometry"
This reverts commit f856fbc00d.
2026-11-03 03:36:15 +01:00
Florian ERNSTandCodex 8e0056e26a Retain signed love and item lifetime command cases
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:36:05 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 4bc2182de8 Test entities from main carry a join number
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 21:35:50 -05:00
Florian ERNSTandCodex e5c89d1b03 Retain aquatic and item lifetime test registrations
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:34:48 +01:00
Ludovic MarieandClaude Opus 5.5 d74deb68c3 Back to living_knockback_v2; merge origin/main
Reverting the fold keeps living_knockback's contract unchanged. The
contract rule in docs/native-mods.md says a contract never changes and an
incompatible change adds a version, as menu_button_v2 did, so a mixin
injection that targets native_living_knockback keeps working. The attacker
fix from f98a0c6c1 stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:34:11 +01:00
Ludovic MarieandClaude Opus 5.5 512b885323 Close the fireball fire test the merge cut short
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:32:50 +01:00
arismoko 616e855940 Share mod-owned generation and move dispatch tests to its crate
Delegate launcher trait, event and helper generation to horizon-mod-api-gen. Keep the old-source compatibility path, move dispatch fixtures out of xtask into an unpublished workspace member, and select launcher checks when shared generator code changes.
2026-11-03 02:31:27 +00:00
Ludovic Marie 0942322e5c Revert "Native mods: one living_knockback hook, with the attacker and what it holds"
This reverts commit 13a2129a80.
2026-11-03 03:31:27 +01:00
Ludovic Marie ee4184f66e Merge pull request #7570 from TheMrZZ/fix/item-pickup-delay
🤖 Agent: The i64 field is wider than vanilla's int, but saving clamps it to a short, so the legacy `u32::MAX` sentinel still saves as 32767 and loads back infinite. Players' `== 0` pickup check keeps refusing a -1 delay, as `ItemEntity.playerTouch` does.
2026-11-03 03:31:22 +01:00
Ludovic Marie 1ea1346618 Merge pull request #7567 from UMCEKO/models/composite-loader
🤖 Agent: Vanilla models carry no `loader` key, so their resolve path is the old one plus an empty fallback map; only `neoforge:composite` adds work. The nesting cap (`MAX_LOADER_DEPTH` 8) together with the per-chain cycle check stops a composite that names itself through a child's parent.
2026-11-03 03:31:18 +01:00
Florian ERNSTandCodex 54670b44a4 Retain aquatic flags and climbing command groups from main
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:31:13 +01:00
Florian ERNSTandCodex 49df4281ca Retain raid captain and guest attacker LAN checks from main
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:27:03 +01:00
Ludovic MarieandClaude Opus 5.5 3dbff51e39 Merge origin/main into the thrown projectile fire
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:26:55 +01:00
grkthegreat2018-botandDevin 189d73cb52 Read hideServerAddress on the multiplayer LAN rows
Vanilla's LanServerEntry hides the host:port while the option is on; the key was persisted but never read.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 21:25:39 -05:00
Florian ERNSTandCodex 4855f3f7a8 Retain signed InLove and climbing command cases from main
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:23:26 +01:00
Ludovic MarieandClaude Opus 5.5 50f8a8e62b Merge origin/main into LivingRef; empty air stacks, replace worn armour
- set_equipment treats an air stack as empty (ItemStack.isEmpty), as /item does.
- Setting a mob's head, chest, legs or feet also replaces the worn armour
  stack it keeps, so the previous piece (a damaged helmet) no longer reads
  back after the write, through this API and /item alike.

Both from Dyna's review on #7563.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:22:57 +01:00
Ludovic MarieandClaude Opus 5.5 9e04a59177 Merge origin/main into the EntityRef data; fix add_tag and an item's name
- add_tag/remove_tag return false when the entity's kind keeps no compound
  for its tags (ominous item spawners, falling blocks, rockets) instead of
  reporting a write that never happened.
- An item entity's name is its CustomName, else its stack's item_name,
  else the item's name (ItemEntity.getName), for /say and the handle alike.

Both from Dyna's review on #7561.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:20:27 +01:00
TeiwazikandClaude Opus 5.5 2819fb6a31 Older servers: translate 26.2's entities, items and advancements
A 26.2 server's entity moves (three shorts and on-ground), position
syncs (with motion), and hand swings (an animate action in 26.2, a
swing_animation in 26.3) are read in 26.2's layout; spawned entities,
falling block states and attributes are renumbered. Particles, explosions
and advancements (whose tab position 26.2 keeps inside the display) are
rewritten. Entity data, inventories, equipment, trades, sounds and
recipes are decoded with 26.2's ids renumbered as they are read, and the
creative slot and container clicks go back in 26.2's numbering.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 03:20:05 +01:00
Florian ERNSTandCodex 2d2b0c74bf Retain fire-resistance and climbing command cases from main
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:19:44 +01:00
Ludovic Marie d2da9d64cb Merge pull request #7559 from levzzz5154/modapi/level-reads
🤖 Agent: Fixed Dyna's finding: `is_loaded` now returns false outside the level's height, as vanilla's `Level.isLoaded` does through `isInValidBounds`, so it agrees with `block_state` at y=400. Merged main and regenerated the Host files (`xtask mod-api --check` clean).
2026-11-03 03:19:37 +01:00
arismoko 8f6fe50c47 Derive mod API receivers from public Ref structs
Drop the shared HANDLES registration list. Reuse the source export walk, infer snake_case receiver keys, and keep unknown-receiver errors and duplicate-key diagnostics.
2026-11-03 02:19:34 +00:00
Ludovic MarieandClaude Opus 5.5 940c621243 Merge origin/main into the LevelRef reads; is_loaded checks the height
is_loaded now returns false outside the level's height, as vanilla's
Level.isLoaded does through isInValidBounds (review on #7559). Host files
regenerated with xtask mod-api.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:17:36 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 261be9b70e Merge origin/main into fix/cart-portal-player
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 21:16:33 -05:00
Ludovic Marie 1459a2c3bb Merge pull request #6804 from NerfPhaselock/perf/gpu-culling
🤖 Agent: Off by default and free while off: records, uploads and the frame pass all sit behind `gpu_culling.is_some()`. One thing to watch: `open_backend` now requests `MULTI_DRAW_INDIRECT_COUNT` on every adapter that offers it, so a driver bug in that feature would reach players who never turn GPU culling on. (Merged main: a comment conflict beside the new FLOAT32_FILTERABLE feature.)
2026-11-03 03:16:15 +01:00
Florian ERNSTandCodex 961ca6713a Merge main into signed item lifetime fix
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:16:03 +01:00
Florian ERNSTandCodex 8340c96527 Retain naming and climbing corpus groups from main
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:15:05 +01:00
arismoko 9116ac3911 Generate the native mod API into Cargo OUT_DIR
Build every profile from the catalog, fragments and handwritten signatures. Keep xtask for filling handwritten placeholders, and stamping for catalog data. Stage launcher-owned declarations as build input, retaining shared injection validation for older source trees with committed generated traits.
2026-11-03 02:14:54 +00:00
Ludovic Marie 1ef9a9e5a2 Merge pull request #7546 from MSedgeMC/fix/dimension-origin
🤖 Agent: Matches `DimensionOriginStructurePlacement.isStructureChunk`; merged with main's new spacing/frequency validation (`dimension_origin` sits beside it in the placement match). The spawn search in `spawn_column` runs at generator setup only when a set uses `dimension_origin`.
2026-11-03 03:14:49 +01:00
Ludovic MarieandClaude Opus 5.5 3c6fae7cb9 Merge origin/main into GPU culling (1/7)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:12:59 +01:00
Ludovic Marie 1bad9d0ae4 Merge pull request #7552 from TheMrZZ/fix/armor-stand-climbing
🤖 Agent: Matches `LivingEntity.handleOnClimbable` (±0.15f clamp, no slide stop for a non-Player) and the post-collision 0.2 lift. Per tick it adds one `on_climbable` lookup per stand before the move, plus one more after a horizontal collision.
2026-11-03 03:11:32 +01:00
Florian ERNSTandCodex c36d8b9a86 Merge main into villager command name fix
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:11:28 +01:00
Ludovic MarieandClaude Opus 5.5 6fc6e71820 Merge origin/main into the dimension-origin placement
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:11:16 +01:00
grkthegreat2018-botandDevin 0876230caa Stop greying out options that already work
available() predated the features: ChunkFade, ImprovedTransparency, ChatDelay, SaveChatDrafts, MusicToast and CtrlClickEmulatesRightClick all have readers now.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 21:10:06 -05:00
Ludovic MarieandClaude Opus 5.5 f751161844 Merge origin/main: armor stand climbing in the split travel
Main split ArmorStand::tick into tick_move and tick_settle around the
bubble-column push (#7669). The climbable lift stays at the end of
tick_move, before that push, and tick_move takes the climbing registry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:09:40 +01:00
Florian ERNSTandCodex 5601c8b935 Adapt fang loading to shared entity loader
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:08:15 +01:00
Ludovic Marie f4765be8a6 Merge remote-tracking branch 'origin/main' into harness/deterministic-flakes 2026-11-03 03:07:37 +01:00
Ludovic Marie a9d66a5453 Merge pull request #7471 from HrafnaValkyrja/refactor/entity-walk-loads
🤖 Agent: Merged with main's UUID bookkeeping carried into the moved loaders; the every-kind attachment reload test passes. Static dispatch through `load_lists!` costs one string compare per listed kind per loaded entity, the same as the old match.
2026-11-03 03:06:23 +01:00
rdbt 13b60a9e96 Merge pull request #7956 from JustKarkat/fix/dim-type-has-ceiling
Read dimension-type has_ceiling
2026-11-03 02:05:41 +00:00
rdbtCVSandOpenAI Codex 19ba515a09 Merge main and retain both matrix aliases and runtime array lengths
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 02:05:40 +00:00
Florian ERNSTandCodex 688963da78 Retain cloud and fang targets after main advances
Co-Authored-By: Codex <[email protected]>
2026-11-03 03:05:08 +01:00
Ludovic MarieandClaude Opus 5.5 fd96c20da1 Merge origin/main into the entity-trait loading
Main added remember_uuid to the orb, arrow, fireball, throwable, falling
block, dragon and crystal loaders (#7398, #7688); those bodies now live in
the moved Game::load_* methods, which take the saved UUID for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:04:20 +01:00
Ludovic MarieandClaude Opus 5.5 2518376ee0 Harnesses: retry rejected vanilla runs, observe boat dips at once, fixed piglin barter check
- one_player: vanilla's run is judged by the harness's check before the
  comparison, and run again (up to 3 times) when it is no proof, so a
  vanilla bobber that missed its armor stand on a loaded runner no
  longer reads as a Horizon diff (fishing_hook_entities). Horizon's run
  is never retried.
- boat_submerged: the short dip and the re-submersion are observed at
  the end of their own 20-tick step, not a step later; at ~40 ticks plus
  the client's syncs a loaded host pushed them past the 60 that eject.
- command_exec piglin pickups: what an adult's barter throws comes from
  vanilla's unseeded level random and the piglin picks some of it back
  up; check that none of the gold lies on the ground instead of the
  item count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:03:22 +01:00
Ludovic Marie 06403637e0 Merge pull request #7475 from TheMrZZ/fix/7438-generated-cloud-selectors
🤖 Agent: Requeued after merging main; the only conflict was the corpus file. `creeper_cloud_selectors` waits 330 ticks for the generated cloud to expire, which adds that much to the corpus run.
2026-11-03 03:02:37 +01:00
grkthegreat2018-botandDevin ebb2a5b12e Freeze content-mod toggles while a world is open
A pack reload rebuilds the mod block-state table from the live enabled set; a mid-world toggle remaps ids under loaded chunks.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 21:02:32 -05:00
rdbtCVSandOpenAI Codex 8116d97407 Compose enderman offsets with the reviewed model-scale changes
Keep both offset and model-matrix regressions, with avatar scale applied once.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 02:02:09 +00:00
Ludovic Marie 0548638dfd Merge remote-tracking branch 'origin/main' into sweep/7475
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-03 03:01:01 +01:00
Ludovic MarieandClaude Opus 5.5 32224fb97d Fix NoAI horses' aiStep and breeze ground-particle draws to match vanilla
- A NoAI horse still runs AbstractHorse.aiStep: tail swish, healing,
  grazing (nextInt 200/900/300) and its animations (#7263's new
  scenarios diverged at tick 1).
- Breeze.tick: shooting, inhaling and standing draw 1 + nextInt(1)
  crumbs, sliding emits twenty without a draw; #7573 had inhaling and
  sliding swapped, which shifted every breeze's random.
- mob_ai probe: a baby starts at its species' getBabyStartAge (a
  sniffer's -48000), as AgeableMob.setBaby does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:00:52 +01:00
rdbtCVSandOpenAI Codex 8a8a435141 Merge main and retain scale after polar-bear dimensions
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 02:00:37 +00:00
rdbtCVSandOpenAI Codex 271fe1b8ca Compose model rendering with the reviewed dimension fixes
Preserve the single application of avatar scale in the shared base transform.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:59:03 +00:00
HrafnaValkyrja f1d2e19f74 Merge remote-tracking branch 'origin/main' into refactor/entity-join-sequence
# Conflicts:
#	crates/horizon-client/src/entities.rs
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/projectiles.rs
2026-11-02 20:59:00 -05:00
Ludovic Marie 7e5a520e3d Merge pull request #7319 from Stink-O/native/block-sprites
🤖 Agent: `MeshTables::tint_source` reads the same `info.tints` table as `default_tint`, so JustKarkat's drift worry only applies if a new `Tint` variant is added without a `TintSource` arm, and the exhaustive match will refuse to compile then. Game-side cost is one extra sampler and keeping the sprite views.
2026-11-03 02:57:51 +01:00
Ludovic Marie 02bd59f0de Merge pull request #7279 from TiggerTakkyTTT/fix/server-management-pause-when-empty
🤖 Agent: pause_when_empty_seconds is read from live state every tick like vanilla's tickServer; merged next to the live player_idle_timeout setting.
2026-11-03 02:56:00 +01:00
Ludovic Marie e2d6b99ab8 Merge pull request #7477 from CocofireHD/feature/hard-memory-cap
🤖 Agent: Opt-in commit budget; off, the allocator wrapper is one relaxed load per call. Off Windows the budget counts mimalloc's commit only (now in the docs).
2026-11-03 02:55:56 +01:00
rdbtCVSandOpenAI Codex c80134158e Merge main and preserve species dimensions before model scaling
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:55:51 +00:00
Ludovic Marie f2f8050726 Merge pull request #7712 from dontstopgorkingit/mandarin-lang
🤖 Agent: Launcher Chinese: 92 English strings filled, reviewed text untouched; 5 stay English because the launcher test needs every string.
2026-11-03 02:55:51 +01:00
Ludovic Marie ae83abccb6 Merge pull request #7729 from dontstopgorkingit/french-lang-update
🤖 Agent: Launcher French: 88 English strings filled, reviewed text untouched; 'Launcher' becomes 'Lanceur' as the game's French already says.
2026-11-03 02:55:47 +01:00
Ludovic Marie e0b39f5df1 Merge pull request #7780 from dontstopgorkingit/game-fr-lang
🤖 Agent: fr_fr fills 56 missing or English strings and keeps the 373 reviewed ones; Mailbox (a Vulkan present mode) and FPS deliberately stay English.
2026-11-03 02:55:43 +01:00
Ludovic Marie 577034f38e Merge pull request #7650 from dontstopgorkingit/croatian-lang
🤖 Agent: Korean and Croatian land together: game files hold only translated entries (37 English copies dropped), launcher tables follow main's 914 strings so the completeness test passes.
2026-11-03 02:55:39 +01:00
rdbtCVSandOpenAI Codex d7e2ee5be2 Compose scale with the reviewed salmon and shulker dimension fixes
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:54:23 +00:00
Florian ERNSTandCodex 8272b5f1f4 Merge main and retain active fangs in entity memory walks
Co-Authored-By: Codex <[email protected]>
2026-11-03 02:54:13 +01:00
Ludovic Marie af42724396 Merge pull request #7736 from horizon-rust/launcher-forge-versions
🤖 Agent: Forge's default build is the promotions' recommended one (66.1.0 for 26.3, not the newer 66.1.3), so a fresh Forge instance starts on the build Forge vouches for; installing Forge/NeoForge still answers "can't install yet" until #7740 lands.
2026-11-03 02:52:58 +01:00
rdbtCVSandOpenAI Codex c2c060f58a Merge main and apply model scaling after species dimensions
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:52:53 +00:00
rdbt 65d925ef72 Merge pull request #7669 from HrafnaValkyrja/refactor/entity-fluids-items
Items and armor stands take fluids from the entity trait
2026-11-03 01:52:38 +00:00
rdbt ca6a4c372c Merge pull request #7655 from TheMrZZ/fix/h049-flat-manual-placement
Keep manual flat placement available with structures disabled
2026-11-03 01:52:34 +00:00
rdbt ed1ae71f96 Merge pull request #7398 from arismoko/v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-entity-holders
Native mods: every entity holds attachments
2026-11-03 01:52:30 +00:00
Ludovic Marie 514e4af344 Merge pull request #7829 from horizon-rust/feat/cosmetics-name-tag
🤖 Agent: the name tag kind is format-only plus one match arm in the game (Slot::NameTag never drawn on the body); a name tag item with parts or a mark over 16×16 is refused at parse, so nothing new reaches the mesh path. pr_ready --quick passes on this head with main merged in.
2026-11-03 02:51:15 +01:00
rdbt b3ef85311a Merge pull request #7801 from gigamans442/feat/standard-pack-gpu-entities-20261102
Bridge standard shader packs to GPU entity posing
2026-11-03 01:51:11 +00:00
rdbtCVSandOpenAI Codex f12bb2ffa0 Compose shulker dimensions with the reviewed salmon fix
Keeps the small dimension fixes compatible as they merge in order.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:51:05 +00:00
rdbtCVSandOpenAI Codex 7e18c35d20 Merge main and preserve both shulker and polar-bear dimensions
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:49:38 +00:00
Ludovic MarieandClaude Opus 5.5 a66fead172 Nightly: build on the newest commit parity-main passed, not only after the newest run
A later failed or timed-out parity-main batch held every Nightly, even
though an earlier run had passed on a commit without the failing merges
(2026-11-01 19:09 to 2026-11-03: no Nightly). green_main_head(parity)
now takes the newest successful parity-main run whose checks passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 02:48:54 +01:00
rdbt cd5f7b5eea Merge pull request #7831 from gigamans442/fix/shader-depth-snapshots-20261102
Shader packs: preserve depth snapshot timing and stage demand
2026-11-03 01:48:46 +00:00
rdbtCVSandOpenAI Codex 1010f71806 Merge main and preserve repeated quick-move regression coverage
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:48:37 +00:00
Ludovic Marie 75315d8ae5 Merge remote-tracking branch 'origin/main' into sweep/7279
# Conflicts:
#	crates/horizon-client/src/game/dedicated.rs
#	tools/test_management_protocol_parity.py
2026-11-03 02:48:22 +01:00
Ludovic Marie e15d2e38fc Merge main into feat/cosmetics-game-settings
# Conflicts:
#	crates/horizon-client-render/src/extras.rs
2026-11-03 02:47:24 +01:00
Ludovic MarieandClaude Opus 5.5 7a4507893d docs: what the hard memory limit counts on each system
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 02:47:10 +01:00
Ludovic Marie 25a71d59c3 Merge remote-tracking branch 'origin/main' into sweep/7477 2026-11-03 02:47:03 +01:00
rdbt f5c36b72be Merge pull request #7704 from rdbtCVS/t3/autosave-player-files-off-tick-thread
Move player autosave file writes off the tick thread
2026-11-03 01:47:02 +00:00
Ludovic Marie 95fadea93e Merge main into feat/cosmetics-first-light 2026-11-03 02:46:54 +01:00
Ludovic Marie 8a751cc563 Merge main into feat/cosmetics-wardrobe-client 2026-11-03 02:46:53 +01:00
Ludovic Marie bb44926baf Merge main into feat/cosmetics-wardrobe-api 2026-11-03 02:46:53 +01:00
rdbt 92b86072fa Merge pull request #7719 from rdbtCVS/perf/spread-server-tick
Dedicated server: cut tick costs for spread-out players
2026-11-03 01:46:32 +00:00
rdbt 5ea2be9b35 Merge pull request #7836 from rdbtCVS/t3/dedicated-server-100-player-optimisations
Dedicated server: tile sparse chunk ticket maps
2026-11-03 01:46:16 +00:00
rdbt 09e169c71d Merge pull request #7873 from rdbtCVS/perf/server-chunk-encoding
Servers: avoid repeated work when sending chunks to many players
2026-11-03 01:46:09 +00:00
rdbtCVSandOpenAI Codex 9a4604f347 Merge main while preserving offhand armor and invalid-button tests
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:45:59 +00:00
Ludovic MarieandClaude Opus 5.5 26a5f455be Merge main; keep reviewed translations, fill only English strings
The table starts from main's (every string the launcher asks for today) and
takes this branch's text only where main still had the English.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 02:44:34 +01:00
Ludovic MarieandClaude Opus 5.5 bb8f5ee210 Merge main; keep reviewed translations, fill only English strings
The table starts from main's (every string the launcher asks for today) and
takes this branch's text only where main still had the English.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 02:44:11 +01:00
Ludovic MarieandClaude Opus 5.5 52106e2a1e fr_fr: keep main's translations, fill only English strings, drop English copies
Leaves Mailbox (a Vulkan present mode) and FPS in English: the game falls
back to en_us for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 02:43:38 +01:00
rdbtCVSandOpenAI Codex 96c5f0ea68 Merge main and keep both loot and item-count cases
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:43:09 +00:00
Ludovic Marie f434a08168 Merge remote-tracking branch 'origin/main' into sweep/7780
# Conflicts:
#	crates/horizon-client/assets/horizon/lang/fr_fr.json
2026-11-03 02:42:58 +01:00
rdbtCVSandOpenAI Codex fdf20ff6e2 Merge main and retain drowned water and wither-arrow fixes
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:42:34 +00:00
Florian ERNSTandCodex 7447bc478f Merge main into bird flap event fix
Co-Authored-By: Codex <[email protected]>
2026-11-03 02:42:22 +01:00
Ludovic MarieandClaude Opus 5.5 1db0144bd8 Keep only translated Korean and Croatian entries
The game files drop the entries that were still the English text: the game
falls back to en_us for a missing key. The launcher tables take today's
string list (27 strings were added since the branch started) and keep the
English text only where no translation exists, because the launcher test
requires every string in every table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 02:42:11 +01:00
Ludovic Marie 3f6aec902e Merge remote-tracking branch 'origin/main' into sweep/7650 2026-11-03 02:41:56 +01:00
rdbt b193a87711 Merge pull request #7964 from grkthegreat2018-bot/fix/7875-noai-bed-wake
a no-AI sleeper still wakes when its bed is gone
2026-11-03 01:41:26 +00:00
rdbt 9a6e9a95c3 Merge pull request #7897 from TiggerTakkyTTT/fix/server-management-idle-timeout
Add live player idle timeout management
2026-11-03 01:40:00 +00:00
rdbt 07d81da9ea Merge pull request #7688 from TheMrZZ/fix/projectile-first-tick-shoot
Emit first-tick shoot events for summoned snowballs
2026-11-03 01:39:55 +00:00
rdbt 5c47cf6729 Merge pull request #7569 from Xmilon/content/particle-behaviour
Native mods: declarative particle motion, size and fade
2026-11-03 01:39:50 +00:00
Ludovic MarieandClaude Opus 5.5 4a8623959a Cosmetics: outfits survive a game restart while the service is down
The fetch thread keeps the last answers in worn.json beside the item files
and reads them only when a request fails, so players it saw before keep
their outfits until the service answers again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 02:39:46 +01:00
rdbtCVSandOpenAI Codex 4262dc2aaa Merge main and retain signed animal NBT regression cases
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:39:46 +00:00
rdbt 1833f48610 Merge pull request #7560 from levzzz5154/modapi/entity-motion
Native mods: an entity's velocity, ground, box, eyes and view through EntityRef (#7387)
2026-11-03 01:39:46 +00:00
rdbtCVSandCodex gpt-6.1-sol 96be484ed1 fix: send vanilla client brand to external servers
Preserve Horizon extension negotiation by announcing the Horizon brand only after the server identifies itself as Horizon.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 01:39:31 +00:00
Ludovic Marie b2ab9e69e3 Merge main into feat/cosmetics-name-tag 2026-11-03 02:37:59 +01:00
Ludovic Marie d348283d99 Merge remote-tracking branch 'origin/main' into launcher-forge-versions 2026-11-03 02:37:44 +01:00
rdbt 52b83fc16f Merge pull request #7961 from JustKarkat/fix/elytra-glide-exit-sync
Elytra: glide-exit tick parity with vanilla
2026-11-03 01:37:21 +00:00
rdbtCVSandOpenAI Codex f8f1a7d8a0 Merge main and keep armor and fire-resistance regression cases
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:37:19 +00:00
rdbt 44060d55ce Merge pull request #7960 from grkthegreat2018-bot/fix/7948-effect-ticks-list-in-place
a mob's effect ticks see its live effect list
2026-11-03 01:37:17 +00:00
rdbt 9b2f6f96c7 Merge pull request #7957 from Joansitoh/fix/7877-mob-attribute-base
Fix /attribute base set on mob movement speed and attack damage (#7877, slice 1)
2026-11-03 01:37:13 +00:00
rdbt 17538cbd6a Merge pull request #7955 from MSedgeMC/fix/guest-riding-jump
A guest's released jump bar reaches the mount they ride (START_RIDING_JUMP)
2026-11-03 01:37:09 +00:00
rdbt 8a0a657d7c Merge pull request #7944 from grkthegreat2018-bot/fix/7876-new-player-spawn-rotation
a player new to the world faces the spawn's angle
2026-11-03 01:37:04 +00:00
rdbt 329b0616c0 Merge pull request #7941 from MSedgeMC/test/campfire-particles
marker_particles: the campfire's smoke, and the puff count vanilla draws per tick
2026-11-03 01:37:00 +00:00
rdbt 5d2c38ca46 Merge pull request #7938 from Joansitoh/fix/7874-phantom-group-size
Fix phantom group size ignoring difficulty
2026-11-03 01:36:56 +00:00
rdbt e7472ccfd2 Merge pull request #7936 from JustKarkat/fix/use-cooldown-integer-seconds
use_cooldown: accept integer seconds
2026-11-03 01:36:52 +00:00
rdbt d0dcc623d8 Merge pull request #7933 from MSedgeMC/feat/transfer-command
Dedicated server: /transfer hands players to another server
2026-11-03 01:36:49 +00:00
rdbt 73f775b4c0 Merge pull request #7918 from MSedgeMC/fix/save-all-failed-message
A failed /save-all tells its source that it failed (commands.save.failed)
2026-11-03 01:36:45 +00:00
rdbt dab5fc414b Merge pull request #7907 from HrafnaValkyrja/fix/projectile-strikes-vehicles
Arrows, tridents and snowballs strike boats and minecarts as in vanilla
2026-11-03 01:36:39 +00:00
rdbt 68fee8e4dd Merge pull request #7906 from grkthegreat2018-bot/fix/7888-effect-modifier-floats
effect modifiers widen vanilla's float literals
2026-11-03 01:36:36 +00:00
rdbtCVSandOpenAI Codex 1047b5812e Merge main while preserving both command regression corpora
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:36:35 +00:00
rdbt 987cd58dcc Merge pull request #7905 from Slavik-85/fix/7817-attack-swing-same-tick
An attack's arm swing is handled in its own tick, not after the move behind it
2026-11-03 01:36:32 +00:00
rdbt d0f8545a6d Merge pull request #7896 from gigamans442/fix/shader-named-dimensions-20261102
Shader packs: honor dimension.properties program folders
2026-11-03 01:36:27 +00:00
rdbt e1fb3e15ff Merge pull request #7889 from grkthegreat2018-bot/fix/7863-setblock-moving-piston
setBlock over a moving piston keeps the extended base
2026-11-03 01:36:24 +00:00
umcekoandClaude Sonnet 5.5 0df82dc747 Models: the obj model loader
The obj loader reads the model's .obj and .mtl through a new AssetReader on
ModelLibrary and bakes unculled quads: triangles repeat their last vertex,
`flip_v` and `shade_quads` are honoured, and `map_Kd #slot` picks a texture slot.
A face whose `o`/`g` object the model chain's `visibility` marks false is skipped;
the model closest to the leaf wins. A missing or broken file warns once and bakes
nothing. ModelQuad gains `shade`, which the mesher honours.

Part of #5551

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 04:36:23 +03:00
rdbt 7beea5b1ea Merge pull request #7887 from MSedgeMC/test/fire-starter-flint
fire_starter_sound: both fire starters on a campfire, a candle and stone
2026-11-03 01:36:20 +00:00
rdbt cfc2fb11ad Merge pull request #7881 from TheMrZZ/fix/h178-noai-chicken-eggs
Tick adult chicken egg timers with NoAI
2026-11-03 01:36:16 +00:00
rdbt 59694b53b9 Merge pull request #7879 from MSedgeMC/test/particle-geyser-gas
particle_providers: the sulfur geyser's four particles and the noxious gas
2026-11-03 01:36:13 +00:00
rdbt d41568c8c5 Merge pull request #7878 from MSedgeMC/test/auto-jump-riding
auto_jump: a player riding a vehicle never auto-jumps
2026-11-03 01:36:09 +00:00
rdbt ff0d47577e Merge pull request #7872 from HrafnaValkyrja/fix/cart-bubble-column
Mobs in a rising bubble column get the full lift in water
2026-11-03 01:36:05 +00:00
rdbt b5dcfe89b5 Merge pull request #7867 from OlaHulleberg/content/render-shape
Content blocks choose their render_shape
2026-11-03 01:35:59 +00:00
rdbt 2e004a57b3 Merge pull request #7866 from gigamans442/fix/global-constant-division-candidate-20261102
Shader packs: lower rewritten global constant initializers
2026-11-03 01:35:56 +00:00
rdbt bc95299b17 Merge pull request #7858 from gigamans442/fix/shader-post-destination-20261102
Shader packs: retain unwritten post destinations and frame clears
2026-11-03 01:35:51 +00:00
rdbt 38d69c8ed8 Merge pull request #7851 from TheMrZZ/fix/h136-in-water-time
Gate saved InWaterTime on cached eye immersion
2026-11-03 01:35:47 +00:00
rdbt e1af23bc8b Merge pull request #7841 from gigamans442/fix/legacy-texture-size-20261102
Shader packs: resolve legacy texture-size queries
2026-11-03 01:35:39 +00:00
rdbt 08167f461e Merge pull request #7838 from MSedgeMC/fix/menu-click-button
Menus: a click with a button other than 0 or 1 changes nothing
2026-11-03 01:35:34 +00:00
rdbt f52cada387 Merge pull request #7830 from MSedgeMC/test/container-screen-pick-item
container_screen: the pick-item button clicked and released on a container screen
2026-11-03 01:35:29 +00:00
rdbt f51bade651 Merge pull request #7809 from TheMrZZ/fix/missing-selector-predicate
Reject missing selector predicates before negation
2026-11-03 01:35:21 +00:00
rdbt f7f3733f30 Merge pull request #7808 from HrafnaValkyrja/test/entity-tick-order
A harness measures where entity tick order differs from vanilla
2026-11-03 01:35:18 +00:00
rdbt 3cdd6565ad Merge pull request #7806 from xFedeT/fix/piglin-brute-broadcast
A hit piglin brute calls the piglins around it
2026-11-03 01:35:12 +00:00
rdbt c77477711a Merge pull request #7805 from MSedgeMC/test/wire-toggle
use_item_on: a clicked redstone wire toggles between dot and cross
2026-11-03 01:35:09 +00:00
rdbt 45a256dde2 Merge pull request #7804 from MSedgeMC/test/diode-controls
use_item_on: a repeater, comparator and daylight detector clicked by hand
2026-11-03 01:35:05 +00:00
rdbt 2957890943 Merge pull request #7802 from xFedeT/fix/lightning-rod-unpower
A struck lightning rod unpowers after 8 ticks
2026-11-03 01:35:01 +00:00
rdbt 010df767f4 Merge pull request #7799 from TheMrZZ/fix/h115-dripleaf-projectiles
Tilt big dripleaves fully on projectile hits
2026-11-03 01:34:57 +00:00
rdbt fd2243ded2 Merge pull request #7798 from gigamans442/fix/world-target-view-identity-20261102
Shader packs: rebind replaced world colour/depth views
2026-11-03 01:34:54 +00:00
rdbt 2419eaf955 Merge pull request #7797 from gigamans442/fix/per-face-uv-centre-20261102
Shader packs: use each terrain quad's UV centre for mc_midTexCoord
2026-11-03 01:34:50 +00:00
rdbt 1f00cf38eb Merge pull request #7796 from xFedeT/fix/painting-backfaces
Paintings cull their back faces
2026-11-03 01:34:46 +00:00
rdbtCVSandOpenAI Codex 903f3e72c7 Merge main and preserve salmon and polar bear dimensions
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 01:34:43 +00:00
rdbt c0ec5eb138 Merge pull request #7795 from gigamans442/fix/standard-pack-shadow-owner-20261102
Shader packs: suppress vanilla blobs for compiled entity shadows
2026-11-03 01:34:42 +00:00
rdbt dd63b9e17d Merge pull request #7793 from xFedeT/fix/interaction-shapes
The block pick uses interaction shapes for cauldrons, composters, hoppers and scaffolding
2026-11-03 01:34:38 +00:00
umcekoandClaude Sonnet 5.5 17d1bea3b9 Models: the composite model loader
A model may name a `loader`, inherited through `parent` (a model's own loader
wins). The composite loader bakes the model's elements, then each shown child as
its own model in document order, with the composite's textures as slot fallback.
`visibility` hides children by name, and an inheriting model's map overrides the
loader's. A loader the decoder does not know adds no geometry. Children's parents
are queued when block models load.

Part of #5551

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 04:34:35 +03:00
rdbt e2ba37451c Merge pull request #7774 from horizon-rust/feat/cosmetics-preview
Cosmetics: a still picture of a player wearing items, drawn on the CPU
2026-11-03 01:34:34 +00:00
rdbt c4a598a2b7 Merge pull request #7784 from xFedeT/fix/nautilus-charge-knockback
A nautilus charge knocks its target back like vanilla
2026-11-03 01:34:00 +00:00
rdbt 348a7d69e0 Merge pull request #7775 from xFedeT/fix/mob-knockback-equipment
A mob's gear counts in its knockback resistance
2026-11-03 01:33:56 +00:00
rdbt 11afe2bcad Merge pull request #7765 from TheMrZZ/fix/h099-falling-chest-data
Restore block entities and inventory for falling chests
2026-11-03 01:33:51 +00:00
rdbt 30b58b9a17 Merge pull request #7735 from TheMrZZ/fix/h070-screaming-goat-voices
Use screaming goat ambient, hurt and death voices
2026-11-03 01:33:47 +00:00
rdbt 360ff834ae Merge pull request #7709 from TheMrZZ/fix/setblock-destroy-air
Honor the setblock destroy-to-air shortcut
2026-11-03 01:33:43 +00:00
rdbt 41d75b78b8 Merge pull request #7694 from senjinthedragon/shader-texture-release
Shader packs: a custom texture over a buffer stops standing in once the stage flips it
2026-11-03 01:33:39 +00:00
rdbt 0397e08ff8 Merge pull request #7657 from MSedgeMC/test/dye-color-mix
Two dyes mix by the recipe that combines them: a test for the breeding colour
2026-11-03 01:33:34 +00:00
rdbt a14d5f012b Merge pull request #7645 from MSedgeMC/fix/bundle-destroyed-spills
A destroyed bundle spills the stacks it held
2026-11-03 01:33:30 +00:00
rdbt 85d23165c6 Merge pull request #7642 from MSedgeMC/fix/pick-block-plant-items
Pick block: a block that is not an item picks its harvest
2026-11-03 01:33:26 +00:00
rdbt eeb4077bb3 Merge pull request #7640 from TheMrZZ/fix/guest-command-damage-attacker
Preserve connected player causes in mob command damage
2026-11-03 01:33:23 +00:00
rdbt 7ad9cd15af Merge pull request #7635 from MSedgeMC/fix/menu-clone-drag-type
Menus: a drag only starts with a quick-craft type the player may use
2026-11-03 01:33:19 +00:00
rdbt 318c55f2bd Merge pull request #7621 from MSedgeMC/feat/wither-skeleton-flame-arrow
A wither skeleton's arrow leaves the bow burning
2026-11-03 01:33:15 +00:00
rdbt 9b0cbc0bd0 Merge pull request #7619 from MSedgeMC/fix/path-cauldron-nodes
A path node on a cauldron moves onto its rim
2026-11-03 01:33:11 +00:00
rdbt f11d3a03cf Merge pull request #7617 from MSedgeMC/test/beardifier-kernel-table
The beard kernel table holds vanilla's values, checked entry by entry
2026-11-03 01:33:07 +00:00
rdbt 85bc5eef6e Merge pull request #7610 from MSedgeMC/fix/spider-vehicle-attachment
A spider sits on a narrow vehicle by its own rule
2026-11-03 01:33:03 +00:00
rdbt ed09e10f80 Merge pull request #7608 from MSedgeMC/fix/recipe-book-seen
Recipe book: a guest's seen recipe stops being highlighted
2026-11-03 01:32:58 +00:00
rdbt 554696dd53 Merge pull request #7605 from MSedgeMC/fix/jigsaw-range-bounds
A jigsaw structure's size and reach stay inside vanilla's bounds
2026-11-03 01:32:54 +00:00
rdbt 2783ee3ee4 Merge pull request #7592 from MSedgeMC/fix/processor-decode-bounds
A capping limit and a block_rot integrity are bounded at decode
2026-11-03 01:32:48 +00:00
rdbt 6c04937b8d Merge pull request #7591 from MSedgeMC/fix/straw-bed-sleep-height
Beds: the sleeping height comes from the bed's shape, not a constant
2026-11-03 01:32:44 +00:00
rdbt 04121e84f8 Merge pull request #7589 from MSedgeMC/fix/killer-bunny-bite-sound
Sound a killer bunny's bite
2026-11-03 01:32:40 +00:00
rdbt 0d78ac3b82 Merge pull request #7578 from MSedgeMC/fix/chest-openers-recheck
world/client: the level recounts a container's openers so a stale count closes the lid
2026-11-03 01:32:35 +00:00
rdbt 9f7120eb1f Merge pull request #7573 from MSedgeMC/fix/breeze-ground-particles
Draw a breeze's ground particles by its pose
2026-11-03 01:32:32 +00:00
rdbt ff7230e9f6 Merge pull request #7571 from MSedgeMC/fix/random-spread-validation
A random spread whose separation reaches its spacing is refused
2026-11-03 01:32:28 +00:00
rdbt 652a30510c Merge pull request #7562 from MSedgeMC/fix/barrel-openers-recheck
world: a barrel recounts its openers on its scheduled tick (BarrelBlock.tick)
2026-11-03 01:32:22 +00:00
rdbt 51fe90467d Merge pull request #7551 from glitchsnake/fix/7510-baby-pig-lightning-piglin
mobs: preserve baby age when pig is converted by lightning (fixes #7510)
2026-11-03 01:32:17 +00:00
Florian ERNSTandCodex f9b2d4c594 Merge main into aquatic FromBucket save fix
Co-Authored-By: Codex <[email protected]>
2026-11-03 02:32:14 +01:00
rdbt 02c98d12c4 Merge pull request #7544 from MSedgeMC/fix/world-symlink-open
Worlds: a folder that holds a symbolic link is not opened
2026-11-03 01:32:13 +00:00
rdbt bfa085bb45 Merge pull request #7540 from UMCEKO/content-mods/atlas-sources
Content mods: sprites a mod adds to vanilla's atlases join the root
2026-11-03 01:32:09 +00:00
rdbt 8358ea1c3d Merge pull request #7539 from TheMrZZ/fix/7480-player-armor-components
Respect player armor durability components
2026-11-03 01:32:06 +00:00
rdbt 4649c72584 Merge pull request #7536 from UMCEKO/models/neoforge-face-data
Models: per-face light, colour, AO and render types
2026-11-03 01:32:02 +00:00
rdbt 45cd4c14d4 Merge pull request #7512 from TheMrZZ/fix/mob-effective-armor
Use effective mob armor and toughness for damage reduction
2026-11-03 01:31:58 +00:00
rdbt 224c341082 Merge pull request #7768 from Joansitoh/fix/6885-guest-death-stats
Guest deaths award deaths stat and deathCount, reset death timers
2026-11-03 01:31:31 +00:00
rdbt 9671fb6807 Merge pull request #7758 from razzo715/fix/ridden-mount-not-pushed
A ridden horse jumps normally against other mobs
2026-11-03 01:31:27 +00:00
rdbt 23182e046e Merge pull request #7757 from MSedgeMC/test/lever-button
use_item_on: a lever and a button clicked with an empty hand
2026-11-03 01:31:24 +00:00
rdbt 05167c8b43 Merge pull request #7751 from TheMrZZ/fix/nbt-item-count-range
Default oversized item NBT counts to one
2026-11-03 01:31:19 +00:00
rdbt 5a83f86cab Merge pull request #7673 from Joansitoh/fix/7631-vecdelta-round
Round entity move deltas like Math.round
2026-11-03 01:31:16 +00:00
rdbt c226b2094b Merge pull request #7670 from ClickingMaster/fix/management-uuid-strict
horizon-server: management PlayerDto ids must be whole UUIDs
2026-11-03 01:31:12 +00:00
rdbt 9980cc2d6f Merge pull request #7668 from dontstopgorkingit/feat/lod-fences
LOD: keep fences and similar thin blocks in far terrain
2026-11-03 01:31:09 +00:00
rdbt 296a58f32d Merge pull request #7659 from ClickingMaster/fix/smite-camel-husk-nautilus
Count camel husks and zombie nautili as smite targets
2026-11-03 01:31:05 +00:00
rdbt e86769fa99 Merge pull request #7656 from ClickingMaster/fix/proxy-local-127-hostname
port-pack net: a host name starting with 127. no longer skips the proxy
2026-11-03 01:31:01 +00:00
rdbt a6265eff9e Merge pull request #7652 from razzo715/fix/lava-fizz-particles
The player who pours water on lava sees its smoke
2026-11-03 01:30:57 +00:00
rdbt c436d92725 Merge pull request #7636 from MSedgeMC/fix/menu-pickup-all-order
Menus: a double-click collects from the side of its button
2026-11-03 01:30:52 +00:00
rdbt ad3fd5256e Merge pull request #7626 from MSedgeMC/fix/passenger-ground-navigation
A mob riding a vehicle keeps its ground navigation
2026-11-03 01:30:48 +00:00
rdbt dc4271f9d7 Merge pull request #7622 from MSedgeMC/fix/pearl-owner-gate
A pearl whose owner is dead or asleep is discarded
2026-11-03 01:30:44 +00:00
rdbt 0f9afa63b5 Merge pull request #7618 from MSedgeMC/fix/polar-bear-stand-box
A rearing polar bear's box grows with its stand animation
2026-11-03 01:30:40 +00:00
rdbt aa7e55a55c Merge pull request #7603 from Joansitoh/fix/7483-wither-head-rotlerp
Fix wither side heads snapping to a different rotation
2026-11-03 01:30:37 +00:00
rdbt a60ad177d6 Merge pull request #7596 from glitchsnake/fix/7553-falling-scaffolding-stack
game: prevent falling blocks from descending through scaffolding (fixes #7553)
2026-11-03 01:30:33 +00:00
rdbt c85bb3a864 Merge pull request #7595 from MSedgeMC/fix/use-stop-vibration
Report the item-use finish vibration when a use ends
2026-11-03 01:30:29 +00:00
rdbt e54e001fb4 Merge pull request #7594 from MSedgeMC/fix/wolf-die-shake
A wolf that dies mid-shake stops shaking
2026-11-03 01:30:25 +00:00
rdbt 2fa8cd4cdf Merge pull request #7593 from MSedgeMC/fix/sulfur-cube-leash
A sulfur cube with a block takes a lead
2026-11-03 01:30:21 +00:00
rdbt 59b0144e2a Merge pull request #7577 from HrafnaValkyrja/fix/spawner-delay-roundtrip
The save round trip no longer depends on where the player lands
2026-11-03 01:30:17 +00:00
rdbt ffc99cf6d7 Merge pull request #7576 from MSedgeMC/fix/worldborder-limit
Fix /worldborder's size-limit message
2026-11-03 01:30:14 +00:00
rdbt a6f7a7fff8 Merge pull request #7575 from razzo715/fix/thrown-projectile-bubbles
Thrown snowballs, eggs and pearls leave bubbles underwater
2026-11-03 01:30:10 +00:00
rdbt a214d63bd1 Merge pull request #7574 from Joansitoh/fix/7440-fishing-cast-down
Fishing rod: cast direction uses Mth's sine table (straight down no longer launches up)
2026-11-03 01:30:06 +00:00
rdbt 1c4360c1e4 Merge pull request #7555 from MSedgeMC/fix/creaking-portal
A heart-bound creaking refuses to use a portal
2026-11-03 01:30:02 +00:00
rdbt ae01c0b14c Merge pull request #7541 from Domanffe/fix/native-mod-image-in-built
Pack the mod image slot when the build has mods, not when trait mods exist
2026-11-03 01:29:59 +00:00
rdbt 52a8524bbf Merge pull request #7208 from TraiNguyenVan/fix/7181-hidden-redraw-spin
Fix idle spin when Wayland redraws stop
2026-11-03 01:29:55 +00:00
rdbt 989de8a1a5 Merge pull request #7084 from yosifdheef/fix/sugarcane-water
Improve parity oracle handling and sugar-cane query order
2026-11-03 01:29:51 +00:00
rdbt 13b76513ad Merge pull request #6510 from MSedgeMC/fix/elytra-collision-damage
Hurt a glide by the distance the block actually stopped
2026-11-03 01:29:48 +00:00
GabrielNicholsandClaude Opus 4.6 f5c34fc62e Content mods: read the spawn biome modifiers (#7478)
Read NeoForge's `add_spawns`, `remove_spawns`, `add_spawn_costs` and
`remove_spawn_costs` biome modifiers alongside `add_features` and
`remove_features` when building the content root:

- `add_spawns` appends its spawners (a single spawner object or a list)
  to each entity type's spawn category in `spawns_by_category` under
  `attributes[minecraft:gameplay/natural_mob_spawns]`, converting
  `minCount` and `maxCount` to 26.3's `count` (an integer when equal,
  `minecraft:uniform` otherwise) and taking the category from
  `entity-types.json`;
- `remove_spawns` removes the resolved `entity_types` (an id, `#tag`,
  list, or `any`/`or`/`and`/`not` holder set) from every category;
- `add_spawn_costs` sets `spawn_costs[type]` (`energy_budget`,
  `charge`) and `remove_spawn_costs` removes them;
- additions run before removals in mod and file order, unknown entity
  types or tags are skipped with a warning, malformed modifiers fail the
  build naming the mod and file, and changed biomes also update
  `data/<ns>/worldgen/biome/<path>.json` in the overlay when present.

Fixes #7478

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-11-02 22:27:07 -03:00
GRKTheGreatandDevin d7a31bc073 a block entity stays only while it fits its block
LevelChunk.setBlockState keeps the entity at a position only while BlockEntity.isValidBlockState holds for the state now there; a mismatched or missing one is dropped for the block's own. Horizon kept whatever entity sat there on a same-name write, so a sculk or creaking heart entity spilled by a removal could end up under a chest. BlockEntity::fits maps each kind to the blocks for_block gives it; replace_block_entities and the entity-wanted list now swap on a name change or a bad fit.

Fixes #7880.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 20:25:27 -05:00
rdbt a5f75d1c4c Merge pull request #7884 from Joansitoh/fix/7674-hex-colour-range
Reject out-of-range hex colours in text components
2026-11-03 01:24:29 +00:00
rdbt 3779d744b6 Merge pull request #7550 from MSedgeMC/fix/font-fishy-advance
Fonts: skip a provider glyph whose advance is fishy
2026-11-03 01:24:23 +00:00
rdbt 5f3c1a4d5e Merge pull request #7771 from razzo715/fix/command-java-whitespace
The command reader skips the whitespace Java does
2026-11-03 01:24:17 +00:00
rdbt f4c87b4ee1 Merge pull request #7666 from ClickingMaster/fix/local-coord-missing-double
Local coordinates: a missing coordinate after a space expects a coordinate
2026-11-03 01:24:11 +00:00
rdbt d570e0a278 Merge pull request #7695 from senjinthedragon/shader-clouds-off
Shader packs: clouds=off draws no clouds
2026-11-03 01:24:05 +00:00
rdbt beabee0525 Merge pull request #7912 from gigamans442/fix/audit-capabilities-20261102
Shader packs: negotiate supported reversed shadow culling
2026-11-03 01:23:58 +00:00
rdbt 2cfe99165d Merge pull request #7558 from ratuser1/fix/6072-option-separators
Options: join Horizon captions with the language's separator
2026-11-03 01:23:38 +00:00
rdbt 2f6ec207bb Merge pull request #7548 from MSedgeMC/fix/language-enter-select
Language: the selection key picks a language and finishes the page
2026-11-03 01:23:32 +00:00
rdbt 5debdb4475 Merge pull request #7766 from xFedeT/fix/png-bad-crc
PNG checksums: shader pack textures too, and the bad-IHDR case pinned
2026-11-03 01:23:26 +00:00
rdbt cd7a9c5fe7 Merge pull request #7697 from senjinthedragon/shader-sqrt
Shader packs: sqrt never takes a negative in functions
2026-11-03 01:23:19 +00:00
rdbt 14192b5023 Merge pull request #7753 from ClickingMaster/fix/shader-if-short-circuit
Shader #if: skip division errors on the side && / || short-circuits
2026-11-03 01:23:13 +00:00
rdbt cae5a3c44e Merge pull request #7662 from ClickingMaster/fix/split-url-ipv6
Fix split_url for a bracketed IPv6 URL without a port
2026-11-03 01:23:07 +00:00
rdbt d838096daf Merge pull request #7934 from JustKarkat/fix/noproxy-ipv6-truncation
NO_PROXY: don't truncate bare IPv6 entries
2026-11-03 01:23:00 +00:00
rdbt 5684b2895f Merge pull request #7721 from ClickingMaster/fix/chat-filter-empty-allow
Chat filter: ignore allow lines with no letters
2026-11-03 01:22:53 +00:00
rdbt 2c2ad41526 Merge pull request #7846 from itarqos5/fix/debug-overlay-odd-scale
Debug overlay: half the automatic scale, rounded up
2026-11-03 01:22:47 +00:00
rdbt 2a681d82f3 Merge pull request #7693 from senjinthedragon/shader-pass-keep
Shader packs: keep the cloud and weather passes on frames with nothing to draw
2026-11-03 01:22:41 +00:00
rdbt da9cf877ed Merge pull request #7696 from senjinthedragon/shader-float32-filter
Renderer: request FLOAT32_FILTERABLE for shader packs' 32-bit float buffers
2026-11-03 01:22:35 +00:00
rdbt 7597446fe2 Merge pull request #7681 from Xmilon/native/tooltip-color-neoforge-name
Native mods: tooltip_color names NeoForge 26.3's RenderTooltipEvent.Texture
2026-11-03 01:22:07 +00:00
rdbtCVSandCodex gpt-6.1-sol af96bf6338 Register player save lifecycle checks with the parity runner
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 01:21:10 +00:00
GRKTheGreatandDevin 23ae5ffd4e a no-AI sleeper still wakes when its bed is gone
LivingEntity.tick runs checkBedExists for every sleeping living entity before aiStep; Horizon kept it inside villager::ai_step, which the no_ai gate skips. The bed check now runs ahead of the gate; ai_step keeps the hurt wake and the bed-facing pose.

Fixes #7875.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 20:20:00 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 4c220217aa Entities get a join number, and chunks save in join order
Every kind in entity_lists! stores when it joined: a counter that rises with
each join. A JoinList replaces the Vec for the eleven lists and numbers an
entity on push, insert or extend; one taken out and put back keeps its number.
A chunk's entities are saved in join order. A checked walk over all lists in
join order is added, with unit tests; nothing ticks with it yet.

Part of #6509.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 20:19:24 -05:00
TeiwazikandClaude Opus 5.5 dc586233bf Older servers: the launcher's lock file lists log for horizon-protocol
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 02:16:25 +01:00
TeiwazikandClaude Opus 5.5 c4e8a4b825 Older servers: translate 26.2's world packets
The session now hands each packet to adapter::translate. A 26.2 server's
chunks have their block states renumbered and, on the global palette,
repacked from 15 to 16 bits, and its light masks turn from long arrays
into 26.3's byte arrays. Block, section and block entity updates, level
events with a state, and tags are renumbered; login and respawn read
26.2's byte game types. Serverbound, the teleport confirmation drops
26.3's position, player actions skip 26.3's destroy direction change and
the punch becomes 26.2's swing. A packet the translation can't read goes
on as it came, with a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 02:12:07 +01:00
JustKarkat 59fe490b95 Mobs with a body slot hold a head item and a body item at once
The body species (horses, donkeys, mules, llamas, camels, wolves, happy
ghasts, nautili, sulfur cubes) kept their body item (horse armour, a
llama's carpet, a harness, a wolf's armor, a swallowed block) in the same
field as the head item, so a helmet replaced the armour and vice versa;
vanilla holds one in each slot (#7883).

Give Extra a separate body field, route every site that read or wrote
equipment through it — the equipment packet, /item, death drops and their
chances, item breaks and sun protection, the horse's hurtArmor, the wolf's
armor interactions, the mount, harness and nautilus equip paths, the
sulfur cube swallow, the conversion carry-over, the save and the load —
and keep the saved NBT compatible: equipment.body and equipment.head keep
their vanilla keys on every species.
2026-11-02 19:02:51 -06:00
rdbtCVSandCodex gpt-6.1-sol c4375f7a43 Verify connected player inventories after save-all and stop
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 01:00:25 +00:00
arismoko 275fffcb37 Native mods: test that saved entities of every kind load their attachments back onto themselves 2026-11-03 00:55:19 +00:00
jedev3095andClaude Sonnet 5.5 36f7c57c9b Server: draw API tokens and panel sessions from the OS random generator
random_token built the control API token, the panel's login codes and its
session cookies from SipHash hasher seeds, the clock and the process ID.
Use getrandom (already in the lockfile through the relay) for 128 random
bits instead.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:53:30 +00:00
jedev3095andClaude Sonnet 5.5 00b3e26fee Relay: keep serving after a connection thread panics
Every lock on the relay's shared state was lock().unwrap(), so one thread
that panicked holding a lock poisoned it and made every later connection
panic too. Take the state back from a poisoned lock instead, as the
telemetry server already does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:53:29 +00:00
jedev3095andClaude Sonnet 5.5 08029f0643 Count modpack override bytes as they are extracted
Modpack::read totalled the sizes a zip declares for each entry, which a
hostile .hpack controls, so the 256 MiB overrides limit did not bind what
extract_overrides wrote. Count the bytes actually inflated, fail instead of
silently truncating a file over 64 MiB, and remove what was already written
when a limit is hit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:53:28 +00:00
JustKarkat b10de35e78 client: apply glide-flag clears at vanilla's tick phase (fixes #7616) 2026-11-02 18:53:22 -06:00
GRKTheGreatandDevin e3e050ce51 a mob's effect ticks see its live effect list
LivingEntity.tickEffects ticks each effect with the mob's list in place, so a Poison or Wither tick's damage reads Resistance and Infested and a totem's granted effects stay. Wither ticks hurt with wither, not magic.

Fixes #7948.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 19:52:57 -05:00
TeiwazikandClaude Opus 5.5 abeaaf526e Older servers: renumber registry ids as packets are read
tools/generate_registry_id_adapter.py writes, from two versions'
registries and blocks reports, how an older release numbers block states
and the registries the protocol sends by id, as runs both ways (26.2's
here). adapter::ids holds them and a per-thread context: while a packet
from such a server is decoded (or one for it encoded), the item stack,
data component, particle, entity data, sound and slot display readers
renumber the ids they meet, since those ids decide how the bytes after
them read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 01:49:40 +01:00
rdbtCVSandCodex gpt-6.1-sol 74449f7a4f Regenerate player save hook documentation
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 00:48:59 +00:00
JustKarkat 839b23995f world: read dimension-type has_ceiling instead of dimension-id stand-ins (fixes #7864) 2026-11-02 18:40:43 -06:00
gigamans442andOpenAI Codex 7a5ba4c808 Cover sized parameter lengths when passed lowered globals
Exercise distinct and shadowing parameter names with a genuinely runtime-lowered global array and preserve constant parameter length 3.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 19:39:08 -05:00
rdbtCVSandCodex gpt-6.1-sol 0194d48a2e Clarify integrated-server player save hook coverage
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 00:38:06 +00:00
MSedgeMC 62358f9ea1 fix: a released jump bar starts the mount a guest rides
`AbstractHorse.handleStartJump` rears the horse, lets it slide on the
rider's keys and plays its jump sound; `Camel.handleStartJump` dashes with
the dash sound. The rider's own client charges the bar and drives a
saddled horse itself (`isLocalInstanceAuthoritative`), so that is the
whole of the server's share — and a guest's had no server side at all.

The mount's reaction is one function over the vehicle
(`mounts::start_riding_jump`), and the jump sound has one definition
(`equine::jump_sound`), used by the host's own ride and the guest's.
2026-11-03 00:36:09 +00:00
MSedgeMC 21a22e3880 fix: a guest's riding jump reaches the game
ServerGamePacketListenerImpl.handlePlayerCommand sends START_RIDING_JUMP
and STOP_RIDING_JUMP (3 and 4) to the vehicle the player rides, which
takes its own handleStartJump/handleStopJump. The hub's action match only
knew sprinting, waking and (through the arm above it) the inventory and
the elytra, so a guest's released jump bar was dropped on the floor: the
horse never reared, and nobody heard its jump sound.
2026-11-03 00:36:09 +00:00
TeiwazikandClaude Opus 5.5 cba4f3af04 Older servers: a session renumbers a connection's packets
The connection to a server of an older release follows each direction's
state from the packets themselves and renumbers every packet through the
#6703 tables, both ways, rewriting the handshake to the server's
protocol. The client pings first and adapts the connection when the
server speaks a known older protocol; the server list shows it joinable.
Behind HORIZON_OLDER_SERVERS=1 until packet contents are translated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTr6rUbVKx6iB2X73DNG
2026-11-03 01:32:24 +01:00
JoansitohandClaude Sonnet 5.5 5ca165bf46 Cover the known/new split of the join rotation
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 01:31:54 +01:00
MSedgeMC c9292cbd29 harness: the stand-in level records what a campfire asks it for
The oracle builds the real block entity for the case's state and items and ticks
it over a level that records every `addParticle` and `addAlwaysVisibleParticle`
by registry name with all six doubles as bits; the douse cases call the real
`CampfireBlock.makeParticles` directly.

The probe reads the same requests from the engine: `ParticleEngine.create` now
records them in `created_requests` instead of making a particle, because a
particle's own sprite draw comes off the engine random - the same generator the
tick draws from - and would move the level random the case is compared on.
2026-11-03 00:26:03 +00:00
MSedgeMC 9851417720 fix: a campfire asks the random for each puff's count again, as vanilla does
`CampfireBlockEntity.particleTick` writes the smoke loop as
`for (int i = 0; i < random.nextInt(2) + 2; i++)`, so the iteration bound comes
from a fresh draw on every pass: a tick with smoke in it keeps asking the random
whether to make one more puff, and spends one more draw when it stops. Horizon
drew that count once and looped it as a fixed number, so the puffs after the
first landed elsewhere and the level random was left in a different place.

With the new cases the old code differs in 148 of the 1,104 `particleTick`
cases. The first is an empty lit campfire facing north, ticked six times from
seed 7: both make two puffs and the second is already at
`3fe0e33ecc7f720d 40500e6bba76d26e 3fe7ea5686e766ea` against
`3fe76e1f3d8e2508 40506480d4539aee 3fe85eeae1fa95a2`, and the next draw is
-1720867674 against -1758292495.
2026-11-03 00:24:17 +00:00
MSedgeMC 5ff3c65778 test: the campfire's smoke column, its cooking smoke and a douse's puff
The marker suite drives the whole `animateTick` loop, but its world is barriers
only and no case ever made a campfire lit, so the campfire half of the same
module - `CampfireBlockEntity.particleTick` and `CampfireBlock.makeParticles`,
which a shovel's douse also runs twenty times - had no comparison anywhere. Its
scope said "barrier marker positions, velocities and random draws", and the
block entity tick was never reached.

1,104 `particleTick` cases and 144 `makeParticles` cases now tick a campfire at
[0, 64, 0] from a seeded level random: both campfire blocks, lit and unlit,
signal fire on and off, all four facings, each of the four cooking slots held
alone and all four held, and a douse's one or twenty puffs. Both sides print
each particle's type, position and velocity as exact bits, in the order the
level was asked for them, and the level random's next draw after the tick.
2026-11-03 00:24:17 +00:00
JustKarkat 36cf662227 items: accept integer seconds in use_cooldown (fixes #7638) 2026-11-02 18:23:56 -06:00
JustKarkat d20c1f5d88 net: don't strip a port from bare IPv6 NO_PROXY entries (fixes #7665) 2026-11-02 18:23:05 -06:00
rdbtCVSandCodex gpt-6.1-sol c4dfef036e Cover shared chunk warm-up and simplify palette fast paths
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 00:22:33 +00:00
MSedgeMC 72919541f4 feat: /transfer sends the players it names and says who went
TransferCommand.transfer sends every target the transfer packet and
replies with commands.transfer.success.single (the player's name) or
.multiple (the count), both with the hostname and the port; with no
players argument the source's own player goes, and a source without one
is `permissions.requires.player`, as getPlayerOrException is.
2026-11-03 00:21:58 +00:00
MSedgeMC f2dfd2a6bc feat: a transferred guest is sent the play-state Transfer packet
TransferCommand.transfer hands each target's client to another server
with ClientboundTransferPacket; the client closes and logs in there. The
hub could receive that packet but had no way to send one, so a
`/transfer` had nowhere to land.
2026-11-03 00:21:58 +00:00
MSedgeMC 24a7197b48 feat: a dedicated server's admins get /transfer
TransferCommand.register registers on a dedicated server only
(Commands.CommandSelection.includeDedicated) at LEVEL_ADMINS, as
`<hostname> [<port> [<players>]]` with
IntegerArgumentType.integer(1, 65535) for the port. The tree had no such
node, so the command was unknown to the game and to the console.
2026-11-03 00:21:58 +00:00
rdbtCVSandCodex gpt-6.1-sol 69e690a342 Preserve large-mob cart collisions and reuse spatial indexes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 00:21:28 +00:00
rdbtCVSandCodex gpt-6.1-sol 333f10f80e Recover player autosave failures and serialize synchronous player writes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 00:21:28 +00:00
rdbtCVSandCodex gpt-6.1-sol db5996bbdd Keep ticket benchmarks compiled and cover tiled routing
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 00:20:50 +00:00
rdbtCVSandCodex gpt-6.1-sol 905c4b1de1 Merge remote-tracking branch 'origin/main' into review/7719
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-03 00:19:00 +00:00
gigamans442andCodex GPT-6.1 Sol a091fdc7cd fix(shaders): preserve inferred multidimensional array order
Keep an inferred outer dimension in the existing dimension reversal.
Scope the local-array fixture to length use; named local array bounds
and comma-array declarators have separate pre-existing parser failures.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-02 19:14:09 -05:00
JoansitohandClaude Sonnet 5.5 a957a589d2 Load a saved movement speed / attack damage base as the attribute's base (#7877)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 01:14:00 +01:00
Florian ERNSTandCodex 2df47f7a21 Merge main into effective mob armor fix
Co-Authored-By: Codex <[email protected]>
2026-11-03 01:13:48 +01:00
MSedgeMC 7bc259cba6 fix: a failed /save-all tells its source that it failed
SaveAllCommand.saveAll sends `commands.save.saving`, then either
`commands.save.success` or the failure its ERROR_FAILED carries,
`commands.save.failed`. Horizon's console handler logged the error and
said nothing, so the source saw "Saving…" followed by silence and the
failure only reached whoever read the log.
2026-11-03 00:12:28 +00:00
GRKTheGreatandDevin 3ec7cc15b2 a player new to the world faces the spawn's angle
Ready.spawn snaps a newcomer to the respawn data's yaw and pitch; for a player without a bed or anchor that is the world spawn's angle (setworldspawn <pos> <rotation>, the level's saved spawn angle). The !known join branch set the position but left yaw, pitch and head yaw at 0, so every new player faced south whatever /setworldspawn's angle said.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 19:11:33 -05:00
JoansitohandClaude Sonnet 5.5 6ea2e7dad2 Fix clippy in rotation test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 01:08:55 +01:00
MSedgeMC 4e403e2d34 feat: /setidletimeout runs in game and names the minutes
SetPlayerIdleTimeoutCommand.setIdleTimeout sets the timeout and replies
with commands.setidletimeout.success and the minutes, or
commands.setidletimeout.success.disabled for 0; the command's result is
the number of minutes. The handler runs where the other dedicated
commands do, so an operator in game and the console reach it through the
same dispatcher.
2026-11-03 00:05:52 +00:00
MSedgeMC a8f42e81c8 feat: /setidletimeout changes the running server's idle timeout
`MinecraftServer.playerIdleTimeout` is a field of the server that
`PlayerList.tick` reads every tick, seeded from `player-idle-timeout` at
start-up. Horizon copied the property into a local of the tick loop, so
nothing a running server did could change it.

The minutes now live beside the other dedicated-server state (`Lists`):
seeded from the property, read from the tick loop, and writable by the
command.
2026-11-03 00:05:52 +00:00
MSedgeMC c8d567b454 feat: a dedicated server's players get /setidletimeout
SetPlayerIdleTimeoutCommand registers on a dedicated server only
(Commands.CommandSelection.includeDedicated) at LEVEL_ADMINS, with
IntegerArgumentType.integer(0) minutes. The tree had neither the node nor
the argument, so an operator in game got "Unknown or incomplete command"
and the timeout could only be changed in server.properties.

The dedicated tree now registers it in vanilla's place in the root order
(after pardon-ip), and the dedicated-commands test names it.
2026-11-03 00:05:52 +00:00
Florian ERNSTandCodex 148160e38f Merge main into aquatic FromBucket save fix
Co-Authored-By: Codex <[email protected]>
2026-11-03 01:02:42 +01:00
gigamans442andCodex GPT-6.1 Sol a56e3a7cd1 Shader packs: negotiate supported reversed shadow culling
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-02 19:01:57 -05:00
gigamans442andOpenAI GPT-6.1 Sol ab10ec05ea Fix core matrix inputs for shader geometry programs
Reuse geometry and shadow matrix state for the core projectionMatrix and modelViewMatrix names. Preserve authored names and local hiding, and verify actual provider values through an opt-in GPU pixel control.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-11-02 19:00:45 -05:00
GRKTheGreatandDevin 17a0cee571 effect modifiers widen vanilla's float literals
MobEffects' Speed, Slowness, Haste and Mining Fatigue amounts are float literals widened into a double modifier, so the low digits differ from a double literal: a zombie with Speed I syncs 0.2760000056922436 in vanilla, not 0.27600000500679017. effect_modifier now writes them as f64::from of the f32, as mobs/finalize.rs already does for the mob table.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 19:00:20 -05:00
HrafnaValkyrja 31c4d893f8 Merge remote-tracking branch 'origin/main' into fix/projectile-vehicle-hits 2026-11-02 18:59:58 -05:00
gigamans442andCodex GPT-6.1 Sol 05b7a9625b fix(shaders): size brace-initialized arrays
Infer the outer array size from top-level initializer-list elements,
including nested aggregates and an optional trailing comma.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-02 18:57:30 -05:00
gigamans442andGPT-6.1 Sol 0ac081b5c0 Pin wildcard path and unknown legacy dimension selection
Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-02 18:55:53 -05:00
gigamans442andGPT-6.1 Sol 82537a9f50 Honor named shader dimension folders
Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-02 18:54:49 -05:00
GRKTheGreatandDevin 8dfffbec33 a sculk catalyst takes a dying player's experience
ServerPlayer.die sent the ENTITY_DIE game event before dropAllDeathLoot, and a SculkCatalystBlockEntity within 8 blocks converts getExperienceReward into a sculk charge and calls skipDropExperience. guest_die went straight to spawn_orbs, so a player dying next to a catalyst still dropped orbs. The guest death path now runs catalyst_takes_experience like mob_died does: the reward becomes a charge, no orbs drop, and the credited killer gets kill_mob_near_sculk_catalyst with the death's killing_blow.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 18:52:57 -05:00
TiggerTakkyTTTandCodex 2e730b0bd5 Add live player idle timeout management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 06:52:51 +07:00
Slavik-85andClaude Opus 5.5 b422e4f2b8 lan: an action's arm swing is handled in its tick, past the move behind it
The hub stops reading a guest's packets after an attack, dig, use or
interaction when a move is queued behind it, so the move waits for the
next tick (0b179e13d). It also stopped before the arm swing the client
sends right after the action (Minecraft.startAttack: the attack, then
swing). The swing starts the attack strength again, so a swing handled a
tick late took a tick off the next hit: punches 5 ticks apart landed at
4 ticks of charge, 8.48 cart damage instead of 10, about 10 punches to
break a minecart instead of 8. On a loaded machine the guest's 12 in
lan_e2e's minecarts check were no longer enough.

The Punch right behind the action now goes with it; the move still waits.

Fixes #7817.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 01:52:08 +02:00
Florian ERNSTandCodex ef4d301ec4 Merge main into villager command name fix
Co-Authored-By: Codex <[email protected]>
2026-11-03 00:50:55 +01:00
JoansitohandClaude Sonnet 5.5 2e6c2fe880 fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:50:08 +01:00
JoansitohandClaude Sonnet 5.5 6955658b16 Face a new player toward the world spawn's angle (#7876)
PrepareSpawnTask: a player with no saved rotation takes respawnData yaw/pitch.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:48:43 +01:00
JoansitohandClaude Sonnet 5.5 9caba58c4e Make /attribute base set on a mob's movement speed and attack damage stick (#7877)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:46:33 +01:00
JoansitohandClaude Sonnet 5.5 fd46c96a83 Fix phantom group size ignoring difficulty
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:46:01 +01:00
GRKTheGreatandDevin 7a79e25da4 setBlock over a moving piston keeps the extended base
chunk_set_block_state ran MovingPistonBlock.destroy's extended-base removal on every state change, so /setblock, /fill, explosions and decay over a moving piston also deleted the extended base behind it. finalTick (removing the moving-piston block entity) still runs there; destroy is ServerPlayerGameMode.destroyBlock's alone, so it now runs only from player_destroy.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 18:45:12 -05:00
Florian ERNSTandCodex 739226a194 Merge main into signed InLove save fix
Co-Authored-By: Codex <[email protected]>
2026-11-03 00:42:04 +01:00
MSedgeMC 37d4e42817 harness: the oracle calls each item's own useOn
The oracle builds a stand-in level holding the case's block, hands the real
useOn a player with an empty hand and records the sound the level is asked
for; the probe reads the case's item and asks the matching branch for the
sound and the pitch.
2026-11-02 23:33:46 +00:00
MSedgeMC 73a8f221f9 test: both fire starters on a campfire, a candle and stone
The corpus held level random seeds only, so the probe always asked for the
fire charge's sound: FlintAndSteelItem's branch, its pitch formula and the one
draw it makes were never compared, and the charge's own useOn was never called
at all.

Each of the 20,006 seeds now runs both items against a campfire, a candle and
stone: 120,036 cases through the real useOn of each item.
2026-11-02 23:33:43 +00:00
jedev3095andCodex b776cdd21e Fix world-operation locks and filesystem save/removal failures
Co-Authored-By: Codex <[email protected]>
2026-11-02 23:32:59 +00:00
Florian ERNSTandCodex fb83f2f20e Merge main into armor stand climbing fix
Co-Authored-By: Codex <[email protected]>
2026-11-03 00:27:56 +01:00
MSedgeMC 2ae5efe6fa A scaled sleeping avatar's bed offset uses its scaled eye height
submit's sleeping translate is `getEyeHeight(Pose.STANDING) - 0.1`, and
`LivingEntity.getEyeHeight` goes through `getDimensions`, so the standing eye
height carries `minecraft:scale`. The avatar branch used the type's 1.62 and
put a scale-2 sleeper at the scale-1 distance from the pillow; multiply by
`Mob::model_scale()`, as the other branch's `awake_dims` already does.

Test: a scale-2 sleeping avatar's bed-relative pose is twice the unscaled
pose; before, the final z reads -4.4343753 where -2.8143752 is expected.
2026-11-02 23:25:10 +00:00
MSedgeMC 56b79e6852 harness: the geyser options the oracle never built
GeyserParticleOptions and GeyserBaseParticleOptions are constructed from the
case's water_blocks and burst_impulse_base, so the four types reach their real
providers instead of the assertion that had no options for them.
2026-11-02 23:23:05 +00:00
MSedgeMC b88db14d40 test: the sulfur geyser's four particles and the noxious gas
Six types the client implements never reached the comparison: the corpus drew
its kinds from a list without geyser, geyser_plume, geyser_base, geyser_poof,
noxious_gas or noxious_gas_cloud, and the probe's option reader has asked for
water_blocks and burst_impulse_base all along.

The geyser, its plume, base and poof now run with water columns of 1, 2, 3, 5
and 8 blocks and burst impulses of 0, 0.25, 0.5 and 1; the gas and its cloud
run dry and in the still water the cloud needs for a spawn point.
2026-11-02 23:23:02 +00:00
JoansitohandClaude Sonnet 5.5 061d571065 Reject out-of-range hex colours in text components (TextColor.parseColor)
Fixes #7674

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:22:05 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 3091a5363c /tag add reaches a thrown trident, a firework rocket and a falling block
`tags_of` reads the Tags of these three (the rocket's and the trident's
compound by entity id, the falling block's saved compound), but `raw_mut`, the
writer behind `/tag`, had no arm for them, so `/tag ... add` wrote nothing and
still reported success, and `@e[tag=]` never found them. Rockets and tridents
now write to their compound by entity id; a falling block writes to its saved
compound and makes one when it has none.

tools/lan_e2e.py --check entity-tags.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 18:20:52 -05:00
MSedgeMC a80520b546 harness: the case's rider flag reaches canAutoJump
The probe asks can_auto_jump for the case's passenger flag before it drives
MotionWorld, which moves an unmounted player; the oracle answers isPassenger()
from the field its Jumper already carried.
2026-11-02 23:20:38 +00:00
MSedgeMC da96552f1b test: a player riding something never auto-jumps
canAutoJump refuses a passenger before it looks ahead, with the on-ground,
staying-on-surface, moving and block-jump-factor tests. The corpus never made
the player a passenger, so that guard ran with the flag false everywhere.

A rider now walks at each of the 39 blocks in the scene from 0.6 of a block
away, at the two step lengths the on-foot walk family jumps from, and one case
in five of the random family rides too.
2026-11-02 23:20:35 +00:00
rdbtCVSandCodex gpt-6.1-sol dfcc1b98f1 perf: avoid repeated work in chunk section encoding
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 23:17:08 +00:00
Florian ERNSTandCodex ce18ed43e1 Merge main and retain first-tick mob projectile notifications
Co-Authored-By: Codex <[email protected]>
2026-11-03 00:16:08 +01:00
MSedgeMC a0a5d98c3f port_map: scale rows for the seat and the enderman offset
`LivingEntity.getPassengerRidingPosition`'s row still said the attachments
were not scaled by `getScale`; every box and attachment carries the attribute
now, and only the age scale remains a gap. `EndermanRenderer.getRenderOffset`'s
note names the `0.02 * state.scale` it takes.
2026-11-02 23:14:50 +00:00
MSedgeMC 09767b3c7d EndermanRenderer.getRenderOffset: a scaled enderman moves further
`getRenderOffset` adds a gaussian `0.02 * state.scale` per horizontal axis for
a creepy enderman. Horizon used a fixed 0.02, so a `minecraft:scale` 2
enderman's random side offset stayed at the scale-1 distance while its model
and box doubled. Multiply by `Mob::model_scale()`.

The test asserts the same seeded gaussian offset doubles at scale 2, with no
window.
2026-11-02 23:10:28 +00:00
MSedgeMC d5a788463e Format the scale dimensions change
CI runs cargo fmt --all -- --check; three expressions in living.rs were written
unformatted.
2026-11-02 23:10:28 +00:00
MSedgeMC d48b33df9a port_map: LivingEntity.getDimensions and getScale rows
getDimensions had no row; it now points at mobs/living.rs::dims, which is
getDefaultDimensions(pose).scale(getScale()) with the sleeping box ahead of
the scale. getScale moves from riders::scale (now a thin f64 wrapper) to
Mob::model_scale, and sanitizeScale, which was absent, names that function's
two caps.
2026-11-02 23:10:28 +00:00
MSedgeMC 8a33c96638 test: a cow's 0.9 x 1.4 box at minecraft:scale 2 is 1.8 x 2.8
EntityType.COW is sized(0.9f, 1.4f).eyeHeight(1.3f) in 26.3, so
getDimensions(pose).scale(2) is 1.8 x 2.8 with its eyes at 2.6, the box
Mob::bounds hands the hitbox raycast, the attack range and the leash pins.
The attribute's floor, 0.0625, gives 0.05625 x 0.0875 and eyes at 0.08125.
Without the scale in awake_dims_in the scale-2 case reads the type's own
0.8999999761581421 x 1.399999976158142 x 1.2999999523162842.

The other two pin what the scale must not do: a sleeping villager keeps
SLEEPING_DIMENSIONS's 0.2 at scale 4, and sanitizeScale caps a shulker at 3
(EntityType.SHULKER's 1 x 1 becomes 3 x 3, eyes at 1.5 where 4 would give
2.0) and a happy ghast at 1, where a cow takes its scale as it came.
2026-11-02 23:10:28 +00:00
MSedgeMC da5273da8f fix: scale every living entity's box and eye height by minecraft:scale
LivingEntity.getDimensions is getDefaultDimensions(pose).scale(getScale())
for every living entity, and Horizon ran that scale only on players and
mannequins: a cow or zombie with a minecraft:scale attribute rendered (and
cast its shadow) at the attribute's size while its box, eye height, name
tag, leash pins, seat and the hitbox the attack range picks stayed at the
type's. A datapack's giant zombie could be walked through.

getScale is sanitizeScale(attributes.getValue(SCALE)), SCALE being
RangedAttribute("attribute.name.scale", 1.0, 0.0625, 16.0). Mob::model_scale
is now that one port, with the two species that cap it (Shulker 3,
HappyGhast 1), and awake_dims_in multiplies width, height and eye height by
it after the baby, species and size dims, as EntityDimensions.scale(f) does.
The sleeping box stays SLEEPING_DIMENSIONS's 0.2, which getDimensions
returns before it ever reaches getScale.

riders::base_attachment divided the scale out of height() for avatars only,
since a player's box was the only one that carried it; every box does now, so
the default attachment divides it out for all of them (a scaled cow's seat
still rides at 1.4, 2.8 at scale 2). riders::scale, the shadow radius and the
avatar model scale read the same helper instead of repeating the 0-means-unset
check.
2026-11-02 23:10:28 +00:00
HrafnaValkyrja 68152ad42b Merge remote-tracking branch 'origin/main' into fix/cart-bubble-column 2026-11-02 18:06:35 -05:00
Florian ERNSTandCodex 93c63826da Merge main into item pickup-delay fix
Co-Authored-By: Codex <[email protected]>
2026-11-03 00:05:34 +01:00
MSedgeMC 88c9e568db Every living entity's model follows minecraft:scale
`LivingEntityRenderer.submit` scales the pose stack by
`LivingEntityRenderState.scale` (`getScale()`) for every living entity, and
does it after the sleeping offset and before `setupRotations`. Horizon applied
the attribute only in the avatar branch of `species_scale`, so a scaled cow or
zombie kept its model at the type's size while every box already followed the
scale (#7844).

Move the uniform scale into `base_transform_at`, between the bed offset and
`setupRotations`, and leave only `AvatarRenderer.scale` (0.9375) in the avatar
branch. The sleeping translate stays outside the scale: vanilla does not scale
it, and a uniform scale does not commute with it.

Tests are matrix assertions, no window: a sleeping villager at scale 2 (the
bed offset stays put while the pose after it doubles) and an avatar at scale 2
(1.875, not 3.75).
2026-11-02 23:04:05 +00:00
MSedgeMC cd9e24fd82 Format the scale dimensions change
CI runs cargo fmt --all -- --check; three expressions in living.rs were written
unformatted.
2026-11-02 23:04:05 +00:00
MSedgeMC d63176135b port_map: LivingEntity.getDimensions and getScale rows
getDimensions had no row; it now points at mobs/living.rs::dims, which is
getDefaultDimensions(pose).scale(getScale()) with the sleeping box ahead of
the scale. getScale moves from riders::scale (now a thin f64 wrapper) to
Mob::model_scale, and sanitizeScale, which was absent, names that function's
two caps.
2026-11-02 23:04:05 +00:00
MSedgeMC f29cd0c74a test: a cow's 0.9 x 1.4 box at minecraft:scale 2 is 1.8 x 2.8
EntityType.COW is sized(0.9f, 1.4f).eyeHeight(1.3f) in 26.3, so
getDimensions(pose).scale(2) is 1.8 x 2.8 with its eyes at 2.6, the box
Mob::bounds hands the hitbox raycast, the attack range and the leash pins.
The attribute's floor, 0.0625, gives 0.05625 x 0.0875 and eyes at 0.08125.
Without the scale in awake_dims_in the scale-2 case reads the type's own
0.8999999761581421 x 1.399999976158142 x 1.2999999523162842.

The other two pin what the scale must not do: a sleeping villager keeps
SLEEPING_DIMENSIONS's 0.2 at scale 4, and sanitizeScale caps a shulker at 3
(EntityType.SHULKER's 1 x 1 becomes 3 x 3, eyes at 1.5 where 4 would give
2.0) and a happy ghast at 1, where a cow takes its scale as it came.
2026-11-02 23:04:05 +00:00
MSedgeMC 1503bf9127 fix: scale every living entity's box and eye height by minecraft:scale
LivingEntity.getDimensions is getDefaultDimensions(pose).scale(getScale())
for every living entity, and Horizon ran that scale only on players and
mannequins: a cow or zombie with a minecraft:scale attribute rendered (and
cast its shadow) at the attribute's size while its box, eye height, name
tag, leash pins, seat and the hitbox the attack range picks stayed at the
type's. A datapack's giant zombie could be walked through.

getScale is sanitizeScale(attributes.getValue(SCALE)), SCALE being
RangedAttribute("attribute.name.scale", 1.0, 0.0625, 16.0). Mob::model_scale
is now that one port, with the two species that cap it (Shulker 3,
HappyGhast 1), and awake_dims_in multiplies width, height and eye height by
it after the baby, species and size dims, as EntityDimensions.scale(f) does.
The sleeping box stays SLEEPING_DIMENSIONS's 0.2, which getDimensions
returns before it ever reaches getScale.

riders::base_attachment divided the scale out of height() for avatars only,
since a player's box was the only one that carried it; every box does now, so
the default attachment divides it out for all of them (a scaled cow's seat
still rides at 1.4, 2.8 at scale 2). riders::scale, the shadow radius and the
avatar model scale read the same helper instead of repeating the 0-means-unset
check.
2026-11-02 23:04:04 +00:00
NerfPhaselockandClaude Opus 5.5 67f910fa79 Renderer: GPU culling lists the near solid sections the walk keeps
GPU frames draw the near solid sections from lists a compute pass builds
(gfx/gpu_culling.rs, gpu_culling.wgsl), one multi_draw_indexed_indirect_count
per arena page, instead of the CPU listing a few draws per section by the
sides it shows the eye and grouping them by page. Every resident section
with solid quads has a record under an id it keeps while it stays: upload,
release, remove_columns and clear_sections keep the records in step, and
its copy. The pass applies the changed records, then writes each page's
draws in id order, so where faces of two sections tie in depth the result
is the same every frame.

The CPU walk still decides: in a GPU frame its solid loop marks the whole
sections it keeps within the render distance that have a record, and the
pass draws exactly those. Sections fading in or out, columns past the render
distance, far terrain and translucent quads stay on the CPU's lists, so GPU
frames show the CPU frames' pixels. Frames with the title, a pack's terrain,
a shadow view or the water mirror stay CPU frames; GPU frames turn cull.rs's
occlusion test off.

It is offered where the device draws a count the GPU wrote (DirectX 12, or
Vulkan 1.2 or VK_KHR_draw_indirect_count) and runs compute shaders, and
where vanilla frames never draw flat; for now the control word gpu-culling
on turns it on. gpu_culling_requested, which means cull.rs's pass, becomes
occlusion_culling_requested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:00:01 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 9bb1158910 Arrows, tridents and snowballs strike boats and minecarts as vanilla's do
Boats were no target for any projectile and minecarts only for arrows, so a
trident or a snowball flew through them and an arrow flew through a boat.
Vanilla's VehicleEntity is pickable: its hurtServer takes the damage (shake,
+10 per point, destroyed past 40), and the projectile is spent (arrow,
snowball) or bounces (trident).

- Boats and minecarts join the targets of arrows (tridents included) and
  snowballs, in the tick, the mod-visible tick-through and the thrown trident.
- The thrown trident's bounce is half what it was: ProjectileDeflection.REVERSE
  scales by -0.5 before the (0.02, 0.2, 0.02); it now stops where it met the
  box, which grows by ProjectileUtil.computeMargin, and its drag is the float
  0.99F.
- tools/lan_e2e.py --check projectile-vehicles.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 17:58:28 -05:00
MSedgeMC dc219db844 Format the scale dimensions change
CI runs cargo fmt --all -- --check; three expressions in living.rs were written
unformatted.
2026-11-02 22:54:53 +00:00
Ludovic Marie 0799237026 Merge #7789 into the language stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-core/src/lang.rs
2026-11-02 23:54:44 +01:00
Ludovic Marie 2df72e6637 Merge #7787 into the language stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-core/src/lang.rs
2026-11-02 23:54:44 +01:00
Ludovic Marie a5574132d8 Merge #7785 into the language stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-core/src/lang.rs
2026-11-02 23:54:43 +01:00
Ludovic MarieandClaude Opus 5.5 c25b3eb969 Merge #7782 into the language stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:54:43 +01:00
Ludovic Marie 6f9b98bbc1 Merge #7739 into the language stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	launcher/src/i18n.rs
2026-11-02 23:54:31 +01:00
Ludovic Marie 77d82184b1 Merge #7734 into the language stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	launcher/src/i18n.rs
2026-11-02 23:54:29 +01:00
Ludovic Marie 7154f5363c Merge #7727 into the language stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	launcher/src/i18n.rs
2026-11-02 23:54:28 +01:00
Ludovic Marie d356e74458 Merge #7718 into the language stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	launcher/src/i18n.rs
2026-11-02 23:54:27 +01:00
Ludovic Marie 8f9370ce10 Merge #7650 into the language stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-core/src/lang.rs
#	launcher/src/i18n.rs
2026-11-02 23:53:17 +01:00
Ludovic MarieandClaude Opus 5.5 ef0a89e640 Merge #7648 into the language stack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:52:57 +01:00
TheMrZZandCodex gpt-6.1-sol f373144fe5 Tick chicken egg timers with NoAI
Share the existing adult egg-laying step with the NoAI path and cover
countdown, laying, baby and jockey controls on real dedicated servers.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 23:51:35 +01:00
Florian ERNSTandCodex edab224760 Merge main into mob Fire Resistance fix
Co-Authored-By: Codex <[email protected]>
2026-11-02 23:51:08 +01:00
Ludovic Marie 31f83499b6 Merge pull request #7585 from MSedgeMC/port-map/suggest-provider-rows
🤖 Agent: queued at b17870dbf5. Port map rows only. Every cited file and function exists on main and does what the row says (checked the ones moved from missing to port against the code).
2026-11-02 23:50:59 +01:00
Ludovic Marie 97ec4a419e Merge pull request #7580 from MSedgeMC/port-map/wall-hanging-sign-chain
🤖 Agent: queued at b3475f8769. Port map rows only. Every cited file and function exists on main and does what the row says (checked the ones moved from missing to port against the code).
2026-11-02 23:50:55 +01:00
Ludovic Marie a22ca63f00 Merge pull request #7565 from MSedgeMC/chore/ledger-client2-011
🤖 Agent: queued at 70b60c488d. Port map rows only. Every cited file and function exists on main and does what the row says (checked the ones moved from missing to port against the code).
2026-11-02 23:50:50 +01:00
Ludovic Marie d69f78a791 Merge pull request #7535 from MSedgeMC/chore/led2-client2-010
🤖 Agent: queued at 76f1dc4a66. Port map rows only. Every cited file and function exists on main and does what the row says (checked the ones moved from missing to port against the code).
2026-11-02 23:50:46 +01:00
MSedgeMC 71c59f86c7 Double-click an empty slot in the pickup-all test
doClick only starts the collect when the carried stack is not empty and the
clicked slot has no item or may not be taken; the test clicked a slot holding
the carried item, which vanilla refuses.
2026-11-02 22:50:07 +00:00
Ludovic Marie c7b92e6174 Merge pull request #7479 from JustKarkat/feat/music-picker
🤖 Agent: queued at dbf5ea1396. I added 90acfe8039 and 9e555b5303: the new commit showed the page's headings (Overworld, Nether biomes...) as raw English through Text::literal; they are now keys under horizon.pages.music in en_us.json, shown with tr, so they can be translated. I also merged main (dbf5ea1396): the cosmetics extras and the music ones sit side by side in extras.rs. Clippy -D warnings and the extras and music tests pass on playhorizon.
2026-11-02 23:49:18 +01:00
Florian ERNSTandCodex db175a315a Initialize captains' head drop chance with the ominous banner
Co-Authored-By: Codex <[email protected]>
2026-11-02 23:48:40 +01:00
gigamans442andCodex GPT-6.1 Sol ba0474c12c Bind lowered array lengths in declaration scope
Keep sized array length constant under local size-name shadows and avoid generated binding collisions with authored identifiers.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-02 17:48:24 -05:00
Ludovic Marie e2385d0ddb Merge pull request #7449 from MSedgeMC/test/openable-blocks
🤖 Agent: queued at ef6cc9fa38. CI parity passes use_item_on against vanilla at this head (built on #7425, main merged): the openable, dragon egg and block state cases match.
2026-11-02 23:44:48 +01:00
Ludovic Marie dbf5ea1396 Merge main into #7479
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/extras.rs
#	docs/extras.md
2026-11-02 23:44:13 +01:00
Ludovic Marie 2b1577ec1c Merge pull request #7425 from MSedgeMC/test/dragon-egg
🤖 Agent: queued at 66f5d2ed9b. CI parity on the stacked #7449 (which contains this PR, merged with main) passes use_item_on against vanilla. I merged main here (66f5d2ed9b): the harness conflict was two new case groups, both kept.
2026-11-02 23:42:53 +01:00
itarqos5andClaude Opus 5 1346da6bfa feat(packs): browse and install resource packs from the pack screen
An Add button beside the search box on Select Resource Packs opens a screen
that searches Modrinth, shows what it found and installs one into the packs
folder. The available list refreshes without the screen closing, so the pack
can be turned on straight away.

Off by default, behind a new `packBrowser` extra. The pack screen is
otherwise vanilla's and tools/test_pack_screen_parity.py compares it with
vanilla, so nothing is drawn at all unless a player turns it on: with the
extra off the screen's draw output is unchanged.

The screen carries a sidebar of category and resolution filters, its own
search box, and one card a pack: icon, name, author, description, and an
Install button that reads Installing and then Installed. Two columns where
the window has room for them, one where it does not. The layout is pinned by
a test rather than by eye: the name column ends before Install begins and the
description starts below the icon, at both widths.

`Backdrop` is new in screen_draw: a screen says there which background it
wants, and `Game::shows_panorama` honours it. The panorama is a cube the
renderer draws before the HUD, so a draw list cannot hold one; a screen asks
instead. The two cases that always had a panorama are untouched, and a
screen that leaves the backdrop at None behaves exactly as before. Only in a
menu: in a world it would replace the world the player is standing in.

Downloaded icons are freed when the browser closes. Each is 64 KiB and a
search brings twenty, so left in the pack screen's icon map they accumulated
for the rest of the session.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-02 22:42:27 +00:00
Ludovic Marie 156b22ff39 Merge pull request #7481 from TheMrZZ/fix/bossbar-list-order
🤖 Agent: queued at 1dfb29c7f0. Tested in a union of 8 PRs on playhorizon, then again on current main: clippy and the lib tests pass, and command_exec matches vanilla. /bossbar list now prints in Java HashMap order. I merged main (1dfb29c7f0): the corpus conflict was two new groups side by side, both kept.
2026-11-02 23:39:51 +01:00
Ludovic Marie 83f6e1db72 Merge pull request #7511 from HrafnaValkyrja/test/save-leads-roundtrip
🤖 Agent: queued at 828237afa7. Tested in a union of 8 PRs on playhorizon, then again on current main: clippy -D warnings and the full horizon-client/-lib/-items lib tests pass, and command_exec and save_roundtrip match vanilla. The save round trip now covers leads, and passes.
2026-11-02 23:39:46 +01:00
Ludovic Marie c0cfafdeb8 Merge pull request #7506 from k3yh/fix/loot-command-nested-tables
🤖 Agent: queued at 79ae7cfe19. Tested in a union of 8 PRs on playhorizon, then again on current main: clippy -D warnings and the full horizon-client/-lib/-items lib tests pass, and command_exec and save_roundtrip match vanilla. /loot rolls loot_table entries through the nested table, as the new corpus cases check.
2026-11-02 23:39:41 +01:00
Ludovic Marie 33b2e23087 Merge pull request #7505 from k3yh/fix/mob-death-loot-table
🤖 Agent: queued at a65b941358. Tested in a union of 8 PRs on playhorizon, then again on current main: clippy -D warnings and the full horizon-client/-lib/-items lib tests pass, and command_exec and save_roundtrip match vanilla. A DeathLootTable on a mob now replaces its type's table when it dies, and the save round trip keeps it.
2026-11-02 23:39:37 +01:00
Ludovic Marie 049a551b09 Merge pull request #7482 from glitchsnake/fix/7442-experience-orb-water-flow
🤖 Agent: queued at d6bb00a14d. Tested in a union of 8 PRs on playhorizon, then again on current main: clippy -D warnings and the full horizon-client/-lib/-items lib tests pass, and command_exec and save_roundtrip match vanilla. I fixed the new test (d6bb00a14d): ExperienceOrb.tick's air drag is the float 0.98F, so the expected velocity is 0.014 * 0.99 * (double) 0.98F; the code was right.
2026-11-02 23:39:33 +01:00
Ludovic MarieandClaude Opus 5.5 9e555b5303 Music page: borrow the heading's key for tr
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:38:45 +01:00
itarqos5andClaude Opus 5 cc6742f381 feat(packs): a Modrinth client for resource packs
The pieces a screen needs to search Modrinth for resource packs and put one
in the packs folder, with no UI of its own yet. The launcher has its own
browser under launcher/src/browser/, but the launcher is a separate
workspace and nothing there is reachable from the game.

Search and download both run on a worker thread and report over a channel,
so a caller only ever polls. Filters map to Modrinth facets, grouped so that
terms within a group widen the search and separate groups narrow it: asking
for 16x and 32x gives both rather than nothing.

What a reply says is treated as data, not as instruction:

- a served file name cannot leave the packs folder (separators and parent
  references are stripped, and the name always ends in .zip)
- a download is checked against the SHA-1 the index states for it
- a reply larger than 512 MiB is refused rather than read into memory
- the file is written beside its destination and renamed, so a pack list
  never sees a half-written zip
- an existing pack of the same name is never overwritten; the new one is
  numbered

SHA-1 is implemented here rather than taking a dependency for it, and is
checked against the published FIPS 180-4 vectors.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-02 22:38:29 +00:00
Ludovic Marie 1dfb29c7f0 Merge main into #7481
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-02 23:37:06 +01:00
itarqos5andClaude Opus 5 9cb1c15761 feat(hud): decode WebP and JPEG images beside PNG
`decode_bytes` read PNG only, so any image that was not one silently failed
to load. The game's own resources are PNG throughout, but images fetched
over the network are commonly neither: Modrinth serves many project icons
as WebP.

The format is told apart by the bytes themselves rather than by a file
name, because a name is not always there and would not be trustworthy
anyway. A `RIFF` container is only WebP when the tag at byte 8 says so, so a
WAV is not mistaken for one. Anything unrecognised still falls through to
the PNG reader, so its error stays the one reported.

Both decoders are small and pure Rust, taken instead of the whole `image`
stack: `image-webp` and `zune-jpeg`. WebP animations give their first frame,
as an animated PNG already does; JPEG has no alpha, so every pixel is
opaque, and greyscale JPEGs expand to RGBA like colour ones.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-02 22:35:36 +00:00
Ludovic Marie 79853c996a Merge pull request #7772 from horizon-rust/feat/cosmetics-draw
🤖 Agent: requeued after merging main: main made mobs::skin::Skin's fields private, so the cosmetics draw now builds it with Skin::new (same walk, no per-frame change); stress_scenes keeps both stress_avatars and main's stress_markings. Clippy -D warnings and test_perf_bench pass on this head.
2026-11-02 23:34:35 +01:00
Stink-OandClaude Opus 5.5 d003ea875a Merge main (0c7b5b61a) into native/block-sprites
Regenerated the mod API Host files with cargo run -p xtask -- mod-api.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:31:47 +00:00
Ludovic Marie 5a3e9b02b9 Merge feat/cosmetics-first-light into feat/cosmetics-game-settings 2026-11-02 23:30:38 +01:00
Ludovic Marie 4444825ecb Merge feat/cosmetics-wardrobe-client into feat/cosmetics-first-light 2026-11-02 23:30:38 +01:00
Ludovic Marie 0fccde52c9 Merge feat/cosmetics-wardrobe-api into feat/cosmetics-wardrobe-client 2026-11-02 23:30:38 +01:00
Ludovic Marie fa67431258 Merge feat/cosmetics-name-tag into feat/cosmetics-wardrobe-api
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/ja.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/tr.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-02 23:30:37 +01:00
Ludovic Marie c4e339ae81 Merge feat/cosmetics-preview into feat/cosmetics-name-tag 2026-11-02 23:30:10 +01:00
Ludovic Marie 9e8c3f64f4 Merge feat/cosmetics-draw into feat/cosmetics-preview 2026-11-02 23:30:10 +01:00
Ludovic Marie 52d38ca94c Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/stress_scenes.py
2026-11-02 23:28:18 +01:00
Ludovic Marie 0c7b5b61a5 Merge pull request #7584 from Xmilon/content/recipe-book-categories
🤖 Agent: queued at 35210289dd. Tested in a union of the five on playhorizon: clippy -D warnings and the recipe and mod lib tests pass. Main merged here (35210289dd): xtask mod-api --check and the stamp tests pass. The categories are read once and cached until the book rebuilds, so opening the book costs nothing more without a mod.
2026-11-02 23:28:00 +01:00
Ludovic Marie 818ed0ac38 Merge pull request #7557 from Xmilon/content/block-shapes
🤖 Agent: queued at 3abeb4f845. Tested in a union of the five on playhorizon: clippy -D warnings and the content and mod lib tests pass. Main merged here (3abeb4f845): xtask mod-api --check and the stamp tests pass. Shapes are resolved when the content loads; the per-block collision lookup is unchanged.
2026-11-02 23:27:55 +01:00
Ludovic MarieandClaude Opus 5.5 35210289dd Merge main into #7584
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:25:07 +01:00
Ludovic MarieandClaude Opus 5.5 3abeb4f845 Merge main into #7557
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:24:46 +01:00
MSedgeMC 84eb77ff5f Ledger: AbstractContainerMenu.doClick
The row points at the engine that dispatches the click and repeats the quick
move, and names the guest copy's one-pass form of it.
2026-11-02 22:23:50 +00:00
gigamans442andCodex GPT-6.1 Sol 4092cfd0bb Fix global constants made runtime by zero division rewriting
Preserve finite declarators and lowered array type queries, dependencies, and initializer order.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-02 17:23:01 -05:00
MSedgeMC 74a52b916d Test that one quick move spreads a stack like vanilla's repeat
doClick calls quickMoveStack again while the clicked slot keeps the item it
moved, because moveItemStackTo fills one empty slot per call. This engine
calls quick_move once, and move_to fills every empty slot of its range in
one pass, which leaves the same remainder as vanilla's loop: 40 snowballs at
16 a slot land 16/16/8, two slots of room leave 8 in the chest, and a full
inventory moves nothing.

The QUICK_MOVE arm says so, so the invariant is not lost to a later
"one slot per call" change to move_to.
2026-11-02 22:22:48 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 90d7553211 A bubble column lifts a mob after its travel, as LivingEntity.aiStep does
Entity.move has no bubble column push in vanilla. LivingEntity.aiStep
calls travel and then applyEffectsFromBlocks, so the lift is added after
the fluid drag of that tick. The mob port pushed at the end of do_move,
inside travel, so water drag scaled the lift by 0.8 (an iron golem in an
up column: 0.139 on the first tick, vanilla 0.175). The push now follows
travel in the mob tick.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 17:22:16 -05:00
Ludovic MarieandClaude Opus 5.5 dd53e1121e Cosmetics: the game's switches match the Wardrobe's; name tag styles drawn
Extras > Cosmetics: My cosmetics (the account's: the game signs in for one call through Mojang's
join and PUT /v1/visible, notes what the service confirmed per account, retries from 15 s to
10 minutes), Other players' cosmetics (Shown, Name tags only, Hidden; the old on/off line keeps
its meaning) and Others' auras and trails (off by default). horizon_cosmetics::prefs holds the
lines the launcher writes too. A player's name tag style draws its mark before the name and its
line under it, in the name tag's three passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:21:18 +01:00
gigamans442andCodex GPT-6.1 Sol 01d13cb9d4 Merge remote-tracking branch 'origin/main' into fix/shader-post-destination-20261102
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-02 17:20:39 -05:00
gigamans442andCodex GPT-6.1 Sol dc1c6fd5bf fix(shaders): retain unwritten post destinations and frame clears
Respect device attachment count and format byte limits when batching the required main-image clears.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-02 17:20:30 -05:00
MSedgeMC 48474b4c59 port_map: LivingEntity.getDimensions and getScale rows
getDimensions had no row; it now points at mobs/living.rs::dims, which is
getDefaultDimensions(pose).scale(getScale()) with the sleeping box ahead of
the scale. getScale moves from riders::scale (now a thin f64 wrapper) to
Mob::model_scale, and sanitizeScale, which was absent, names that function's
two caps.
2026-11-02 22:18:31 +00:00
MSedgeMC 507a23ff4a test: a cow's 0.9 x 1.4 box at minecraft:scale 2 is 1.8 x 2.8
EntityType.COW is sized(0.9f, 1.4f).eyeHeight(1.3f) in 26.3, so
getDimensions(pose).scale(2) is 1.8 x 2.8 with its eyes at 2.6, the box
Mob::bounds hands the hitbox raycast, the attack range and the leash pins.
The attribute's floor, 0.0625, gives 0.05625 x 0.0875 and eyes at 0.08125.
Without the scale in awake_dims_in the scale-2 case reads the type's own
0.8999999761581421 x 1.399999976158142 x 1.2999999523162842.

The other two pin what the scale must not do: a sleeping villager keeps
SLEEPING_DIMENSIONS's 0.2 at scale 4, and sanitizeScale caps a shulker at 3
(EntityType.SHULKER's 1 x 1 becomes 3 x 3, eyes at 1.5 where 4 would give
2.0) and a happy ghast at 1, where a cow takes its scale as it came.
2026-11-02 22:18:31 +00:00
MSedgeMC df52bce5c7 fix: scale every living entity's box and eye height by minecraft:scale
LivingEntity.getDimensions is getDefaultDimensions(pose).scale(getScale())
for every living entity, and Horizon ran that scale only on players and
mannequins: a cow or zombie with a minecraft:scale attribute rendered (and
cast its shadow) at the attribute's size while its box, eye height, name
tag, leash pins, seat and the hitbox the attack range picks stayed at the
type's. A datapack's giant zombie could be walked through.

getScale is sanitizeScale(attributes.getValue(SCALE)), SCALE being
RangedAttribute("attribute.name.scale", 1.0, 0.0625, 16.0). Mob::model_scale
is now that one port, with the two species that cap it (Shulker 3,
HappyGhast 1), and awake_dims_in multiplies width, height and eye height by
it after the baby, species and size dims, as EntityDimensions.scale(f) does.
The sleeping box stays SLEEPING_DIMENSIONS's 0.2, which getDimensions
returns before it ever reaches getScale.

riders::base_attachment divided the scale out of height() for avatars only,
since a player's box was the only one that carried it; every box does now, so
the default attachment divides it out for all of them (a scaled cow's seat
still rides at 1.4, 2.8 at scale 2). riders::scale, the shadow radius and the
avatar model scale read the same helper instead of repeating the 0-means-unset
check.
2026-11-02 22:18:31 +00:00
itarqos5andClaude Opus 5 3ecc48b4c7 fix(debug): half the automatic scale, rounded up, for the F3 overlay
`debugGuiScale` 0 (the default, Auto) means half the automatic GUI scale,
and the halving truncated. An even automatic scale is exactly half either
way, but an odd one lost: a 1280x720 window has an automatic scale of 3, so
the overlay drew at 1, a third of the GUI scale, while a maximised 1920x1080
window has 4 and drew at 2, a half. The overlay therefore looked smaller,
relative to everything else on screen, on a restored-down window than on a
maximised one.

Rounded up instead, so an odd scale takes the nearer usable half. Every even
automatic scale is unchanged, which is why the existing expectations for
854x709 and 1920x1080 still hold; a 1280x720 case is added beside them.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-02 22:17:48 +00:00
MSedgeMC 8b6d24fbae Ledger: Slot.mayPickup and Player.canDropItems
Both rows name the guest menu's copy of the guard: the armour rule and the
menu's own rules for a pickup, and the drop throttle for a THROW.
2026-11-02 22:16:27 +00:00
MSedgeMC 2671f0926a Test the guest menu's pickup and drop guards
A refused slot keeps its stack against a click and a shift-click while the
slot next to it gives its own up; a collect starts from an empty or a
refusing slot only, and skips the slots that refuse; THROW drops nothing
while can_drop_items is false; and a survival guest cannot take a cursed
chestplate off, while a creative one can.
2026-11-02 22:16:27 +00:00
MSedgeMC 54d75a852b Menus: the guest menu reads Slot.mayPickup and Player.canDropItems
doClick guards four takes that Horizon's guest menu engine had no way to
refuse: a PICKUP or a QUICK_MOVE out of a slot whose mayPickup says no, the
clicked slot of a PICKUP_ALL (a collect starts from an empty slot or from
one that refuses), each slot that collect sweeps, and THROW while
Player.canDropItems is false.

Rules gains may_pickup and can_drop_items, both defaulting to true, so a
host's plain container slots behave as before and a menu with stricter
slots can say so. View::may_pickup answers with the one rule the player's
own slots have in vanilla, ArmorSlot.mayPickup: a survival player cannot
take off armour carrying the binding curse (PREVENT_ARMOR_CHANGE), which
the local menu engine already refuses and the guest path did not.
2026-11-02 22:15:57 +00:00
MSedgeMC cabb4486c1 Ledger: the off-hand armour branch of InventoryMenu.quickMoveStack 2026-11-02 22:13:34 +00:00
MSedgeMC c686e9eeef Test wearing armour out of the off-hand slot
An iron helmet shift-clicked from menu slot 45 lands in the helmet slot;
with that slot taken the next helmet fills the main inventory, and three
diamonds from the off hand fill it too.
2026-11-02 22:13:34 +00:00
MSedgeMC 34e889990d Menus: a shift-click from the off hand wears the armour it holds
InventoryMenu.quickMoveStack asks for the item's equipment slot before it
looks at the clicked index, so the armour branch also runs for menu slot
45, the off-hand slot: an armour piece held there is put on when its armour
slot is empty. Horizon's first arm took every off-hand stack straight to
the inventory, so a helmet in the off hand stayed unworn.

The chain now keeps vanilla's order above the armour slots: the equipment
slot first, then the main-inventory and hotbar ranges, then the final else
that an off-hand stack takes into the inventory (which is where that index
already went).
2026-11-02 22:13:34 +00:00
Ludovic Marie 56afb1c0d8 Merge pull request #7162 from glitchsnake/perf/4128-explosions-fps
🤖 Agent: queued at 86929d2ebe. The points of my earlier review are addressed (UPDATE_ALL kept). I added 7f5ff72503: the debris pick now draws the weighted random for every particle, as vanilla's does, and the unused explosion_blocks is gone. Clippy and the particle lib tests pass on playhorizon with main merged.
2026-11-02 23:12:01 +01:00
Ludovic MarieandClaude Opus 5.5 2b2dfaa6db Launcher: a launcher theme worn in the Wardrobe brings its sky and accent
First Light, the founder set's theme, replaces the sky's six stops and the accent's hue while the
selected account wears it, read from the outfit kept on disk (Saved::theme) so it holds offline.
The look is worked out again after each change of the outfit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:11:51 +01:00
Ludovic MarieandClaude Opus 5.5 366dd657dd Cosmetics: rustfmt prefs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:11:50 +01:00
Florian ERNSTandCodex ec792786a0 Merge main into mob effective armor fix
Co-Authored-By: Codex <[email protected]>
2026-11-02 23:11:02 +01:00
MSedgeMC 37049dbca5 Ledger: AbstractContainerMenu.isContainerClickButton
The row names the click guard in the guest menu's click dispatch, and
THROW's own reading of the button.
2026-11-02 22:10:21 +00:00
MSedgeMC 7b1a5c6c0b Test that only buttons 0 and 1 click a container slot
A PICKUP or a QUICK_MOVE with button 2 leaves the stack in its slot, the
cursor alone and drops nothing outside the window; button 1 still takes
half of the stack, and THROW's button 2 still means the whole stack.
2026-11-02 22:10:21 +00:00
MSedgeMC 357b48baf1 Menus: a pickup or a quick move is a click with button 0 or 1
AbstractContainerMenu.isContainerClickButton refuses PICKUP and QUICK_MOVE
for any button other than 0 and 1 before doClick does anything else, so
such a click moves nothing at all. Horizon read every button except 1 as
the primary action, so a modified client could take a stack out of a slot,
shift-click it into the hotbar, or drop the cursor with a button the
vanilla screen never sends.

THROW is not gated by that check, but its amount reads the button the same
way: doClick takes one item for button 0 and the whole stack for every
other button, where Horizon took one for anything but button 1.
2026-11-02 22:10:21 +00:00
Ludovic Marie 06d51d16f2 Merge pull request #7528 from ThatCakeIsAPie/feat/datapack-biomes-network
🤖 Agent: queued at a43709317e. Tested in a union on playhorizon: clippy -D warnings, the datapack_biome_sync test and lan_e2e chest (host and guest) pass. The new worker context is built only when a world opens or resources reload, and reuses the pack's when the biome table is the same, so nothing changes per frame or per chunk.
2026-11-02 23:09:24 +01:00
rdbtCVSandCodex gpt-6.1-sol 9e114dc491 perf: tile sparse chunk ticket levels
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 22:06:44 +00:00
Florian ERNSTandCodex c05506e297 Load summoned evoker fangs into active simulation
Co-Authored-By: Codex <[email protected]>
2026-11-02 23:05:43 +01:00
Ludovic MarieandClaude Opus 5.5 1468f0860a Launcher: the Wardrobe client keeps the outfit offline, knows themes and visibility
The founder set replaces the early-player set in words. Entry::theme (a launcher theme's sky and
accent), Me::visible and Service::set_visible (Show my cosmetics), and Saved: the last catalogue
and answer plus the changes still to send, per account, synced in order once the service answers.
Item files are kept by hash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:05:06 +01:00
Ludovic MarieandClaude Opus 5.5 c9b454ebd3 Launcher: the Wardrobe is a top-bar page
Between Servers and Versions, with the cosmetics flag on: a row per slot, the player on their own
skin with their name tag style, what they own for the chosen slot, the founder set's claim (and,
once its window closes, that it was free until then and isn't offered any more), and three
switches: Show my cosmetics (on the service), Hide other players' cosmetics and Show other
players' auras and trails (horizon_cosmetics::prefs, the game's horizon-extras.txt lines). It opens
on the outfit kept on disk: offline it keeps working and counts what waits; a failure keeps the
outfit with Try again and Copy details. Settings > Account no longer has the Wardrobe panel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:05:06 +01:00
Ludovic MarieandClaude Opus 5.5 a291c94493 Launcher: Wardrobe picture texels with as_chunks (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:05:06 +01:00
Ludovic MarieandClaude Opus 5.5 bcec565e4b Launcher: the Wardrobe, behind the cosmetics flag
Settings > Account gets 'Wardrobe…' on a Microsoft account when
horizon_cosmetics::enabled(): a picture of the account's skin wearing its
items (turnable), the early-player set's claim while its window is open,
and a row per slot to choose what to wear. HORIZON_LAUNCHER_PAGE=wardrobe
(development builds) shows it with a made-up account, for screenshots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:05:06 +01:00
OlaHullebergandClaude Opus 5.5 bb5c43d602 Content blocks choose their render_shape
A content block always drew as its template does, so a block whose moving parts its block entity
draws every frame (a turning shaft, a machine's arm) still had its model baked into the chunk mesh.
`properties.render_shape` takes vanilla's `RenderShape` by name: `MODEL`, `ENTITYBLOCK_ANIMATED`
(the model is left out of the chunk mesh) or `INVISIBLE`. It sets the state's `render_shape`, which
the mesher already reads. Outline, collision, light and occlusion are unchanged.

Item 1 of #5550.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:04:34 +00:00
Ludovic Marie 7124749605 Merge pull request #7533 from MSedgeMC/fix/wither-spawn-particles
🤖 Agent: queued at 1a8c48a62f. I added 1a8c48a62f: the charge's particles were spawned only by the window that ran the wither, so players on a server (the default single player too) still saw none. remote.rs now spawns them for a server's clients from the synced charge, as vanilla's client tick does. Clippy and the wither lib tests pass in a union on playhorizon.
2026-11-02 23:03:37 +01:00
Ludovic Marie 75b2d8bfad Merge pull request #7525 from TheMrZZ/fix/experience-level-boundary
🤖 Agent: queued at 418cca25b5. Tested in a union on playhorizon: clippy -D warnings passes, and command_exec matches vanilla on every case this PR adds (the only failing cases were the trial spawner ones that #7689 fixed on main since).
2026-11-02 23:03:31 +01:00
MSedgeMC efcc2c4c98 port_map: Creaking.getHurtSound names the sway and the hostile hurt
The creaking had no sound row at all. Its hurt sound is the sway while it is
bound to a heart and `Monster.getHurtSound` otherwise, and the giant, which
overrides nothing, keeps the same hostile pair.
2026-11-02 22:00:49 +00:00
MSedgeMC 9e91a6c8c1 test: a blow on a creaking names the sway or the hostile hurt
Three hits through `Mob::damage`, the `LivingEntity.hurtServer` the game runs
for a landed blow, read back from the mob's outbox. A creaking with no heart
takes the blow through `Monster.hurtServer` and plays `entity.hostile.hurt`;
a heart-bound one only takes `#bypasses_invulnerability`, whose hurt sound is
`entity.creaking.sway`. A giant is hurt and then killed for the hostile pair.

With the arm removed all three read what the table gives today:
`["entity.generic.hurt"]` for the unbound creaking, the sway case and the
giant's hurt, and the giant's death is `["entity.generic.death"]`.
2026-11-02 22:00:49 +00:00
MSedgeMC 3dc2eba2ad fix: a creaking and a giant hurt with the monster sounds
`Creaking.getHurtSound` answers a blow with `SoundEvents.CREAKING_SWAY` while
the creaking is bound to a heart and with `super.getHurtSound` otherwise, which
is `Monster.getHurtSound`: `entity.hostile.hurt`. `Monster.getDeathSound` is
`entity.hostile.death`, and `Giant` overrides none of the three, so it keeps
the hostile pair too.

`ai::voice_sounds` had no arm for either, so both fell through to the type's
own sounds: the creaking's table row carries `entity.generic.hurt`, the
giant's `entity.generic.hurt` and `entity.generic.death`.

Refs tools/port_map/game2-003.tsv (Creaking.getHurtSound, new row).
2026-11-02 22:00:49 +00:00
gigamans442andOpenAI Codex 4eb4b71cb7 Shader packs: resolve legacy texture-size queries
Resolve EXT_gpu_shader4 size-query names only for matching known sampler
dimensions, preserving pack-defined functions, macros and invalid-call diagnostics.

Co-Authored-By: OpenAI Codex (GPT-6.1 Sol) <[email protected]>
2026-11-02 16:59:55 -05:00
Ludovic Marie 10e0b694b3 Merge pull request #7509 from UMCEKO/item-models/mod-type-fallbacks
🤖 Agent: queued at 68c6f12e92. Tested in a union on playhorizon: clippy -D warnings and the full horizon-assets lib tests pass, foreign_tests included. Everything happens when item models load; the per-frame item render path is unchanged. A definition that still fails now logs why instead of disappearing without a word, which helps pack authors.
2026-11-02 22:59:03 +01:00
Ludovic Marie 799e545722 Merge pull request #7499 from Qwerner88880/ingredient-test
🤖 Agent: queued at b56ec105e0. Tested in a union on playhorizon: clippy -D warnings, the crafting, native and music lib tests pass. Main merged here (b56ec105e0, hook conflicts resolved, generated.rs regenerated): xtask mod-api --check, the markers and the stamp tests pass. With no mod, a recipe ingredient has no custom kind, so the craft test never reaches the hook.
2026-11-02 22:57:45 +01:00
Ludovic Marie b56ec105e0 Merge main into #7499
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 22:55:57 +01:00
Ludovic MarieandClaude Opus 5.5 6d036acb46 Merge main into #7479
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:54:48 +01:00
Ludovic MarieandClaude Opus 5.5 90acfe8039 Music page: its headings are lang keys again
775981f9a8 made the biome headings at run time and brought back raw English
labels shown with Text::literal. The built-in and biome group labels are now
keys under horizon.pages.music, and Row::Heading shows tr(key).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:54:41 +01:00
TheMrZZandCodex gpt-6.1-sol 842fbf138a Gate saved InWaterTime on cached eye immersion
Apply the ConversionTracker affliction predicate for Zombie-derived types when writing their saved counter. Add real-server wet, dry, feet-only and water-removal controls and mark the positive counter codec fixture as afflicted.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 22:53:05 +01:00
Ludovic MarieandClaude Opus 5.5 8237151d51 Launcher: the Wardrobe's link to the cosmetics service
The service checks the account the way an online-mode server does: the
launcher tells Mojang's session server it joins a one-time id the service
chose, and the service asks Mojang. The Minecraft token goes to Mojang only.
Catalogue, the account's items, claiming the early-player set while its
window is open, and what each slot wears. Unused until the Wardrobe panel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:52:44 +01:00
Ludovic Marie 7084e67744 Merge pull request #7508 from MSedgeMC/fix/menu-offhand-quick-move
🤖 Agent: queued at 8987ae75eb. Tested in a union of 8 PRs on playhorizon: clippy and the full horizon-client/-lib/-items lib tests pass, the new off-hand quick-move test included. One nit for later: the comment says a totem goes to the off hand, but a totem has no equippable slot in 26.3, so only items whose component says offhand (the shield) take that branch, which is what the code does.
2026-11-02 22:52:04 +01:00
Ludovic MarieandClaude Opus 5.5 d6bb00a14d Expect the orb's float air drag in the flowing-water test
ExperienceOrb.tick multiplies by 0.98F, so the velocity after one tick is
0.014 * 0.99 * (double) 0.98F, about 2.6e-10 away from the double 0.98.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:48:15 +01:00
Federico TaglianiandClaude Sonnet 5.5 f90541efef A villager's food is the item's villager_food component
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 22:46:23 +01:00
Ludovic MarieandClaude Opus 5.5 a2edcd034f Cosmetics: name tag styles, a mark and a line with no model
A name_tag item has no parts: its texture is a square mark of at most 16x16 drawn before the
player's name and tag.line the colour of a line under it. bone defaults to head. The game's
body pass never draws one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:46:12 +01:00
gigamans442andCodex 58e5884547 Fix shader depth snapshot timing and demand
Keep depth2 before the opaque hand and depth1 after it. Include Deferred
post readers and attached compute stages in depth requirements, and
populate requested copies for water-only shader programs.

Fixes #7815

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-02 16:42:53 -05:00
MSedgeMC 4eeb851bdd container_screen: generate the pick-item cases
Every fourth case clicks with the pick-item button instead of the left or the
right one: on the menu slot, outside the window and over neither, with a
carried stack or an empty hand, with and without infinite materials, and
sometimes with another button's click between the press and the release.
2026-11-02 21:41:44 +00:00
MSedgeMC d88da91fd9 container_screen: add the pick-item button's click case
`AbstractContainerScreen.mouseClicked` asks keyPickItem.matchesMouse(event)
&& player.hasInfiniteMaterials() before anything else, and then answers CLONE
for the hovered stack (or slot id -999 outside the window) instead of PICKUP,
QUICK_MOVE or THROW; a carried stack starts a quick craft drag of type 2
instead of a plain one; and mouseReleased answers CLONE for the carried stack
whenever the event is the pick-item button, so a Survival player's middle
release clones too (and the server refuses it). Horizon answers all of it on
main (`container_screen::Clicks`'s press and release, reached from the game's
inventory_pick_press and inventory_pick_release) and no case pressed the third
button.
2026-11-02 21:41:41 +00:00
MSedgeMC abd607f028 port_map: Shulker.getDefaultDimensions points at the peek scale
The row said inline at `shulker::progress_aabb` and that the down-face height
scale was not seen; the scale is `shulker::peek_height_scale`, applied by
`Mob::awake_dims_in` over the plain box.
2026-11-02 21:41:25 +00:00
MSedgeMC 7dbed3b22e test: an open shell under a block is 1 x 1.5 with its eyes at 0.75
The test ticks the client path a remote shulker takes: `Shulker.tick` eases
`currentPeekAmount` 0.05 a tick towards the synced raw peek, 100 while
`ShulkerAttackGoal` holds the lid open, so ten ticks leave 0.5 and
`Shulker.getDefaultDimensions` scales the 1 x 1 x 0.5 box by `(1, 1.5)`.

With the arm removed it reads "a half open shell is 1 x 1.5 with its eyes at
0.75: 1 x 1 x 0.5". A second test keeps the other five attach faces at
1 x 1 x 0.5 with the lid fully open, and a closed shell under a block at 1 x 1.
2026-11-02 21:41:21 +00:00
MSedgeMC fb0807bfc3 fix: a shulker hanging below a block grows taller as its lid opens
`Shulker.getDefaultDimensions` scales the plain box by `(1, 1 + currentPeekAmount)`
when the shell is attached to `Direction.DOWN` and the lid has started to open,
so a shulker fixed under a block stands up to twice as tall while it fires, and
its eyes rise with the box. Horizon kept the 1 x 1 box for every peek amount.

`Mob::awake_dims_in` applies that scale last, over every other arm, through
`shulker::peek_height_scale`: it reads the eased peek in `anim.v[PEEK]`, the
`currentPeekAmount` a client ticks towards the synced raw peek.

Refs tools/port_map/game2-002.tsv:121 (Shulker.getDefaultDimensions, inline).
2026-11-02 21:41:04 +00:00
Florian ERNSTandCodex 16f4e08928 Send chicken and parrot flap events to sculk sensors
Co-Authored-By: Codex <[email protected]>
2026-11-02 22:37:51 +01:00
gork 279329fe47 Update French game (fr_fr) translation 2026-11-02 22:37:30 +01:00
Ludovic Marie 0bcd2f1567 Merge pull request #7412 from glitchsnake/fix/5812-chest-minecart-open
🤖 Agent: queued at 3042ed59ab. Built and ran with #7407/#7431: the host's half of the extended minecarts check passes; the guest's punches fail exactly as on main 3fc4c4ca0 (#7817), which this PR doesn't touch.
2026-11-02 22:29:13 +01:00
Federico TaglianiandClaude Sonnet 5.5 c993a28c5d Entities win the depth tie with coplanar terrain: the boat's floor over a trapdoor
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 22:29:09 +01:00
Ludovic Marie c58d296d5a Merge pull request #7431 from TheMrZZ/fix/name-tag-armor-stand
🤖 Agent: queued at 61c54f727b. lan_e2e frames passes for host and guest (diamond turned in the frame, helmet on and off, name tag consumed). minecarts fails the same way on main (#7817).
2026-11-02 22:29:09 +01:00
Ludovic Marie a5e5e93425 Merge pull request #7407 from readerkid/fix/shulker-push-step
🤖 Agent: queued at 32779e1a20. lan_e2e on playhorizon (with #7412 and #7431 merged on top): shulker_lift and containers pass; the guest stands on an opening shulker box like the host. minecarts fails the same way on main 3fc4c4ca0 (#7817), not this PR.
2026-11-02 22:29:03 +01:00
gigamans442andCodex 993663b918 Use fixed-size pixel chunks in the GPU fixture
Use the iterator spelling required by current Clippy for constant RGBA
chunks. Preserve every pixel predicate, count and assertion.

Co-Authored-By: Codex (GPT-6.1-sol) <[email protected]>
2026-11-02 16:24:47 -05:00
TheMrZZandCodex gpt-6.1-sol 04627b17a7 Use the entity-type Peaceful policy for summon admission
Share the existing allowed-in-Peaceful check between plain and NBT summons instead of rejecting the monster category. Add fresh-server admission controls for camel husks, camels, husks and zombie horses.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 22:17:58 +01:00
gigamans442andCodex eae70a8ab6 Specify byte type in the skin output regression
The byte comparison is ambiguous with serde_json's numeric PartialEq impls.
Make the cast output explicit without changing production skinning behavior.

Co-Authored-By: Codex (GPT-6.1-sol) <[email protected]>
2026-11-02 16:15:39 -05:00
Florian ERNSTandCodex 85e10d5686 Reject missing selector predicates before negation
Co-Authored-By: Codex <[email protected]>
2026-11-02 22:14:27 +01:00
MSedgeMC 39d128927b use_item_on: generate the repeater, comparator and daylight detector case 2026-11-02 21:13:55 +00:00
MSedgeMC c390be3360 use_item_on: generate the redstone wire dot and cross case 2026-11-02 21:13:45 +00:00
HrafnaValkyrja be79f209ba Merge remote-tracking branch 'origin/main' into fix/wind-gaps 2026-11-02 16:13:34 -05:00
HrafnaValkyrja 5936df9648 Merge remote-tracking branch 'origin/main' into test/entity-tick-order 2026-11-02 16:13:21 -05:00
gork 18c46ff86a Add Portuguese (pt_pt) game translation 2026-11-02 22:13:17 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 5f327b3806 Add a tick order parity harness: where join order changes what entities do
Measures, tick by tick against the vanilla server, 82 cells of two kinds
joined in both orders (wind, TNT blast, projectile hits, rider and vehicle,
pushes and merges). Horizon's 38 known differences are recorded in KNOWN,
printed every run and not failing CI. No game behaviour changes (#6509).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 16:13:06 -05:00
Ludovic Marie f1d6576b33 Merge pull request #7762 from horizon-rust/feat/cosmetics-fetch
🤖 Agent: queued at b72413d1de. Nothing calls it yet; the point to watch is that the frame never blocks: Frame::worn only reads the cache under one lock, and the worker posts at most 64 UUIDs per request with a 15 s→10 min backoff when offline, so a dead service costs the game nothing.
2026-11-02 22:12:08 +01:00
Ludovic MarieandClaude Opus 5.5 a1ba83e95c Cosmetics: a still picture of a player wearing items, drawn on the CPU
For the launcher's Wardrobe: the player model at rest with its skin and
each item on its bone, from any angle, with a depth buffer and cutout
alpha. Tests check which face shows from the front and from behind, and
that a cap sits above the head without covering the face.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:10:06 +01:00
Ludovic MarieandClaude Opus 5.5 dcf54ab49c Cosmetics: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:10:06 +01:00
Ludovic Marie 9a0150862f Merge pull request #7792 from horizon-rust/feat/launcher-glass-instance-java
🤖 Agent: the loader grid reads Prism's list through loaders::Kind::parse, so it shows only Fabric and Quilt until the Forge/NeoForge backends land; their logos and notes are already in.
2026-11-02 22:09:49 +01:00
MSedgeMC bf4ae5c890 use_item_on: add the redstone wire dot and cross case
RedstoneWireBlock.useWithoutItem turns a wire that is a cross or a dot into
the other shape and passes anything else on; Horizon's wire_use already does
it and nothing in the suite clicked a wire.
2026-11-02 21:09:28 +00:00
Federico TaglianiandClaude Sonnet 5.5 6bcd48bc66 A hit piglin brute calls the piglins around it, as maybeRetaliate does
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 22:09:21 +01:00
MSedgeMC 38be47f1cd use_item_on: add the repeater, comparator and daylight detector case
All three turn their own property by hand on a right click
(RepeaterBlock.useWithoutItem, ComparatorBlock.useWithoutItem,
DaylightDetectorBlock.useWithoutItem) and Horizon already does each; the
suite clicked none of them.
2026-11-02 21:09:20 +00:00
HrafnaValkyrja 460663ab7c Merge remote-tracking branch 'origin/main' into fix/wind-gaps
# Conflicts:
#	crates/horizon-client/src/game/wind.rs
2026-11-02 16:08:55 -05:00
Ludovic Marie 89b1db287e Merge pull request #7778 from horizon-rust/feat/launcher-glass-instance-modal
🤖 Agent: the dialog now pins a new Horizon instance's version when a non-newest Nightly is picked (Instance::version, same as the Home picker); picking Newest keeps it following updates.
2026-11-02 22:07:47 +01:00
ClickingMasterandClaude Sonnet 5.5 0cee614b6d Drowned go-to-water: doc the vanilla source
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 13:06:24 -08:00
Federico TaglianiandClaude Sonnet 5.5 1f5ebb03eb A struck lightning rod schedules its unpowering
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 22:04:59 +01:00
Ludovic MarieandClaude Opus 5.5 32779e1a20 Merge main into #7407
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:04:55 +01:00
Ludovic MarieandClaude Opus 5.5 38830a813c Drop the guest container sound test that compiles slices of storage.rs
It pinned the source text of container_sound and the guest open path
rather than what a player hears; lan_e2e's containers and shulker_lift
checks run both through two real games.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:04:55 +01:00
gigamans442andCodex 50e285cd9a Merge current public main and preserve tagged layer fallback
Keep the merged pose reuse and tagged-layer contracts. Resolve the sole
renderer method rename conflict with the standard-pack CPU-layer guard,
and update the original fixture's capability calls. No unmerged decoder
implementation is imported.

Co-Authored-By: Codex (GPT-6.1-sol) <[email protected]>
2026-11-02 16:00:37 -05:00
Federico TaglianiandClaude Sonnet 5.5 54b7779983 Paintings cull their back faces, so nothing shows from inside the box
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:59:35 +01:00
gigamans442andOpenAI Codex 95942138f7 Tests: read world-target pixels with fixed-size chunks
Use the standard array chunk iterator for known RGBA pixels, retaining the
same original GPU outputs and assertions while satisfying current Clippy.

Co-Authored-By: OpenAI Codex (GPT-6.1 Sol) <[email protected]>
2026-11-02 15:58:57 -05:00
[email protected]andClaude Sonnet 5.5 428a5694b8 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:58:18 +01:00
gigamans442andCodex 9909af9ced Bridge standard shader packs to GPU entity posing
Retain shader identities, overlays and hidden shadow casters in GPU output
space while preserving conservative CPU fallbacks and separate glowing outlines.
Use each current culling query's result instead of historic CPU-span markers.
Include original draw/offset/overlay regressions and an asset-free offscreen
CPU/GPU comparison fixture. Validation remains in progress for draft review.

Co-Authored-By: Codex (GPT-6.1-sol) <[email protected]>
2026-11-02 15:57:22 -05:00
gigamans442andCodex GPT-6.1 Sol 9c09b12c61 Fix terrain mc_midTexCoord for partial quads
Average all four mapped UV corners while retaining the non-flat tile-centre fallback. Add original terrain/shadow GPU regressions for partial, rotated, clamped and four-corner UVs.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-11-02 15:57:13 -05:00
Federico TaglianiandClaude Sonnet 5.5 02bcaa34ce The block pick takes the interaction shape's face for cauldrons, composters, hoppers and scaffolding
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:55:55 +01:00
Ludovic Marie 3fc4c4ca05 Merge pull request #7395 from arismoko/v2/ses_01M6E91JFQZ707DB55V2730YTX-w1-bow
🤖 Agent: queued at d9b0388195. Checked: clippy, the bow and native lib tests and the arrow harness pass on the branch before fa876269f3. That commit only puts the crossbow mob's attack-delay draw back before the pitch draw, as main has it. Its head builds and passes xtask mod-api --check and the stamp tests on today's main.
2026-11-02 21:55:18 +01:00
gork a420e6bfdb Add Urdu game (ur_pk) translation 2026-11-02 21:55:01 +01:00
Ludovic Marie 6d7d6b7083 Merge pull request #7497 from horizon-rust/perf/horse-markings-gpu
🤖 Agent: horse markings and the slime outer cube now take the GPU skin path when the renderer draws tagged layers in order (improved transparency off); the every-mob byte test covers a whitefield horse and a slime, and translucent bodies stay on the CPU.
2026-11-02 21:53:48 +01:00
gork 0fd4da41d8 Add Indonesian game (id_id) translation 2026-11-02 21:51:23 +01:00
Federico TaglianiandClaude Sonnet 5.5 bd5bd166c9 A nautilus charge knocks its target back with LivingEntity.knockback
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:51:09 +01:00
gork 7837481e45 Add Hindi game (hi_in) translation 2026-11-02 21:50:25 +01:00
Ludovic MarieandClaude Opus 5.5 112d954387 launcher: the instance dialog's Vanilla and Modded parts in glass
Mojang's versions (Snapshots, Old, search, the list) and the loader grid,
one card per loader loaders::Kind knows, with its logo and the mockup's
note, four across from 1280 px.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:49:16 +01:00
Ludovic MarieandClaude Opus 5.5 11abdd59c1 launcher: the instance dialog in glass
The mockup's InstanceModal: kind cards and a live preview on the left;
Version (pin) and Start from for Horizon, name and group, icons (loader
logos, grass block, blocks, colours) on the right. All four mockup loader
logos ship now; the loader list follows loaders::Kind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:48:17 +01:00
Ludovic Marie 873b04b1a9 Merge pull request #7410 from arismoko/v2/ses_01M6EA9PP2BP3SA9FS27G86287-w3-explosion
🤖 Agent: queued at 5494d7fc5e. Your main merge changes only context lines, so the code is what I checked: clippy -D warnings plus the explosion and native lib tests pass. On today's main it passes xtask mod-api --check and the stamp tests. The hook fires only with a native mod loaded.
2026-11-02 21:47:20 +01:00
Federico TaglianiandClaude Sonnet 5.5 9b6684d8c4 A mob's gear counts in its knockback resistance
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:47:11 +01:00
Ludovic Marie f700231384 Merge pull request #7498 from horizon-rust/perf/map-frame-names
🤖 Agent: map_vertices now borrows the map's decorations instead of cloning them; the borrow ends before any mutable use of self.maps, so nothing changes in what is drawn.
2026-11-02 21:45:12 +01:00
Ludovic Marie 0c3a01490c Merge pull request #7496 from horizon-rust/perf/mob-layer-names
🤖 Agent: a layer on the body's own model now copies the body's poses and Skin::draw reuses the previous walk when model, base matrix and pose version match; the CPU vertex digest of all 90 mob types × ages × variants is byte-identical to main.
2026-11-02 21:45:06 +01:00
razzo715andClaude Opus 5.5 eac12655bc The command reader skips the whitespace Java does (#7672)
Brigadier's StringReader.skipWhitespace uses Character.isWhitespace;
Reader::skip_whitespace used Rust's char::is_whitespace. Rust skips U+0085
and the no-break spaces U+00A0, U+2007 and U+202F, which Java doesn't, and
Java skips U+001C to U+001F, which Rust doesn't. Reader::is_java_whitespace
is Java's set, checked against Java 25 over all of Unicode.

Fixes #7672

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:44:24 +01:00
TheMrZZandCodex gpt-6.1-sol 1c5777ac8b Tilt big dripleaves fully when projectiles hit
Dispatch projectile block hits to the dripleaf behavior, including powered leaves, and schedule the existing full-tilt reset after 100 ticks. Add fresh-server projectile and no-hit timing controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 21:43:36 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 150c00e2cb Wind bursts push late-ticking kinds after their own tick
ServerLevel ticks entities in join order, so primed TNT, a falling block
or an experience orb that joined before a wind charge has already moved
when the charge bursts, and the push is for its next tick. Horizon ticks
per kind and these three tick after the projectiles, so the push landed
a tick early (6 of 99 wind_walk values off the real server). The push
now waits for the entity's own tick when it joined before the charge.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 15:43:10 -05:00
Florian ERNSTandCodex 2e8ddc24ce Preserve aquatic FromBucket save values and defaults
Co-Authored-By: Codex <[email protected]>
2026-11-02 21:40:21 +01:00
gigamans442andCodex GPT-6.1-sol d3117ec15d Respect compiled standard-pack entity shadow ownership
Use the running pack's compiled non-player entity caster capability to
avoid adding vanilla mob blobs beside its shadows. Preserve fallback
for player-only, disabled, absent and failed paths and native dimensions.

Add original compiled-pipeline and production sprite-selection controls.

Co-Authored-By: Codex GPT-6.1-sol <[email protected]>
2026-11-02 15:34:50 -05:00
readerkid 2b72af6652 Push the player with a real shulker-box move, and let the opener hear the box
A lid pushing this client's own player wrote the position straight into the
body. Vanilla's moveCollidedEntities calls Entity.move(MoverType.SHULKER_BOX,
...), an ordinary move: the collision scan resolves it and its end writes the
ground state. A direct write leaves the body's on_ground stale, so a body the
lid lifts off the ground keeps a ground flag gravity has to fight, and the lift
reads as steps rather than a move. The local player now goes through
MotionWorld::shulker_box_push -> entity_move::move_entity, and only the player:
the box entities the server pass already pushed are left to it, so a hosted
world no longer pushes its guests and mobs twice a tick.

The pushed distance stays vanilla's (box size + 0.01):
Shulker.getProgressDeltaAabb has no halving in 26.3, so a lid rising 0.05 per
tick pushes 0.11.

The container open and close sounds never reached everyone. A guest opening a
container told nobody: the host's copy changed its opener count only when the
menu closed, so the box opened silently for every player around, the opener
included. Vanilla counts the opener (ContainerOpenersCounter.incrementOpeners)
and plays the sound with a null source (level.playSound(null, ...)), so the
host now counts a guest's open and, on the first opener, plays the sound where
the capture relay (lan_broadcast_effects) carries it to every guest, the opener
included. Nothing is queued separately: the relay already sends every play of
the tick, and a second queue made a hosted world play the sound twice.

Fixes #6657

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-11-02 12:34:10 -08:00
gork a1a3eaa740 Ensure all current launcher strings are present 2026-11-02 21:33:52 +01:00
gork ad7268a080 Add missing launcher server/world strings to Korean 2026-11-02 21:33:52 +01:00
gork 54ba9570fe Add missing launcher strings to Korean translation 2026-11-02 21:33:52 +01:00
gork b1845cac25 Add full Korean launcher translation 2026-11-02 21:33:52 +01:00
gork 757346bd7c Fill in Korean game translation 2026-11-02 21:33:52 +01:00
gorkandClaude Code af2b35afad Add Korean (ko) translation support
Co-Authored-By: Claude Code <[email protected]>
2026-11-02 21:33:52 +01:00
Ludovic MarieandClaude Opus 5.5 6a35c8e1e8 Cosmetics: reuse the pose buffers and bone matrices between avatars
300 avatars with three items each: frame p50 +0.63 ms over none, +0.48 ms
after (stress_avatars, alternating A/B on the Mac mini).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:33:37 +01:00
gork 3dbff4c58c Ensure all current launcher strings are present 2026-11-02 21:32:31 +01:00
gork 74bfded4ab Add missing launcher server/world strings to Croatian 2026-11-02 21:32:31 +01:00
gork 0e9cb96e1f Add missing launcher strings to Croatian translation 2026-11-02 21:32:31 +01:00
gork 7c76e2c9c2 Add full Croatian launcher translation 2026-11-02 21:32:31 +01:00
gork 63aa3cca41 Fill in Croatian game translation 2026-11-02 21:32:31 +01:00
gorkandClaude Code 6285aa692f Add Croatian (hr) translation support
Register Croatian (hr / Hrvatski) as a launcher language and Horizon game
language, falling back to English for untranslated strings.

Co-Authored-By: Claude Code <[email protected]>
2026-11-02 21:32:31 +01:00
gigamans442andOpenAI Codex a61029e052 Shader packs: refresh replaced world colour and depth bindings
Track the actual source views so equal-sized world-target replacements do
not leave post-processing sampling the previous frame. Add original GPU
regressions for independent replacements, retained inputs, resize and the
no-final fallback.

Fixes #7744

Co-Authored-By: OpenAI Codex (GPT-6.1 Sol) <[email protected]>
2026-11-02 15:31:18 -05:00
Federico TaglianiandClaude Sonnet 5.5 9d51225eb7 Shader pack textures decode PNGs with wrong chunk checksums; pin the bad-IHDR case
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:31:05 +01:00
gork 8c724598bc Fill missing launcher strings in Mandarin translation 2026-11-02 21:28:57 +01:00
gork d6abf19ad3 Fill missing launcher strings in Arabic translation 2026-11-02 21:28:31 +01:00
gork 3ed6547fba Update locale parse test for European Portuguese 2026-11-02 21:26:40 +01:00
Ludovic Marie 68ff469984 Merge pull request #7430 from arismoko/v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-native-template
🤖 Agent: queued at 0e6e918a75. The native template builds a trait mod against the current hooks and ships with the rest of cargo horizon-mod new. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests and datapack_worldgen parity pass.
2026-11-02 21:24:29 +01:00
MSedgeMC 95c28566db use_item_on: generate the lever and button case 2026-11-02 20:23:20 +00:00
MSedgeMC 6ec822d5b9 use_item_on: add the lever and button click case
Vanilla's LeverBlock.useWithoutItem pulls the lever on either state and
ButtonBlock.useWithoutItem presses a button that is not already pressed,
both of which Horizon already does; the suite had no case for either.
2026-11-02 20:23:20 +00:00
razzo715andClaude Opus 5.5 9204f99e25 A ridden mount is not pushed by the mobs it touches (#7647)
Entity.push(Entity) moves an entity only when it is not a vehicle
(!isVehicle() && isPushable(), for both). push_entities pushed a ridden
horse anyway, and the server sent its new motion to the rider, whose
client drives the horse and took it over the jump: the horse jumped
lower, or not at all, against a mob on Horizon servers.

A vehicle is no longer pushed by the mobs it touches nor moved by its own
push; the mobs around it are still pushed away. Seen gains is_vehicle so
a pusher knows its target is one.

Fixes #7647

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:22:33 +01:00
Ludovic Marie 888d6949ae Merge pull request #7720 from horizon-rust/feat/cosmetics-format
🤖 Agent: queued at 2111ead65d. Pure Rust crate, nothing calls it yet; the risk is the format itself: unknown fields are refused and the 512-vertex / 64×64 / 24 px reach limits are checked at parse, so later PRs can't draw an item past them. Box UV face order follows ModelPart.Cube (tested on the down face).
2026-11-02 21:22:20 +01:00
ClickingMasterandClaude Sonnet 5.5 b9aac00674 Shader #if: skip division errors on the side && / || short-circuits
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 12:20:30 -08:00
TheMrZZandCodex gpt-6.1-sol 2c7ad7f2be Create block entities when falling blocks land
Load carried TileEntityData over the placed block entity defaults so falling chests retain their inventory. Add a dedicated-server regression with direct placement, empty chest, supplied inventory and falling sand controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 21:19:58 +01:00
JoansitohandClaude Sonnet 5.5 4e19cc0a83 Guest deaths award the deaths stat, deathCount and reset the death timers
ServerPlayer.die: DEATH_COUNT objectives, awardStat(DEATHS), resetStat of
TIME_SINCE_DEATH and TIME_SINCE_REST. Part of #6885.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:19:43 +01:00
Florian ERNSTandCodex 0325f870e3 Merge main into villager command name fix
Retain villager name cases alongside main's command corpus additions.

Co-Authored-By: Codex <[email protected]>
2026-11-02 21:18:31 +01:00
JoansitohandClaude Sonnet 5.5 b91722abcd Math.round(float) exactness for /time arguments and /compute round
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:15:39 +01:00
levzzzandClaude Opus 5.5 de39b5b799 Native hooks: mob_split and baby_entity_spawn name kinds with their namespace
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 01:14:38 +05:00
Ludovic MarieandClaude Opus 5.5 ca4435ef27 Cosmetics: the game draws them on players, behind a flag
The skin path poses each item on its bone and draws it with the player,
textures in the skin atlas's slots; Extras gets 'My cosmetics' and
'Other players' cosmetics'. Off by default (horizon_cosmetics::ON_BY_DEFAULT).
The perf bench gains stress_avatars, 300 mannequins, to measure the cost.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:12:52 +01:00
levzzzandClaude Sonnet 5.5 da55dd6ef6 Native mods: mob_split and baby_entity_spawn hooks
NeoForge's MobSplitEvent at Slime.remove (children count changeable, cancel
spawns none) and BabyEntitySpawnEvent at Animal.spawnChildFromBreeding (cancel
makes no baby; parents still rest, as NeoForge resets them). Part of #4953.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 01:12:49 +05:00
[email protected]andClaude Sonnet 5.5 a530210f89 Merge #7582's attack scope (knockback_scope, knockback_held)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:12:45 +01:00
[email protected]andClaude Sonnet 5.5 f856fbc00d Native mods: one living_incoming_damage hook with the attacker, weapon and geometry
Fold living_incoming_damage_v2 into living_incoming_damage: it gains attacker,
weapon, source_position, victim_position and victim_view; the v2 hook, its
fragment and the mace-only dispatch are gone. The hook has no Host.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:12:44 +01:00
Florian ERNSTandCodex e7573451d7 Default invalid item NBT counts to one
Co-Authored-By: Codex <[email protected]>
2026-11-02 21:12:42 +01:00
Ludovic Marie bc761767d3 Merge pull request #7460 from arslanarm/shaderpacks/composite-scale
🤖 Agent: queued at ec8cebc6a8. A scaled pass loads its attachments instead of clearing them and draws into a whole-pixel viewport, so its buffers keep what lies outside it. Unscaled programs keep [1,0,0] and draw as before. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests and datapack_worldgen parity pass.
2026-11-02 21:10:24 +01:00
Ludovic Marie 2b61a77946 Merge pull request #7459 from arslanarm/shaderpacks/smooth-id
🤖 Agent: queued at 9d20f0cc84. Found by black-box probing of Iris with your own packs, which is fine; the two tests pin the nine smooth forms and the dropped four- and five-argument calls. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests and datapack_worldgen parity pass.
2026-11-02 21:10:20 +01:00
Ludovic Marie 94b517a2c7 Merge pull request #7393 from ThatCakeIsAPie/feat/biome-fixture-parity
🤖 Agent: queued at 6a6ef2de28. The chunk oracle now loads a pack's level stem, so the biome data-pack fixture tests the pack's biomes rather than vanilla's. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests and datapack_worldgen parity pass.
2026-11-02 21:10:15 +01:00
Ludovic Marie 2b9351929d Merge pull request #7444 from MSedgeMC/fix/cave-spider-poison-mob
🤖 Agent: queued at 713582bbdd. A mob's melee hit now applies its weapon's post-attack effects to the mob it hit, not only to players. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests and datapack_worldgen parity pass.
2026-11-02 21:10:11 +01:00
[email protected]andClaude Sonnet 5.5 c4b453775c Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:08:57 +01:00
gork 7183d3c3ae Add Portuguese (pt) launcher translation support (Portugal) 2026-11-02 21:08:17 +01:00
[email protected]andClaude Sonnet 5.5 18c5041b37 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:08:08 +01:00
[email protected]andClaude Sonnet 5.5 13a2129a80 Native mods: one living_knockback hook, with the attacker and what it holds
Fold living_knockback_v2 into living_knockback: it gains attacker, attacker_main
and attacker_off, and an armor stand's sweep fires it. The v2 hook and fragment are
gone. A mixin injection naming native_living_knockback as a raw target must take
the new arguments.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 21:08:07 +01:00
levzzz 0f599d9d13 Merge remote-tracking branch 'hr/main' into modapi/level-reads 2026-11-03 01:08:06 +05:00
Ludovic Marie ec5f55a937 Merge pull request #7732 from horizon-rust/fix/ci-vanilla-data-cp-n
🤖 Agent: unblocks every parity job; macOS cp -n exits 1 on a skipped file under set -e.
2026-11-02 21:07:23 +01:00
gork ab6837364e Add Indonesian (id) launcher translation support 2026-11-02 21:06:44 +01:00
Ludovic MarieandClaude Opus 5.5 57149ccfd8 CI vanilla data: fill missing languages file by file
#7434 copied them with cp -n, which exits 1 on macOS when it skips a
file that is already there: on every runner with a preparation that has
some languages, set -e ended vanilla-data.sh and every parity job failed
in its setup step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:06:37 +01:00
ClickingMaster cdf83e52b0 Merge origin/main 2026-11-02 12:06:20 -08:00
levzzz 49818818f0 Merge main into modapi/living-refs 2026-11-03 01:05:53 +05:00
gork 2b51c591a3 Update French (fr) launcher translation 2026-11-02 21:05:03 +01:00
gork bc5136ae0e Add Hindi (hi) launcher translation support 2026-11-02 21:04:25 +01:00
Ludovic Marie 6beacfe91a Merge pull request #7703 from horizon-rust/feat/launcher-glass-home-console
🤖 Agent: the Console keeps its search, Follow, Copy, Share log (with its mclo.gs confirmation) and logs folder; only the look changes, and the panel loses its now-unused button row since every Home tab draws its own head.
2026-11-02 21:02:29 +01:00
levzzz e804b87eee Merge remote-tracking branch 'hr/main' into modapi/entity-data 2026-11-03 01:01:34 +05:00
levzzz da4058715d Merge main into modapi/entity-motion 2026-11-03 01:01:12 +05:00
ClickingMasterandClaude Sonnet 5.5 2d18714f27 Chat filter: ignore allow lines with no letters
An allow entry like '!*' normalised to empty runs and matched every word,
disabling the whole filter.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 12:00:40 -08:00
rdbtCVSandClaude Opus 5.5 889f87be50 Dedicated server: cut tick costs for spread-out players
- Structure starts: the start cache keeps two generations per shard
  instead of clearing a full shard, so starts still being asked for are
  not rebuilt (jigsaw assembly on the tick thread).
- Streaming: Ranges::distance asks ExtraCover's buckets for the nearest
  area instead of scanning every player; the buckets also answer when
  players are too far apart for the dense grid.
- Minecarts: id lookups take the next index as a hint, cart pushes and
  mob/player pushes look only at carts in nearby chunk cells, and the
  entity boxes a cart collides with are filtered to its reach.
- Natural spawning: players are bucketed by 8-chunk cells for the
  chunk-in-range checks, and mobs find their nearest player through a
  64-block grid (ties to the lowest index, as the scan).
- Protocol: an interrupted socket read is retried instead of dropping
  the connection.

Refs #7713

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:59:24 +00:00
levzzz 6a43ca6d5a Merge main into modapi/level-reads 2026-11-03 00:59:14 +05:00
oxi-does-dev 73729552ef container: settle hotbar keys from the raw key, not the translated one
mapped_key_event translated the physical key into the vanilla default of
its mapping before key_pressed ran, and the container arm then asked
container_hotbar_key about that default again. A default can itself be
another mapping's rebound key: with key.hotbar.7 on F and key.swapOffhand
on Left Alt, the off-hand press arrived as KeyF and swapped the hovered
item into hotbar slot 7.

Vanilla's AbstractContainerScreen.checkHotbarKeyPressed works in
Screen.keyPressed's keyspace: the raw key. mapped_key, key_pressed,
control_key_down now carry the raw key through (the same value where the
caller had no window key), and the container arm rebinding asks about it.

Fixes #7707
2026-11-02 22:58:50 +03:00
[email protected]andClaude Sonnet 5.5 649274f27b Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:58:40 +01:00
[email protected]andClaude Sonnet 5.5 f98a0c6c13 Native mods: a mob's hit on a mob and a mob's projectile record the attacker's held items
mob_hits_mob opened no knockback scope and projectile_hit only did for a player
owner, so living_knockback_v2 saw attacker_main as None for a mob attacker.
knockback_scope_for opens the scope for the attacker a damage source names.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:58:38 +01:00
gorkandClaude Code 751e11f655 Add Arabic (ar) launcher translation support
Registers Arabic (ar, "العربية") as a launcher language using the
provided full Arabic translation (launcher/lang/ar.json).

Co-Authored-By: Claude Code <[email protected]>
2026-11-02 20:58:24 +01:00
gork ce7e7000a4 Update Mandarin (zh) launcher translation 2026-11-02 20:57:09 +01:00
levzzzandClaude Sonnet 5.5 ed024da92f Native hooks: living_death, living_conversion_pre, living_conversion_post
NeoForge's LivingDeathEvent and LivingConversionEvent.Pre/Post. Death is
cancellable for mobs and server players (the entity stays at 1 health).
Conversion Pre is cancellable and fires in the shared convert_to, the
dispenser's mooshroom shear and lightning on pigs and villagers.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:56:41 +05:00
Ludovic MarieandClaude Opus 5.5 898cf8d52e launcher: Forge and NeoForge loader kinds and their version lists
loaders::Kind gains Forge and NeoForge (and Kind::ALL, the order a picker
shows them). Their versions come from Forge's maven-metadata.xml with its
promotions (stable up to the recommended build) and NeoForge's version list
API (21.1.x plays 1.21.1, 26.3.0.x plays 26.3, its forge artifact for
1.20.1). Imports from Prism/MultiMC keep a Forge or NeoForge loader.
Installing them comes in the next PR; until then install says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:56:39 +01:00
levzzz 6ed5b17a6d Merge pull request #7454 from levzzz5154/modapi/entity-handles
Native mods: entity and level handles (EntityRef, LevelRef) and an entity's position, type, UUID and level
2026-11-03 00:56:07 +05:00
Florian ERNSTandCodex 418cca25b5 Merge main into experience level boundary fix
Retain the experience boundary cases alongside main's command corpus additions.

Co-Authored-By: Codex <[email protected]>
2026-11-02 20:55:01 +01:00
levzzzandClaude Sonnet 5.5 b11f571aab Native mods: sleeping problem and experience change (can_player_sleep, player_xp_change)
NeoForge's CanPlayerSleepEvent at ServerPlayer.startSleepInBed: a mod may
clear or replace the first problem vanilla found, or add one. NeoForge's
PlayerXpEvent.XpChange at Player.giveExperiencePoints (orb pickups,
advancement rewards, /xp add points): a mod may change the amount or cancel.
Part of #4953.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 00:53:54 +05:00
Ludovic Marie 76d1cb530c Merge pull request #7706 from horizon-rust/tools/pacing-first-use
🤖 Agent: the end scenario run before portal trips #7702, so the default order keeps portal first.
2026-11-02 20:52:40 +01:00
Ludovic Marie 7414db9be1 Merge pull request #7687 from horizon-rust/feat/launcher-glass-home-mods
🤖 Agent: a mod's on/off still runs switch_mod (the browser's mods_switch, or the jar rename for Java instances) and the rows stay culled to the view for modpacks; native mods keep their old build/GitHub controls at the row's end until the Mods page is ported.
2026-11-02 20:51:34 +01:00
Florian ERNSTandCodex 4ca824f847 Honor the setblock destroy-to-air shortcut
Co-Authored-By: Codex <[email protected]>
2026-11-02 20:51:24 +01:00
Ludovic Marie c358b98008 Merge pull request #7682 from horizon-rust/feat/launcher-glass-home-packs
🤖 Agent: a pack's on/off still goes through packs::set_enabled (options.txt) and Remove through packs::remove (the trash), unchanged; only the rows, the Toggle and the screenshot grid are new, and the grid still stops at 8 until Show all.
2026-11-02 20:50:16 +01:00
Ludovic Marie e80d6c1114 Merge pull request #7445 from TheMrZZ/fix/creeper-custom-fuse
🤖 Agent: queued at 06de8714b7. Requeued after its land conflict: I merged main (06de8714b7). The only conflict was the corpus, where your creeper_custom_fuse group now sits beside #7463's forceload and #7418's max-health groups, all three kept. Fuse is read into max_swell and saved back from it.
2026-11-02 20:50:11 +01:00
Stink-OandClaude Opus 5.5 8dd32dcb13 Merge main (2105feb78) into native/block-sprites
docs/native-mods.md: the world_drawn entry keeps both the block sprite arrays and main's motion paragraph.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:49:15 +00:00
Ludovic Marie 2105feb78d Merge pull request #7354 from Xmilon/native/render-name-tag
🤖 Agent: queued at b7ca31b03a. a8cdcce9d keeps the no-mod path free (no player-id set, no dispatch without a native mod). Its head merged with today's main passes xtask mod-api --check and the stamp tests, and the native lib tests passed on that merge.
2026-11-02 20:47:05 +01:00
Ludovic MarieandClaude Opus 5.5 b72413d1de Cosmetics: what other players wear, fetched in batches and cached
The game asks the cosmetics service what the players in view wear: one
POST for up to 64 UUIDs, on a thread of its own. Answers are cached for
the service's TTL (kept drawn while asked again), item files on disk by
their hash, and a service that doesn't answer leaves the last answers in
place and is asked again after 15 s, doubling up to 10 minutes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:46:44 +01:00
Ludovic Marie 06de8714b7 Merge main into #7445
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-02 20:46:25 +01:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 947d14bf74 Move dedicated player autosave writes off the tick thread
Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-11-02 19:46:12 +00:00
Ludovic MarieandClaude Opus 5.5 95ed63525d pacing_bench: first-use scenarios (End, weather, particles, post chains)
Each records the frames after a first use: the End, rain/thunder/lightning,
53 particle kinds, and spectating the mobs with post chains.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:45:59 +01:00
[email protected]andClaude Sonnet 5.5 b4f5ca7c56 Mod particles: the sprite animation advances in the tick
The Mod tick (size or alpha curves) and the Base tick never called sprite_from_age, so
loop and age stayed on the first sprite. A declared age or loop now ticks like
Behaviour::Animated, the curve tick walks the set too, and a particle without
animation keeps its spawn sprite as before. A test runs the real particle tick.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:45:53 +01:00
Ludovic MarieandClaude Opus 5.5 2111ead65d Cosmetics: the item format, bones and animations (horizon-cosmetics)
A new crate reads cosmetic item files: boxes with Minecraft's box UV, a
texture of at most 64x64, the player model bone the item follows, its
attachment and simple looping animations (swing, bob, spin). Items past
the vertex budget (512), the part count or a reach of 24 pixels from
the bone are refused. Nothing draws them yet; docs/cosmetics.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:43:03 +01:00
Codex 2c7b9f7460 Restore Linux pointer capture on mouse input
Reassert the native cursor grab on gameplay clicks and wheel events, recovering from window-manager grab loss without consuming the event or warping the pointer.

Fixes #7654

Co-Authored-By: Codex <[email protected]>
2026-11-02 19:42:00 +00:00
Ludovic MarieandClaude Opus 5.5 d4540fa520 launcher: Home's Console tab in glass
Not in the mockup (its Overview shows a game log only while the game
starts): kept, in its patterns. The count, then the search (the small
FIELD), Follow (a Toggle), Copy and Share log… (QUIET) and the logs
folder (ICON_BUTTON); the log itself is the mockup's Logs: round at 12,
black at 25 %, Geist Mono 12 px on 1.75, white at 75 %, warnings and
errors in the look's amber and red. Share log asks first on a card as
the patch log's. Every Home tab is now in glass, so the panel's
separate button row goes.

Design: dezer-x (playhorizon.dev/launcher).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:41:57 +01:00
Ludovic Marie b451b111e6 Merge pull request #7292 from levzzz5154/render/world-drawn-motion
🤖 Agent: queued at 82a73e8423. I merged main (82a73e8423): xtask mod-api --check and the stamp tests pass on it, and clippy plus the native and native_render lib tests passed on the previous merge.
2026-11-02 20:41:07 +01:00
Ludovic MarieandClaude Opus 5.5 ef6cc9fa38 use_item_on: the openable and dragon egg branches each keep their own tail
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:40:55 +01:00
Ludovic MarieandClaude Opus 5.5 66f5d2ed9b Merge main into #7425
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:40:27 +01:00
Ludovic Marie ae52503e8c Merge #7425 and main into #7449
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/test_use_item_on_parity.py
2026-11-02 20:40:27 +01:00
hsafandClaude Opus 5.5 9608a5d841 Dedicated server: operators can run /stop, /save-all, /save-on, /save-off
Vanilla's Commands(CommandSelection.DEDICATED) registers StopCommand,
SaveAllCommand (with `flush`), SaveOffCommand and SaveOnCommand for
LEVEL_OWNERS; Horizon only had them on the console. They are now in the
dedicated tree, and the stop and autosave switches live in the shared
Lists, so the console, the management protocol and a player's command
change the same state. A guest's /save-all saves once their turn ends,
when their own state is back in their record.

Fixes #7667

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:40:04 +02:00
Stink-OandClaude Opus 5.5 e348ae8e91 Merge main (e5a138fac) into native/block-sprites
Regenerated the mod API Host files with cargo run -p xtask -- mod-api.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:39:49 +00:00
levzzz 4caae28435 Merge remote-tracking branch 'hr/main' into modapi/entity-handles
# Conflicts:
#	crates/horizon-client/src/game/mod_api_host_generated.rs
#	crates/horizon-client/src/mod_api/generated_host.rs
2026-11-03 00:39:30 +05:00
gork 8b6aabeb59 fmt 2026-11-02 20:39:21 +01:00
Ludovic Marie c642911c97 Merge pull request #7273 from Ragbie/fix/late-entity-add-state
🤖 Agent: queued at 60aa0ec37f. I merged main (60aa0ec37f, the tick_blocks order keeps native_joins_flush before lan_mark_synced). clippy, the lan lib tests and the server_entities and dedicated_server harnesses pass on it.
2026-11-02 20:39:07 +01:00
Ludovic Marie ebb0b9fe97 Merge pull request #7397 from socksprox/t3/block-registry-snapshot-cache
🤖 Agent: queued at 2a001222fc. Same diff as the branch I checked with main merged: clippy -D warnings and the horizon-world cache, snapshot and registry tests pass. The snapshot is keyed beside behaviours-v2.bin, so a stale registry rebuilds instead of loading.
2026-11-02 20:39:01 +01:00
SenjinandClaude Opus 5.5 23e1072806 Shader packs: test that a custom texture over a buffer ends when its stage flips it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:38:59 +01:00
Ludovic MarieandClaude Opus 5.5 82a73e8423 Merge main into #7292
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:38:24 +01:00
fy2neandAntigravity 3f70e22ce5 UUID: require ASCII hex digits and reject multi-byte slices
uuid_from_text validates that all non-dash characters are ASCII hex digits,
preventing a panic when slicing multi-byte characters whose byte length is 32,
and rejecting leading + signs from from_str_radix.

Co-Authored-By: Antigravity <[email protected]>
2026-11-02 11:38:08 -08:00
Ludovic Marie e5a138fac7 Merge pull request #7689 from horizon-rust/fix/command-exec-trial-spawner-dying
🤖 Agent: command_exec passes with it (1615/1615); main fails 12 of these without it.
2026-11-02 20:37:15 +01:00
TheMrZZandCodex gpt-6.1-sol 6d8fae1d71 Use screaming goat voice events from the saved flag
Select the ambient, hurt and death event names through the same goat state used by vanilla. Add a dedicated-server packet regression with adult, baby and silent controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 20:36:38 +01:00
[email protected]andClaude Sonnet 5.5 16348a2ff0 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:36:34 +01:00
Ludovic Marie 8dd76f8d48 Merge pull request #7418 from TheMrZZ/fix/mob-max-health-clamp
🤖 Agent: queued at 604256c623. I pushed 27d3d3ffdb: a saved max_health base other than the type's default now goes into finalize's bases, so /attribute ... base get answers 40 for command_exec's summoned cow instead of 10. command_exec passes with it, then I merged main (the corpus conflict was #7463's forceload group beside yours; both kept).
2026-11-02 20:36:11 +01:00
[email protected]andClaude Sonnet 5.5 abc62fcd7a Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:36:01 +01:00
Ludovic MarieandClaude Opus 5.5 5ed4c1b822 Merge main into the command_exec trial spawner fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:36:00 +01:00
Ludovic Marie f01b04c5fe Merge pull request #7679 from horizon-rust/fix/launcher-glass-home-narrow
🤖 Agent: layout only, from the window width of the last frame (window_width): at 1280 px and wider nothing moves; under it the invite code stacks under the actions and the page strip scrolls, as the mockup's xl breakpoint does.
2026-11-02 20:35:47 +01:00
Ludovic Marie ed98228bcf Merge pull request #7686 from horizon-rust/perf/post-chain-preload
🤖 Agent: the three spectator chains compile on their own thread from the first frame in a world; until a chain has arrived, apply leaves it out instead of compiling in the frame. Cold Metal cache: first-spectate frame 67–140 ms → under 8 ms.
2026-11-02 20:35:44 +01:00
Ludovic Marie b3dfd0257f Merge pull request #7473 from oxi-does-dev/middle-click-world-select
🤖 Agent: queued at 81bea7acc5. Middle click selects the world under the cursor in both title-screen paths and is ignored on the Options screen. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests, command_exec and use_item_on parity pass.
2026-11-02 20:35:40 +01:00
Ludovic Marie c1c27495a7 Merge pull request #7455 from oxi-does-dev/fix-tab-focus
🤖 Agent: queued at cd18f6c74f. Closing the inventory clears the controller cursor's focus, origin, slider edit and pending press, so nothing stays highlighted. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests, command_exec and use_item_on parity pass.
2026-11-02 20:35:36 +01:00
Ludovic Marie 27a1130e3e Merge pull request #7446 from testatanucleare13-create/fix/7424-mod-block-loot-table
🤖 Agent: queued at cf3d1e1187. /loot mine now asks block_loot_table, which keeps a content block's namespace (loot_repro:blocks/...), instead of building a minecraft: id. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests, command_exec and use_item_on parity pass.
2026-11-02 20:35:31 +01:00
[email protected]andClaude Sonnet 5.5 caa5aaf0d3 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:35:30 +01:00
HrafnaValkyrja adedbd05ba Merge remote-tracking branch 'origin/main' into fix/stand-fluids 2026-11-02 14:35:27 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 cdc16cf909 Armor stands in lava and bubble columns move as in vanilla
A stand in lava now follows LivingEntity.travelInLava (halve, sink by gravity / 4)
instead of air physics, and the bubble column's push runs after the stand's
settle step, as LivingEntity.aiStep applies block effects after travel.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 14:35:11 -05:00
arismoko 5494d7fc5e Merge upstream main into w3-explosion
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 19:34:50 +00:00
Ludovic Marie 604256c623 Merge main into #7418
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
2026-11-02 20:34:39 +01:00
arismoko d9b0388195 Merge upstream main into w1-bow
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 19:34:33 +00:00
Ludovic Marie f4a9e85d7b Merge pull request #7434 from testatanucleare13-create/fix/7114-pack-screen-parity
🤖 Agent: queued at 91a96f3b4b. cp -n now fills only the language files a preparation lacks, and pack.mcmeta is copied on its own when missing, so a partly prepared tree no longer skips the whole restore. Passed in the union run with the other u10 PRs.
2026-11-02 20:33:32 +01:00
[email protected]andClaude Sonnet 5.5 4071527bcd Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:32:59 +01:00
Ludovic Marie da1f8461ef Merge pull request #7432 from TheMrZZ/fix/7256-initial-note-instrument
🤖 Agent: queued at 2f32ceb119. Without strict, the state passes through updateFromNeighbourShapes before the named properties apply, and a shape that would become air keeps the parsed state. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests, command_exec and use_item_on parity pass.
2026-11-02 20:32:55 +01:00
Ludovic Marie 7798427129 Merge pull request #7463 from TheMrZZ/fix/forceload-query-order
🤖 Agent: queued at 72817d326e. ForcedChunksSavedData sorts by ChunkPos.pack, so negative z orders like vanilla's reply. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests, command_exec and use_item_on parity pass.
2026-11-02 20:32:50 +01:00
Ludovic Marie 366f772db5 Merge pull request #7408 from k3yh/fix/mod-loot-command-ids
🤖 Agent: queued at a54a0a733d. loot_tables now walks every namespace under data/, prepared files included, so /loot completes a content mod's tables. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests, command_exec and use_item_on parity pass.
2026-11-02 20:32:45 +01:00
Ludovic Marie c12cf40999 Merge pull request #7406 from Akirabane/fix/7115-console-effect-sync
🤖 Agent: queued at c7a4d4bc00. The resync runs once per console command and only while the world is open to LAN. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests, command_exec and use_item_on parity pass.
2026-11-02 20:32:40 +01:00
Ludovic Marie b0980ade8b Merge pull request #7402 from MSedgeMC/fix/ghastling-age
🤖 Agent: queued at f395135ea5. set_age(-24000) now runs the happy ghast's own setup instead of leaving an adult's goals on the ghastling. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests, command_exec and use_item_on parity pass.
2026-11-02 20:32:35 +01:00
Ludovic Marie 53bc0f7664 Merge pull request #7396 from MSedgeMC/fix/sitting-cat-breed
🤖 Agent: queued at 7da932e807. Matches Wolf.canMate: only a wolf partner must stand, a cat sits and still breeds; the test covers both species and both sides. Checked in a union with main: clippy -D warnings, all horizon-client/client-lib/items lib tests, command_exec and use_item_on parity pass.
2026-11-02 20:32:29 +01:00
Florian ERNSTandCodex ef517d04cb Fix first-tick shoot events for summoned projectiles
Co-Authored-By: Codex <[email protected]>
2026-11-02 20:32:15 +01:00
Ludovic MarieandClaude Opus 5.5 103bc04b69 launcher: Home's Mods tab in glass
The mockup's InstanceMods: "N mods in {instance}" and + Find mods over
one ROW per mod, its 36 px icon round at 9, the name and "version ·
source". The launcher's own controls fit in: the filter (the small
FIELD) and Update all in the head, a mod's Update and its on/off Toggle
at the row's end. Rows are still culled to the view for modpacks.

Design: dezer-x (playhorizon.dev/launcher).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:30:32 +01:00
levzzz 106d716cfc Merge pull request #7624 from ErikKalikas/mod-item-container
Native mods: items in an item, Capabilities.Item.ITEM
2026-11-03 00:30:31 +05:00
JustKarkatandJustKarkat's AI agent 775981f9a8 The Music page is an extra, and lists every biome pool's tracks
The Music page becomes a Horizon extra like the others: off by default, a
switch on the Extras page (sound section), musicPage in horizon-extras.txt.
While it is off, Music & Sounds is exactly vanilla's page and no pool is
filtered; turning it on adds the Music... button and applies musicOff.

The page now covers every track the loaded sounds.json can play. After the
seven built-in situations, the tracks only a biome's pool names (the
overworld's deep dark, swamp, badlands and cave pieces, the Nether's) are
listed under Overworld biomes and Nether biomes: vanilla shares them between
several biomes, so no heading claims one. A track several pools share is
listed once, from the first pool that names it, so a row's "play now" always
has a pool that plays it and no track is left out.

That needs rows whose pool is not the group's, so MusicGroup holds
MusicTrack { event, id } and the page's headings are made at run time
(Row::Heading). track_groups is a pure function of the registry, tested with
a registry of base, biome, record and non-music events.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-02 13:30:10 -06:00
[email protected]andClaude Sonnet 5.5 f152b155a6 Native mods: living_incoming_damage_v2 fires after the victim's invulnerability check
An invulnerable or dead mob fired v2 although vanilla's hurtServer returns early
and NeoForge's LivingIncomingDamageEvent comes after that check. Mob::refuses_damage
is the guard damage_with already ran, now shared.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:27:54 +01:00
Ludovic Marie 4ce22f1d7c Merge pull request #7519 from horizon-rust/perf/gen-ring
🤖 Agent: queued. The one risk is the biome cache emptied by the idle hook; released_caches_generate_the_same_chunks shows chunks come out identical after it, the 16 worldgen harnesses pass, and lan_e2e chest/light plus a dedicated server with 2 bots (20 TPS) pass on this head.
2026-11-02 20:27:51 +01:00
[email protected]andClaude Sonnet 5.5 37b8a8bfe2 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:25:59 +01:00
Ludovic Marie a113002100 Merge pull request #7658 from horizon-rust/feat/launcher-glass-home-lists
🤖 Agent: a world's Back up / Copy / Delete still run through worlds_page's world_action (trash, never rm); only the row and its ⋯ menu are new, and Delete needs a second click as the instance menu's.
2026-11-02 20:22:16 +01:00
Ludovic MarieandClaude Opus 5.5 a6f0187207 perf(render): spectator post chains compile in the background
The first frame through a creeper's, spider's or enderman's eyes loaded its
post chain and compiled its pipelines in that frame. With Metal's shader cache
cold (a first play, or shaders that changed) that froze the game for 0.1-0.9 s.
PostEffects::preload now loads the three chains and compiles their pipelines on
a thread of its own from the first frame in a world; apply leaves a chain out
until it has arrived instead of compiling it in the frame. Chain loading only
needs the resources root, so load/translated_pass/standin_pass/image take it
instead of &self.

Spectating with a cold vertex shader, 3 alternated pairs on the Mac mini: worst
frame 67-140 ms -> 5.7-7.8 ms, 0.1% low 89-163 -> 192-277 FPS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:21:47 +01:00
Ludovic MarieandClaude Opus 5.5 43fbc4cd5a launcher: Home's Packs and Screenshots tabs in glass
Packs (the mockup's `Packs`): one ROW per resource or shader pack, its
picture, "Resource pack · on" and a Toggle (40×24, the accent while
on). Open folder and + Add pack… head the list, packs dropped on it are
added, and Remove (clicked twice) shows under the pointer.

Screenshots (the mockup's `Screenshots`): the count, then a grid of
16:9 pictures round at 12, 4 across from 1280 px and 2 below, each
with its file name; the folder button where the mockup has its blur
toggle, and Show all past 8.

Design: dezer-x (playhorizon.dev/launcher).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:21:17 +01:00
Ludovic MarieandClaude Opus 5.5 1ed6cdc8aa launcher: Home's head and the top bar under 1280 px (glass)
The mockup's `xl` breakpoint: under 1280 px wide, Home's invite code goes
under the actions as wide as the page (`xl:flex-row`, `w-full
xl:w-[340px]`), and the top bar's pages give way to the buttons on the
right and scroll sideways (`min-w-0 xl:shrink-0`). Before, the invite
code covered the folder button and the settings button left the window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:21:17 +01:00
MSedgeMC 8f711a7090 Format the creaking portal test
CI runs cargo fmt --check; the assertion fits on one line differently once the
call grew a crate path.
2026-11-02 19:21:16 +00:00
[email protected]andClaude Sonnet 5.5 0d51c75876 Native mods: tooltip_color names NeoForge 26.3's RenderTooltipEvent.Texture
RenderTooltipEvent.Color was replaced by .Texture in NeoForge 1.21.5; the 26.1 and
26.2 javadocs only have Texture. The hook gives the colour half of it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:19:54 +01:00
Ludovic Marie 8965685e5d Merge pull request #7627 from horizon-rust/feat/launcher-glass-home-tabs
🤖 Agent: the tab strip's cut-out corner is painted as three backdrop pieces (strip, body, a 16 px inward fillet baked with a smooth alpha edge); a strip nearly as wide as the panel falls back to one rounded plate, as the mockup's clip-path does.
2026-11-02 20:18:06 +01:00
[email protected]andClaude Sonnet 5.5 0c4d07930b Docs: living_knockback_v2 hands over held items; v1 already fires before resistance
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:17:02 +01:00
gorkandClaude Code 6069cc5424 LOD: broaden far-terrain thin-block support
Co-Authored-By: Claude Code <[email protected]>
2026-11-02 20:14:33 +01:00
gorkandClaude Code cc47db6a9a LOD: keep fences, walls, bars and panes as far terrain
Co-Authored-By: Claude Code <[email protected]>
2026-11-02 20:14:33 +01:00
MSedgeMC 6727ba939e Bind the test world result instead of naming an unused variable
CI denies unused_variables: the check that used it was removed when the needless
return went, so the result is discarded explicitly now.
2026-11-02 19:13:24 +00:00
ClickingMasterandClaude Sonnet 5.5 2635cca1e5 Drowned go-to-water goal only targets water blocks (#7632)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:12:51 -08:00
Ludovic MarieandClaude Opus 5.5 b5aaa3c0bf launcher: Home's head and the top bar under 1280 px (glass)
The mockup's `xl` breakpoint: under 1280 px wide, Home's invite code goes
under the actions as wide as the page (`xl:flex-row`, `w-full
xl:w-[340px]`), and the top bar's pages give way to the buttons on the
right and scroll sideways (`min-w-0 xl:shrink-0`). Before, the invite
code covered the folder button and the settings button left the window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:12:30 +01:00
MSedgeMC 3be36cf20f port_map: the destroyed-item rows name the spill and its spawn 2026-11-02 19:12:29 +00:00
MSedgeMC 6d72fafa8d test: a destroyed shulker box spills what it holds
The same `ItemEntity::hurt` the item tick reads, with a box holding three
stacks in slots 20, 0 and 3: the spill comes back in slot order — oak log 8,
cobblestone 64, diamond 4 — and the box keeps no slot filled. With the
container arm taken out of `hurt`, the spill is empty:
left `[]`, right `[("minecraft:oak_log", 8), ("minecraft:cobblestone", 64),
("minecraft:diamond", 4)]`.
2026-11-02 19:12:29 +00:00
MSedgeMC b32675ae6a fix: a destroyed shulker box spills what it holds
`BlockItem.onDestroyed` reads the item entity's stack, writes
`ItemContainerContents.EMPTY` back into `container` and streams the old
contents — the non-empty slots, in slot order — to
`ItemUtils.onContainerDestroyed`. Every block item's item entity therefore
spills a container it carries; for a shulker box that is the box's contents,
lost where the box burned up.

`ItemEntity::hurt` (the port of the health step `ItemEntity.hurtServer`
runs) now spills both arms the stack's components name: `bundle::destroyed`
for a bundle's `bundle_contents` and `item_data::destroyed_container` for a
container item's `container`, which the item-entity tick adds to the level
where the entity lay.
2026-11-02 19:12:29 +00:00
MSedgeMC 0ba3af1b71 port_map: BundleItem.onDestroyed points at the item death spill 2026-11-02 19:12:29 +00:00
MSedgeMC f8d3912424 test: a bundle's contents leave it when its item entity dies
One test drives `ItemEntity::hurt` — the health step `environment_tick`
runs for every damage source the game applies to a dropped item — and one
the same tick at a lava block through the prepared resources (`.local`,
skipped without them, like the fire tests beside it). With the spill taken
out of `hurt`, the first fails on the empty spill: left `[]`, right
`[("minecraft:stone", 3), ("minecraft:diamond", 1)]`.
2026-11-02 19:12:29 +00:00
MSedgeMC e0f80e3de2 fix: a destroyed bundle spills the stacks it held
`ItemEntity.hurtServer` runs `ItemStack.onDestroyed` before it discards a
dead item, and `BundleItem.onDestroyed` hands its `bundle_contents` to
`ItemUtils.onContainerDestroyed`, which drops each copy where the entity lay.
Horizon took the health down to zero and removed the entity with nothing
inside it going anywhere.

The four damage sites in `ItemEntity::environment_tick` (fire, lava, fire
contact, cactus) now go through `ItemEntity::hurt`, the port of that health
step, which empties the stack and returns what left it; `tick_dropped_items`
adds each stack as a `new ItemEntity(level, x, y, z, stack)`: the destroyed
entity's position, no pickup delay, the constructor's 0.2-less-0.1 nudge.
2026-11-02 19:12:29 +00:00
ClickingMasterandClaude Sonnet 5.5 828f06c0f8 Cite LocalCoordinates.parse
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:11:07 -08:00
ClickingMasterandClaude Sonnet 5.5 3e5058c9f1 Local coordinates: a missing coordinate after a space expects a coordinate, not 'mixed'
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:10:13 -08:00
ClickingMasterandClaude Sonnet 5.5 a3f73dbfd1 Fix split_url for a bracketed IPv6 URL without a port
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:08:55 -08:00
ClickingMasterandClaude Sonnet 5.5 e3d3ac7d44 Count camel husks and zombie nautili as smite targets
#minecraft:zombies includes camel_husk and zombie_nautilus; the mob weapon
smite bonus missed them.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:06:21 -08:00
Ludovic Marie 97937bcf85 Merge pull request #7613 from horizon-rust/feat/launcher-glass-home
🤖 Agent: the Play button's ⋯ menu and the version picker reuse the launcher's existing start, update and version actions; only their look changes (dark appearance). Java instances still show no version picker.
2026-11-02 20:05:20 +01:00
MSedgeMC b1b2929006 port_map: the dye mix rows name the free functions 2026-11-02 19:04:12 +00:00
MSedgeMC 775472a053 test: two dyes mix by the crafting recipe that combines them
A lamb, puppy or kitten born to two dyed parents takes the colour
`DyeColor.getMixedColor` answers, which asks the recipe manager for the pair
first (`findColorMixInRecipes`) and only then picks one of the two.

The test runs the prepared recipe data (`.local/horizon-client/26.3`,
skipped without it, like the data tests beside it) through the same
`dye_mix` the three breed call sites reach: red + yellow makes orange,
blue + green cyan, blue + red purple, green + white lime, and orange + cyan,
which no recipe mixes, keeps one of the two.
2026-11-02 19:04:12 +00:00
MSedgeMC d435ad75cf dye_mix: the two DyeColor lookups are free functions
`DyeColor.getMixedColor` and `findColorMixInRecipes` were methods on `Game`,
so the only way to reach them was to build a whole game. They read the
recipe manager and a coin, so they take those two: `dye_mix(recipes, random,
a, b)` and `dye_color_mix_in_recipes(recipes, a, b)`. The three breed call
sites hand them `self.res.recipes()` and `&mut self.world.random`, as
before; nothing else changes.
2026-11-02 19:04:12 +00:00
ClickingMasterandClaude Sonnet 5.5 8e94e2a694 horizon-server: management PlayerDto ids must be whole UUIDs (#7637)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:04:08 -08:00
Ludovic Marie 7cc36d34c3 Merge pull request #7653 from horizon-rust/perf/limiter-grid
🤖 Agent: only Efficient pacing changes (Precise stays vanilla's limit_display_fps); after a long frame the grid restarts from now, so no frames are rushed out to catch up. Cap 140: 139.3 → 140.0 FPS average.
2026-11-02 20:03:18 +01:00
ClickingMasterandClaude Sonnet 5.5 15e302b719 port-pack net: only dotted 127.x.x.x addresses are local, not names starting with 127.
Fixes #7629

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:02:26 -08:00
MSedgeMC 13b7c339a4 port_map: Salmon.getDefaultDimensions 2026-11-02 19:01:36 +00:00
MSedgeMC a6fa986bb6 test: the box of each salmon variant
Medium is the type's box, small is half of it and large is half again, on
the width, the height and the eye height.
2026-11-02 19:01:36 +00:00
MSedgeMC b6b2702d35 fix: a salmon's box follows its size variant
Salmon.getDefaultDimensions is super.getDefaultDimensions(pose).scale(
getSalmonScale()), and getSalmonScale returns the variant's boundingBoxScale
field: SMALL 0.5, MEDIUM 1.0, LARGE 1.5 (Salmon$Variant static initialiser:
ldc 0.5f, fconst_1, ldc 1.5f). EntityDimensions.scale(float) is scale(f, f),
so the width, the height and the eye height all follow the variant.

Horizon stored the variant - finalizeSpawn picks small 30, medium 50, large
15 - and drew the right model, but Mob::awake_dims_in had no salmon arm, so
a small salmon kept the medium box (hitbox, attack range and collision) at
half its model size, and a large one had a box two thirds of its model.
2026-11-02 19:01:36 +00:00
HrafnaValkyrja 2970bd933a Merge remote-tracking branch 'origin/main' into refactor/entity-fluids-items 2026-11-02 14:01:02 -05:00
razzo715andClaude Opus 5.5 e23b21562b The player who puts out lava sees its smoke (#7537)
LiquidBlock.fizz sends level event 1501 with no excluded player, so the
player whose water turned the lava to obsidian sees the smoke too. The
server left 1501 out for the acting player, whose bucket prediction keeps
only sounds, and the other players heard the hiss twice: from the event
and from the sound the server captured while handling it. 1501 now goes
to every client and the server plays its sound unshared.

lan_e2e's sparks check pours a bucket beside lava for the host and the
guest: smoke on both sides, one hiss each, obsidian.

Fixes #7537

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:00:49 +01:00
Ludovic Marie ed3948f79c Merge pull request #7566 from horizon-rust/feat/launcher-glass-shell
🤖 Agent: dark look only: the glass shell replaces the sidebar, top bar and menus; light keeps today's look. The Sculk card stays in the sidebar above Discord, as before.
2026-11-02 20:00:36 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 f91e66a520 Items and armor stands take fluids and bubble columns from the Entity trait
The item and armor-stand ticks lived in horizon-client, which cannot see the
game crate's Entity trait, so each called the fluid update and the bubble
column push from inside its own tick. Each tick is now split at the push: the
game runs push_by_fluid, the move, push_by_bubble_column, the rest, and
push_by_fluid again. Armor stands also report NoGravity through EntityRules.
TNT's inline column push uses the new push_by_bubble_column_over default.
Part of #6544.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 14:00:15 -05:00
Ludovic MarieandClaude Opus 5.5 0fea17d096 launcher: Home's Worlds and Multiplayer tabs in glass
The mockup's Worlds and Multiplayer lists: one ROW per world or server,
white at 6 % under the pointer. A world's row plays it, its size turning
into "Play ›" under the pointer; its ⋯ menu holds Back up, Copy, Data
packs, Open folder and Delete… (clicked twice). Data packs still drop
on the row. A server's row has Join, and Remove (clicked twice) under
the pointer; Add server opens a line with the glass fields.

Design: dezer-x (playhorizon.dev/launcher).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:00:05 +01:00
JoansitohandClaude Sonnet 5.5 0132e550d0 Show the server's fire on the player's own screen (#7623)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 19:58:46 +01:00
levzzzandClaude Opus 5.5 fa876269f3 Crossbow mobs: draw the attack delay before the loading sound's pitch
Keeps the mob random stream's order from before this branch, so a
pillager's or piglin's attack delays don't change for the same seed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:58:19 +05:00
SenjinandClaude Opus 5.5 db56175051 Shader packs: draw the block outline with the pack's gbuffers_line
As in Iris, the block outline is drawn by the pack's gbuffers_line, else
gbuffers_basic, through the pack's own transform; the game draws its own
only when the pack has neither. Complementary Reimagined r5.9.3 and Super
Duper Vanilla 1.3.8 style the outline in gbuffers_line; SEUS PTGI HRR, which
renders the world smaller into a corner and scales it up, showed the game's
outline floating in the top right. The edges are the game's outline buffer,
widened in screen space after the pack's transform.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:57:28 +01:00
levzzzandClaude Opus 5.5 7786fa8559 Native mods: a loaded ominous item spawner keeps its UUID and attachments
load_ominous_item_spawner never called remember_uuid, so the attachments
saved on a spawner were not read back, and its next save used a new UUID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 23:56:51 +05:00
SenjinandClaude Opus 5.5 2452415050 Shader packs: sqrt never takes a negative in functions
GLSL leaves sqrt of a negative undefined. Packs written against GL drivers
compute things like sqrt(1.0 - y * y) with y that is 1 in exact arithmetic;
the GL compiler's rounding keeps the argument at zero, SPIR-V's fused
arithmetic can leave it a hair below, and the NaN spreads. SEUS PTGI HRR's
GGX sample for perfectly smooth water became NaN for a pattern of pixels:
speckled reflections at shallow angles. sqrt in function bodies now clamps
its argument at zero; global initializers keep the built-in (constants).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:55:33 +01:00
SenjinandClaude Opus 5.5 19fcb04853 Renderer: request FLOAT32_FILTERABLE for shader packs' 32-bit float buffers
GL filters and mipmaps RGBA32F textures; without the feature wgpu samples
them unfiltered and Horizon skips their mipmaps ("mipmaps of unfilterable
buffers are not generated"). Requested only where the adapter has it.
Bliss 2.1.2 keeps colortex5 as RGBA32F.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:55:15 +01:00
JoansitohandClaude Sonnet 5.5 84de7a384a Round entity deltas like Math.round (ties toward +infinity)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 19:55:10 +01:00
SenjinandClaude Opus 5.5 273447e8b2 Shader packs: clouds=off draws no clouds
shaders.properties' clouds=off now hides the game's clouds and drops the
pack's gbuffers_clouds. A pack without a cloud program (Bliss 2.1.2) had the
game's clouds drawn into its buffers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:55:08 +01:00
MSedgeMC 806ec22286 test: prove the wither skeleton ignite without prepared resources
The rule now lives in `ignite_for_wither_skeleton(m, &mut p)`, which
`perform_ranged_attack` calls just before the shot, so its value can be pinned
without a loaded world: a wither skeleton's arrow carries 2000 fire ticks (100
seconds), a plain skeleton's 0, and a Flame arrow already alight for 4000 ticks
keeps its longer burn (`Entity.igniteForTicks` only ever raises the count).

The world-based case stays beside it: it follows `perform_ranged_attack` itself
and only runs where the prepared resources are present.
2026-11-02 18:54:22 +00:00
SenjinandClaude Opus 5.5 9c34b6ddfe Shader packs: a custom texture over a buffer stops standing in once the stage flips it
texture.<stage>.colortexN replaced buffer N in every program of the stage.
As in Iris, it now does so only until an earlier program of the stage flipped
the buffer; later programs read what was written. final continues
composite's. Super Duper Vanilla 1.3.8 (texture.composite.colortex0 = its
clouds), Bliss 2.1.2 (colortex6 = blue noise) and BSL 10.1.8 (colortex7 =
dirt) override buffers they also write.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:54:00 +01:00
MSedgeMC 8338dcf7b1 port_map: BundleItem.onDestroyed points at the item death spill 2026-11-02 18:52:21 +00:00
MSedgeMC 5359e998d6 test: a bundle's contents leave it when its item entity dies
One test drives `ItemEntity::hurt` — the health step `environment_tick`
runs for every damage source the game applies to a dropped item — and one
the same tick at a lava block through the prepared resources (`.local`,
skipped without them, like the fire tests beside it). With the spill taken
out of `hurt`, the first fails on the empty spill: left `[]`, right
`[("minecraft:stone", 3), ("minecraft:diamond", 1)]`.
2026-11-02 18:52:21 +00:00
MSedgeMC 6af75cb8d1 fix: a destroyed bundle spills the stacks it held
`ItemEntity.hurtServer` runs `ItemStack.onDestroyed` before it discards a
dead item, and `BundleItem.onDestroyed` hands its `bundle_contents` to
`ItemUtils.onContainerDestroyed`, which drops each copy where the entity lay.
Horizon took the health down to zero and removed the entity with nothing
inside it going anywhere.

The four damage sites in `ItemEntity::environment_tick` (fire, lava, fire
contact, cactus) now go through `ItemEntity::hurt`, the port of that health
step, which empties the stack and returns what left it; `tick_dropped_items`
adds each stack as a `new ItemEntity(level, x, y, z, stack)`: the destroyed
entity's position, no pickup delay, the constructor's 0.2-less-0.1 nudge.
2026-11-02 18:52:21 +00:00
MSedgeMC 011bb2e53f Test the pick-block item of every block that overrides it
Each block in the table has no item of its own, so the item it picks is the
override; blocks that are their own item are untouched.
2026-11-02 18:50:23 +00:00
MSedgeMC bdd2b9ed6f Pick block: a block with no item of its own picks its harvest
Block.getCloneItemStack is overridden by the blocks that are not their own
item, and Horizon's clone_item knew only the two stem overrides, so pick
block on a crop, a sapling or a vine gave nothing.

CropBlock returns new ItemStack(getBaseSeedId()) (wheat seeds, carrot,
potato, beetroot seeds, torchflower seeds), BambooSaplingBlock bamboo,
SweetBerryBushBlock sweet berries, both cave vine blocks glow berries,
BigDripleafStemBlock the big dripleaf, GrowingPlantBodyBlock the head
block (kelp, twisting and weeping vines), and CandleCakeBlock a cake, which
is what makes the seventeen coloured candle cakes pickable.
2026-11-02 18:50:23 +00:00
gorkandClaude Code 23e78a5cce Memory: don't load mob model geometry on a dedicated server
Dedicated/headless servers carry the full entity-model layer data but never
render it. Parse entity-types.json only for entity facts and skip
entity-models.json (and custom entity models) in that case, sharing one
empty Part for the render-only fields. Also skip CEM model loading on the
server.

Co-Authored-By: Claude Code <[email protected]>
2026-11-02 19:49:18 +01:00
TheMrZZandCodex gpt-6.1-sol d7026acacc Keep manual flat placement available with structures disabled
Retain the flat locator while preserving disabled-world terrain streaming. Supply definitions for explicit placement independently of automatic starts and locate searches. Cover both structure flags with fresh dedicated servers and bounded loaded-coordinate readiness.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 19:49:03 +01:00
SenjinandClaude Opus 5.5 af641a5139 Shader packs: keep the cloud and weather passes on frames with nothing to draw
draw_clouds and draw_weather took their pass before checking the frame had
anything for it, and returned without putting it back. The first frame with
no rain or snow dropped the pack's gbuffers_weather for good: a world loaded
in clear weather showed no rain at all once it started raining (BSL 10.1.8:
nothing drawn). Clouds the same after a frame with no cloud quads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:48:21 +01:00
Florian ERNSTandCodex 7aa99a177b Preserve connected player causes in mob command damage
Co-Authored-By: Codex <[email protected]>
2026-11-02 19:46:09 +01:00
arismoko 26db6dcaf9 Merge upstream main into w3-explosion
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 18:44:59 +00:00
arismoko 8c579c0ef1 Merge upstream main into w1-bow
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 18:44:42 +00:00
Ludovic Marie b53cfcd14e Merge pull request #7625 from itarqos5/fix/lod-saved-open-water-light
🤖 Agent: queued at the maintainer's request. Read the diff: saved LOD columns now light open water like the live path, gated on sky_light so the Nether gains none; the per-span name lookup only runs on a column's top span (light_open_water_where returns early otherwise), so the save decoder's cost barely moves. Waterlogged blocks at the surface aren't counted as water, same as before.
2026-11-02 19:44:35 +01:00
Kaic Salomão 96e4a59b54 chore: add resourcepacks to .gitignore 2026-11-02 15:43:10 -03:00
Kaic Salomão 49b7e096be fix(cem): correct part attach and pivot parity with Fresh Animations 2026-11-02 15:43:10 -03:00
Ludovic MarieandClaude Opus 5.5 2e06039438 perf(client): Efficient pacing keeps frames on a fixed grid of slots
Vanilla's FramerateLimiter starts each frame's slot when the thread wakes up,
so every late wake-up lengthens that frame. Efficient pacing (Horizon's default;
Precise stays vanilla's) now ends each slot at its deadline: the cap holds on
average. A frame that ends past its slot, or wakes up more than half a frame
late, starts the grid again from now, so no frame is rushed out to catch up.

Cap 140 (maxFps 144 loads as vanilla's 140), RD 16, 3 alternated pairs on the
Mac mini: 139.3-139.4 -> 140.0 FPS average (p50 7.18 -> 7.14 ms); lows equal
within noise.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:42:38 +01:00
MSedgeMC 7a221321bc Test both directions of a double-click collect
With 5 stone in slots 0 and 1 of a chest and 63 on the cursor, a button-1
collect takes from slot 1 and a button-0 collect from slot 0.
2026-11-02 18:41:56 +00:00
MSedgeMC 4ec7e2c96d Menus: a double-click collects from the side of its button
AbstractContainerMenu.doClick's PICKUP_ALL arm starts its sweep at slot 0
with a step of +1 when the button is 0, and at the last slot with a step of
-1 when it is 1, over the same two passes (partial stacks first). The screen
only sends PICKUP_ALL from a release of button 1
(AbstractContainerScreen.mouseReleased checks event.button() == 1), so the
right double-click is the one that is meant to fill from the far end.

Horizon's pickup_all did not take the button at all and always swept
0..len, so a collect that could not take everything emptied the first
matching slot instead of the last.
2026-11-02 18:41:56 +00:00
MSedgeMC 078bdb45f4 Test the drags a survival guest may not start
A type-3 and a type-2 drag (start, two slots, end) leave the slots empty and
the cursor full; a charitable drag still splits the stack, and a creative
clone drag still fills the slots and keeps the cursor's stack.
2026-11-02 18:41:47 +00:00
MSedgeMC e550c9d85c Menus: a drag only starts with a quick-craft type the player may use
AbstractContainerMenu.doClick takes the type out of the click's high bits,
asks isValidQuickcraftType before it sets quickcraftStatus to 1, and
resets the drag otherwise: type 2 (clone) needs
Player.hasInfiniteMaterials, and only 0 and 1 need nothing. Its status
guard above that only lets a start through while no drag is open, so a
second start resets the fields instead of taking the drag over.

Horizon started a drag for any type and let a second start replace the
type, so a survival guest's type-2 or type-3 drag placed whole stacks
where vanilla moves nothing.
MSG2
2026-11-02 18:41:47 +00:00
Ludovic Marie 8dbfac396b Merge pull request #7538 from horizon-rust/feat/launcher-glass-tokens
🤖 Agent: tokens only (glass colours, radii, Bricolage/Inter fonts, icons) added to horizon-theme beside the existing ones; no launcher screen changes look until the shell PR #7566 reads them.
2026-11-02 19:41:20 +01:00
[email protected] ece30badde xtask: the dispatch contract test passes the attacker's held items 2026-11-02 19:41:11 +01:00
Ludovic Marie 9cc24a67ab Merge pull request #6295 from some-du6e/feat/graphics-settings-redesign
🤖 Agent: queued at c3aac5716d. verify passes on this head. Every row main added since the branch was cut (block-entity caches, the four Leaf Culling modes, BlueMap, Frame Generation, Translucent Batching hidden on macOS) has a place, and the language test walks every page, so a missing key would fail. Vanilla's Video Settings screen is untouched.
2026-11-02 19:34:51 +01:00
MSedgeMC 2d3dd5df59 port_map: GroundPathNavigation.canUpdatePath has its passenger arm 2026-11-02 18:33:05 +00:00
MSedgeMC b77cbcd6bf test: a riding mob's ground navigation
Standing on the ground, in a liquid, or riding a vehicle: pathing is on. In
the air with no vehicle it is off, and no path is made for the mob.
2026-11-02 18:33:05 +00:00
MSedgeMC 6cf5720eef fix: a mob riding a vehicle keeps its ground navigation
GroundPathNavigation.canUpdatePath is onGround() || isInLiquid() ||
isPassenger() (javap: Mob.onGround ifne 30, Mob.isInLiquid ifne 30,
Mob.isPassenger ifeq 34). Horizon had the first two, so a mob sitting on a
vehicle had can_update_path() false: create_path_raw refused to make any
path for it, recompute_path only set delayed_recompute, and do_tick skipped
follow_the_path. A piglin on a hoglin, a husk on a camel or a chicken jockey
could not path at all while riding.
2026-11-02 18:33:05 +00:00
MSedgeMC ea8c7a339d Take the early return out of the spider seat test
The world-gated pattern the other mob tests use, `let _ = with_test_world(...)`,
has no return to lint: clippy's needless_return is denied in CI and the early
return was the last statement of the test.
2026-11-02 18:32:38 +00:00
ErikKalikasandClaude Opus 5.5 a298731285 Native mods: items in an item, Capabilities.Item.ITEM
native_item_container gives a mod item its slots (1..=256). The contents
are kept in the stack's minecraft:container, as a shulker box item keeps
them; an empty item carries nothing, so it stacks with a new one.
native_item_container_slots reads one item of a stack;
native_item_container_insert tops up equal stacks, then the first empty
slots (SimpleContainer.addItem's order), no slot past its limit, never a
shulker box; native_item_container_extract takes from a slot. Both answer
what moved and the single item it becomes, without changing the stack
given. The hook and helpers are in the typed mod API. Fixes #6768.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:32:00 +02:00
Ludovic MarieandClaude Opus 5.5 057bcfcd1a launcher: Home's tabbed glass panel and Overview
In the dark appearance, Home's tabs become the mockup's TabbedPanel
(horizon-site components/launcher/launcher-views.tsx):

- one glass plate whose top-left corner is the tab strip: the strip round
  at 18 on top, the panel round at 20, joined by a 16 px inward curve. The
  strip is measured as it is laid out; glass.rs bakes the curve as its own
  piece with a smooth alpha edge, one texel per device pixel;
- the tabs with their icons and counts (Tabs size md);
- Overview: the three newest screenshots (the first twice as big),
  See all opening the Screenshots tab, beside the newest launcher
  release's patch log by section, All notes opening Patch notes; then
  what Play still needs (the launcher's rows; the Instance row's actions
  are in the sidebar's ⋮ menu in this look).

The other tabs' bodies are unchanged here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:31:49 +01:00
MSedgeMC e33a2f1fb3 port_map: ThrownEnderpearl.isAllowedToTeleportOwner names owner_can_take_pearl
The row was `missing` with the gap "teleports the owner on a pearl's hit without
asking whether they are dead or sleeping"; the hit now asks.
2026-11-02 18:30:54 +00:00
MSedgeMC 4e525110d7 test: a dead or sleeping owner cannot take its pearl
The vanilla truth table of `isAlive() && !isSleeping()`: alive and awake takes the
pearl, a dead owner, a sleeping owner and a dead one in bed do not.
2026-11-02 18:30:54 +00:00
MSedgeMC 77c6a82155 fix: a pearl whose owner is dead or asleep is discarded
ThrownEnderpearl.onHit discards the pearl when `getOwner() == null ||
!isAllowedToTeleportOwner(owner, level)`: javap shows `getOwner; ifnull 116;
invokestatic isAllowedToTeleportOwner; ifne 121; discard; return`. That helper, in
the pearl's own dimension, reaches `LivingEntity.isAlive() && !isSleeping()`
(bytecode 26-45) and returns false otherwise, so a dead or sleeping owner is never
teleported and takes no damage.

`Game::projectile_hit`'s EnderPearl arm teleported whenever `p.owner ==
Some(Who::Player)` and a player existed: with `ender_pearls_vanish_on_death` off a
dead player, and any player in bed, was moved onto the pearl and took the 5
`minecraft:ender_pearl` damage.

`horizon_client::projectiles::owner_can_take_pearl` is the same-dimension half of
the vanilla rule, read by the pearl's hit.
2026-11-02 18:30:54 +00:00
MSedgeMC f9be49f6bd port_map: WitherSkeleton.getArrow names perform_ranged_attack
The row was `missing` with the gap "an arrow shot by a wither skeleton is not set
on fire for 100 seconds"; the arrow is now ignited before the shot.
2026-11-02 18:30:46 +00:00
MSedgeMC 3925ca9d22 test: only a wither skeleton's arrow comes out burning
`perform_ranged_attack` against a player four blocks away: the wither skeleton's
arrow carries 2000 fire ticks, a plain skeleton's 0. Before the change both were
0, and `on_fire()` false for the wither skeleton's arrow.
2026-11-02 18:30:46 +00:00
MSedgeMC a9f53fc757 feat: a wither skeleton's arrow leaves the bow burning
WitherSkeleton.getArrow(ItemStack, float, ItemStack) is `super.getArrow(...)` then
`AbstractArrow.igniteForSeconds(100.0f)` on the arrow it returns: javap shows the
`invokespecial AbstractSkeleton.getArrow`, then `ldc #142 // float 100.0f` and
`invokevirtual AbstractArrow.igniteForSeconds`.

`mobs/undead.rs::perform_ranged_attack` built every skeleton's arrow the same way
- the species' tipped effect (stray, bogged, parched) and the bow's Flame bonus -
and never ignited a wither skeleton's, so its arrows flew cold and did not set
alight what they hit (Entity.igniteForSeconds(100) is 2000 fire ticks).
2026-11-02 18:30:46 +00:00
Ludovic Marie 26c83240d6 Merge pull request #7405 from dontstopgorkingit/perf/rasterize-sunrise-fan
🤖 Agent: queued at f96c062b3a. Checked on main merged in: clippy -D warnings, the sunrise lib tests and the sunrise_shader GPU test pass. The fan is drawn only while the sunrise colour is visible, so the sky pass drops its per-pixel test the rest of the day.
2026-11-02 19:30:36 +01:00
[email protected] 964c882608 Merge upstream/main 2026-11-02 19:30:29 +01:00
[email protected] 9441881bd8 Native mods: living_knockback_v2 hands over the attacker's held items; clippy allow for the macro-free armor stand test 2026-11-02 19:30:28 +01:00
MSedgeMC 8871870a61 port_map: PathNavigation.trimPath and Node.cloneAndMove are ported 2026-11-02 18:27:41 +00:00
MSedgeMC 46faadbaec test: the cauldron raise of a path
A node on a cauldron is raised one block; the node after it follows when it
is not above the cauldron and keeps its height when it is; a path with no
cauldron under it is not touched.
2026-11-02 18:27:41 +00:00
MSedgeMC 5dbd0e8088 fix: a path node on a cauldron moves onto its rim
PathNavigation.trimPath walks the new path and, for every node whose block
is in BlockTags.CAULDRONS, replaces it with the same node one block up
(Node.cloneAndMove). When the node after it is not above it (node.y >=
next.y) that node is moved to the rim's height as well. PathNavigation.moveTo
runs trimPath before it checks the node count.

Horizon only had the avoid_sun half of the trim, so a route to a cauldron
- a leatherworker's job site, a cauldron next to a farm - ended one block
inside the block: the mob walked into its side and stopped instead of
standing on the rim. The raise now runs for every navigation kind, from
nav_move_to_path, before the sun trim.
2026-11-02 18:27:41 +00:00
MSedgeMC dc4073266e port_map: PolarBear.getDefaultDimensions is ported 2026-11-02 18:27:30 +00:00
MSedgeMC 4cab74eb25 test: the box of a rearing polar bear
Three ticks of rearing is half the animation, so half again as tall; six
ticks is twice. The flag alone moves nothing: the box follows the
animation, which is what vanilla's getDefaultDimensions reads.
2026-11-02 18:27:30 +00:00
MSedgeMC 430b86d7d7 fix: a rearing polar bear's box grows with its stand animation
PolarBear.getDefaultDimensions scales the box of a bear that is not a baby
by (1, 1 + clientSideStandAnimation / 6): the width stays, the height and
the eye height grow to twice at a full rear-up, and the box is refreshed
whenever the animation changes (PolarBear.tick). Horizon kept the type's
box for a rearing bear, so a bear standing up was clickable and hittable
only up to its resting height.

The animation lives in anim.v[STAND] on both sides in Horizon, where
vanilla only advances it on the client, so this uses it as-is.
2026-11-02 18:27:30 +00:00
Ludovic MarieandClaude Opus 5.5 faa200b663 command_exec: let the trial spawner's last pair finish dying
Each passenger case killed its zombie and chicken and set the next
spawner at once. A dying mob stays 20 ticks and still obstructs a spawn
(Mob.checkSpawnObstruction), so vanilla spawned nothing in cases 1-3
and the harness reported 12 mismatches on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:27:09 +01:00
Ludovic MarieandClaude Opus 5.5 27d3d3ffdb Saved max_health bases stay attribute bases
/attribute ... max_health base get now reads finalize's base, which
loading recorded only for horses: a summoned cow with a saved base of
40 answered its type's 10 (command_exec's attr case).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:27:08 +01:00
MSedgeMC 48f2326314 Spell the spider seat test's floats as f32
0.4 and 2.0 as arguments to a f32 parameter fall back to f32 with a
future-incompatible warning; the literals say f32 themselves.
2026-11-02 18:26:45 +00:00
MSedgeMC 86f6464cc4 test: pin the beard kernel table to vanilla's values
`crates/horizon-core/src/density/beardifier.rs::kernel` fills the 13824-entry
table `Beardifier.BEARD_KERNEL` holds. Nothing checked it: the table is inside
the crate, 24³ floats, and a wrong exponent, offset or index order in it would
only show up as subtly wrong terrain under every `beard_thin`/`beard_box`
structure. This adds the check, with the values read off vanilla.

## Vanilla

`Beardifier`'s static initialiser:

```java
private static final float[] BEARD_KERNEL = Util.make(new float[13824], kernel -> {
   for (int zi = 0; zi < 24; zi++)
      for (int xi = 0; xi < 24; xi++)
         for (int yi = 0; yi < 24; yi++)
            kernel[zi * 24 * 24 + xi * 24 + yi] = (float)computeBeardContribution(xi - 12, yi - 12, zi - 12);
});

private static double computeBeardContribution(final int dx, final int dy, final int dz) {
   return computeBeardContribution(dx, dy + 0.5, dz);
}

private static double computeBeardContribution(final int dx, final double dy, final int dz) {
   double distanceSqr = Mth.lengthSquared(dx, dy, dz);
   return Math.pow(Math.E, -distanceSqr / 16.0);
}
```

So entry `zi * 576 + xi * 24 + yi` is `e^(-(dx² + (dy + 0.5)² + dz²) / 16)` as a
`float`, with the half-block lift on `y` only.

## What Horizon had

`kernel()` builds the same array with `std::array::from_fn`, mapping the index
back to `(x, y, z) = ((i / 24) % 24, i % 24, i / 576)` and adding the same 0.5
to `y` before `jmath::pow(E, -squared / 16.)`. Written that way it is right —
and it is, value for value — but nothing said so, and `jmath::pow`'s promise of
HotSpot-exact `Math.pow` was the only thing holding the numbers up.

## Proven

`cargo test -q -p horizon-core --lib the_beard_kernel`:

- every one of the 13824 entries has the bits vanilla's formula gives it: the
  test hashes the whole table (`float` bits, 31-multiplier) and gets
  `b44d13f8c3d98c65` from both sides, and pins seven entries by index —
  `kernel[0] = 3.9172745e-12`, `kernel[7212] = 0.9844964` (the centre, where
  `dy` is 0.5), `kernel[13823] = 6.9435353e-11` among them.
- the centre is asserted to be the table's maximum, so an index order that
  transposes `x` and `z` (they are symmetric in the formula, but `y` is not)
  cannot pass by accident.

Failing without the fix: move the falloff from 16 to 12 — the one constant a
reader is most likely to mistype — and the test stops on
`kernel[0] is 6.175119e-13 not 3.9172745e-12` (bits `640810070` vs `747230124`);
the table hash moves too. Nothing else in the suite notices.

## How the values were read

The class cannot be run here: `client-26.3.jar` holds class files of version 69
and the only JDK on this machine is 21, so `Class.forName("…Beardifier")` ends
in `UnsupportedClassVersionError` and the private field cannot be reflected out.
The values therefore come from the initialiser's own formula, transcribed
(`computeBeardContribution` above, including the `+ 0.5` in the three-argument
overload), compiled and run under java 21, printing `Float.floatToIntBits` of
each entry; the Rust side was compared against those bits entry by entry and
agrees on all 13824.

## Not proven

- Only the table. `getBeardContribution`'s `fastInvSqrt`, the `0.8`/`0.4`
  weights, `getBuryContribution`'s radius of 6 and the rigid/junction loops are
  untouched here and were verified elsewhere.
- No behaviour changes and no `port_map` row changes: the map already carries
  `Beardifier.computeBeardContribution  inline  …beardifier.rs::kernel  fills
  the beard kernel table`, which this makes true rather than aspirational.
2026-11-02 18:25:04 +00:00
MSedgeMC 8918c8615e rerun 2026-11-02 18:25:04 +00:00
itarqos5andClaude Opus 5 b4629e064f fix(lod): light saved far columns' open water as open sky
A saved chunk's LOD columns kept each water block's own skylight, which
fades a level per block of water, while the generator's far columns light
open water as open sky. Where the two met - the stride limit in
`SavedLodSampler::grid` - the difference drew a dark band through the sea.

`Column::light_open_water` already existed for this and the live path
(`Column::from_chunk`) applies it; `decode_lod_surfaces` never did. It has
no `BlockFacts` and so cannot reach `state_kinds`, so the rule moves into
`Column::light_open_water_where`, taking the caller's own test for water;
the decoder names water from the block registry, which leaves lava out as
the emissive test did.

Only where there is a sky: the decoder's `sky_light` gates it, so the
Nether's water gains no skylight it never had.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-11-02 18:24:51 +00:00
Ludovic Marie 0041483e91 Merge pull request #7615 from horizon-rust/tools/pacing-vsync
🤖 Agent: tools only; --vsync raises if perf_bench stops writing enableVsync:false, so it can't silently bench without VSync.
2026-11-02 19:23:58 +01:00
[email protected] 81e9020fa9 Mod particles: spawn velocity, random velocity range and sprite animation modes 2026-11-02 19:23:42 +01:00
Ludovic Marie 1411020cbb Merge pull request #7489 from horizon-rust/feat/launcher-scene
🤖 Agent: the scene is the website's HorizonScene ported draw for draw (same seeds and random order), pinned by a golden test with values from the site's own code; dark appearance only, light keeps the dawn sky, idle CPU below the old sky (0.66 s vs 0.78 s per 20 s).
2026-11-02 19:22:00 +01:00
MSedgeMC fc147cc62d Drop a needless return in the leash test
The early return at the end of the test body is the last statement, so clippy's
needless_return (denied in CI) rejects it.
2026-11-02 18:21:44 +00:00
Ludovic MarieandClaude Opus 5.5 9094d94928 tools(pacing_bench): --vsync for frames paced by the display
The bench runs with VSync off. --vsync writes enableVsync:true, in a window
(--size): with vanilla's default 120 FPS cap on a 60 Hz display, frames wait
for the refresh as most players' do, and the swapchain's acquire waits show in
the slow frames' spans (r_acquire).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:19:15 +01:00
masa-masa-0314andCodex 9cb556525e World creation: generate the selected single biome
Load the saved dimension generator beneath datapack dimension overrides instead of reconstructing it only from the preset. Preserve saved noise settings and dimension type, including after reload.

Fixes #7501

Co-Authored-By: Codex <[email protected]>
2026-11-03 02:18:18 +08:00
[email protected] ed0f651c6a Native mods: no macro in the hook points (hook_points_stay_hookable) 2026-11-02 19:17:44 +01:00
MSedgeMC 98524f1a14 test: ask the shared mob rule, not the creaking helper
`mob_can_use_portal` now splits in two: `mobs::mob_can_use_portal` is the part
the mob's own state decides (alive, not a boss that never travels,
`Creaking.canUsePortal`), and the game's version adds the rides and the
players this side carries. The test asks the shared rule, so deleting the
heart clause fails it with "a creaking bound to its heart must not leave it
behind" instead of only removing a helper nothing tested.
2026-11-02 18:16:19 +00:00
Ludovic MarieandClaude Opus 5.5 1b7a297ac4 launcher: the redesign's Home head (glass look)
In the dark appearance, Home's banner becomes the mockup's HomeView head
(horizon-site components/launcher/launcher-views.tsx):

- the edition tabs (the instance's own lit; the other can't be picked, as
  an instance's edition is chosen when it is made), the instance's icon and
  name in Bricolage at 64 px, and how long it was played or has run now;
- Play (Update while the launcher's version has a newer one, Stop while the
  game runs, the progress while preparing, updating or starting) with its
  ⋯ menu: Prepare (again), Play without updating, the desktop shortcut,
  Copy the launch command;
- the version picker with its ticked list and Manage versions, the Mods big
  icon (opens the instance's Mods tab), the folder button whose ⋯ shows
  under the pointer (instance folder, screenshots, Minecraft folder);
- the invite code field with Join inside, 340 wide on the right.

The playing-now and official SMP counts stay after the played line. The
glass scrollbar lies over the page margin instead of taking a gutter. The
light appearance keeps the old banner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:16:15 +01:00
MSedgeMC bc9b4e85be Test where a spider sits on a narrow vehicle
A spider on a 0.4-wide chicken hangs 0.3125 below the seat and a cave spider
0.21875, where the code put both at 0; a 2.0-wide vehicle keeps the default,
and a cave spider with minecraft:scale 2 hangs 0.4375.
2026-11-02 18:13:06 +00:00
MSedgeMC 30f3de004f A spider sits on a narrow vehicle by its own rule
Spider.getVehicleAttachmentPoint returns 0.3125 of the scale, and
CaveSpider's 0.21875, when the vehicle's box is no wider than the rider's;
a wider vehicle keeps the type default. vehicle_attachment took only the
rider, so no rule could read the vehicle's box and both spiders sat at the
default 0.

It now takes the vehicle's getBbWidth, and the four callers pass it: a mob's
width, a boat's 1.375, a minecart's and primed TNT's 0.98, a text display's
0.
2026-11-02 18:13:06 +00:00
Florian ERNSTandCodex bebdb71d76 Preserve signed InLove values in animal NBT
Co-Authored-By: Codex <[email protected]>
2026-11-02 19:11:30 +01:00
MSedgeMC c69a290bc3 port_map: handleRecipeBookSeenRecipePacket is ported
The row said RECIPE_BOOK_SEEN_RECIPE had no handler; it now names the hub's
dispatch and the lookup it makes.
2026-11-02 18:10:50 +00:00
MSedgeMC 723afbaaba test: a display id names the recipe it was numbered for
Two fixture recipes get display 0 and 1 in id order, and the id past the
last display names nothing - the lookup the seen-recipe handler makes.
2026-11-02 18:10:50 +00:00
MSedgeMC 1f1e308e0a feat: a guest's seen recipe clears its highlight
ServerGamePacketListenerImpl.handleRecipeBookSeenRecipePacket asks the
recipe manager for the display's recipe (getRecipeFromDisplay) and hands it
to ServerRecipeBook.removeHighlight, which drops it from toBeDisplayed: the
recipe stops being announced as new. The hub had no arm for the packet, so
a guest's book kept highlighting every recipe it had already looked at.

ServerAdvancements::recipe_of_display is the manager's lookup over the
displays the server numbered.
2026-11-02 18:10:50 +00:00
MSedgeMC 00d0bf86aa test: the seen recipe is a two-byte display id
Opcode 48 with AC 02 reads as display 300 and writes back the same bytes, a
list cut short is refused, and an id without a variant still reads as
Unknown - which is what opcode 48 did before it had one.
2026-11-02 18:10:50 +00:00
MSedgeMC db562ee212 feat: read ServerboundRecipeBookSeenRecipePacket
The serverbound play enum had no variant for opcode 48, so a guest's
recipe-book highlight could never be cleared: the packet decoded as
Unknown and the hub's dispatch has no arm for that.

The packet is one VarInt, the RecipeDisplayId the player looked at.
2026-11-02 18:10:50 +00:00
MSedgeMC 78f41319e0 test: prove the wolf reset runs on the death path
The first version called `tame::die` directly, so it still passed with the
call site taken out. `drops::die` now asks `drops::death_state` first — the
state half of `LivingEntity.die`, before any loot — and the test calls that,
so deleting the wolf clause fails it with "a dead wolf is not wet".
2026-11-02 18:08:22 +00:00
MSedgeMC 49bb317931 test: the depths and reaches vanilla refuses on a jigsaw structure
A depth of 21, 200 or -1, and a max_distance_from_center of 0, 129, -8, a
horizontal of 0 or a vertical of 0 or 4065: each decoded as a placing jigsaw
before and places nothing now, while 0..=20 and the in-range reaches still do.
2026-11-02 18:08:18 +00:00
MSedgeMC dba1612baf fix: a jigsaw structure's size and reach stay inside vanilla's bounds
JigsawStructure's codec reads `size` with Codec.intRange(0, 20) and
`max_distance_from_center` with horizontal Codec.intRange(1, 128) beside
vertical ExtraCodecs.intRange(1, DimensionType.Y_SIZE). Horizon read both
without a bound, so a structure carrying a depth of 200 or a reach of 0 laid
out pieces vanilla would never have registered; it now decodes as
unsupported, the way vanilla leaves such a structure out of the registry,
instead of failing the whole registry.
2026-11-02 18:08:18 +00:00
JoansitohandClaude Sonnet 5.5 52b98391e4 Fix wither head step test target
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 19:04:10 +01:00
JoansitohandClaude Sonnet 5.5 2a2efd0675 Format wither test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 19:04:10 +01:00
JoansitohandClaude Sonnet 5.5 390585920c Fix wither side heads snapping: WitherBoss.rotlerp does not wrap its result
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 19:04:10 +01:00
[email protected] f7ec51cff3 Format fireworks.rs 2026-11-02 19:00:21 +01:00
Florian ERNSTandCodex 16d3c1dfa5 Use villager professions in command display names
Co-Authored-By: Codex <[email protected]>
2026-11-02 19:00:10 +01:00
Ludovic Marie 97dc1b553b Merge pull request #7600 from horizon-rust/fix/limiter-park-steps
🤖 Agent: only SystemClock::park changes (1 ms sleep steps); the FramerateLimiter logic stays vanilla's. At a 60 FPS cap on macOS, p99 19.8 → 16.75 ms and 1% low 50 → 59.5 FPS over 3 alternated pairs.
2026-11-02 18:59:55 +01:00
Ludovic Marie 9adaec4fcb Merge pull request #7599 from horizon-rust/tools/pacing-max-fps
🤖 Agent: tools only; --max-fps raises if perf_bench stops writing maxFps:260 rather than silently benching uncapped.
2026-11-02 18:58:47 +01:00
TheMrZZandCodex gpt-6.1-sol 0a9a77d387 Apply loot damage modifiers on the correct random stream
Apply set_damage in the items loot engine and honor an inline table’s optional random sequence. Cover float clamping, additive modifiers, ineligible items, stream tails and the unsequenced fallback through command execution.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 18:55:34 +01:00
LokiandAntigravity 7c6f2f7599 game: prevent falling blocks from descending through scaffolding (fixes #7553)
Co-Authored-By: Antigravity <[email protected]>
2026-11-03 02:55:19 +09:00
Ludovic MarieandClaude Opus 5.5 39872d4b4b tools(pacing_bench): --max-fps for a player's frame cap
The bench runs uncapped (maxFps 260), so the frame limiter never paces a
frame. --max-fps N writes maxFps:N: at 60 it showed every frame at 17-20 ms on
macOS (the limiter's sleeps running late), fixed in fix/limiter-park-steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:55:15 +01:00
MSedgeMC db0baa0a91 port_map: Wolf.die is tame::die
The row said missing and that nothing resets the wet and shake fields on
death; it now points at the reset and the `drops::die` call site.
2026-11-02 17:55:13 +00:00
MSedgeMC b904bbccaa test: a wolf that dies mid-shake is dry and still
Entity event 8 starts the shake with the wolf soaked; after the death the wet
flag, the counter and both shake animations are zero.
2026-11-02 17:55:13 +00:00
MSedgeMC eb1e04d086 fix: a wolf that dies mid-shake stops shaking
`Wolf.die` clears `isWet`, `isShaking`, `shakeAnim` and `shakeAnimO` before
`super.die`, so a wolf killed while shaking the water off does not keep the
wet flag or the shake on its body. Horizon kept both (`extra.flag2`, the
shake counter and `anim.v/vo[SHAKE]`), so the corpse shook on the ground.

`mobs/tame.rs::die` is that reset; `mobs/drops.rs::die`, the ported
`LivingEntity.die`, asks it before any loot is rolled.

Refs tools/port_map/game2-001.tsv:34 (Wolf.die, missing).
2026-11-02 17:55:13 +00:00
MSedgeMC 8a26f90c66 Test the sulfur cube's lead and its rope height
A cube with a block in its body can be leashed (hasBodyItem) and a rope hangs
at half its 0.49 box, 0.245, on its centre; the default put it at the eye
height 0.175 and 0.196 in front. Without a block the cube takes no lead.
2026-11-02 17:54:59 +00:00
MSedgeMC 9f0217377e A sulfur cube takes a lead while it holds a block
SulfurCube.canBeLeashed is hasBodyItem(), not the Enemy rule Mob.canBeLeashed
applies, so a cube with a swallowed block takes a lead. Horizon's
can_be_leashed falls through to !hostile() and refused every cube, whose
category is a monster's.

SulfurCube.getLeashOffset is (0, getBbHeight() / 2, 0): the rope hangs at
half the cube's box on its centre, not the eye-height default with
bbWidth / 2 in front.
2026-11-02 17:54:59 +00:00
MSedgeMC 611bbed5a5 port_map: StrawBedBlock.getSleepHeight is ported
The row said the height was a flat 0.5625; it now names Game::sleep_height.
2026-11-02 17:54:43 +00:00
MSedgeMC db799490ba test: the shape's top, the straw bed's foot and setPosToBed's 0.125
Pins getSleepHeight's decision table (a straw bed's head goes to its foot,
a foot that is gone or is another block has no height, every other bed is
its own shape), the top of a multi-box shape, and the position: a 7/16 foot
at y 70 puts the sleeper at 70.5625 where the constant gave 70.6875.
2026-11-02 17:54:43 +00:00
MSedgeMC e43b10c356 fix: the sleeper's position uses that height
try_sleep and tick_sleep both built the position from the constant; they
ask the bed now, and try_sleep stops before lying down when the bed has no
shape to lie on.
2026-11-02 17:54:43 +00:00
MSedgeMC a1f83a7bda fix: a bed's sleeping height comes from the shape it is slept on
AbstractBedBlock.getSleepHeight answers with the top of the block's shape
(VoxelShape.max(Axis.Y), empty for a shape with nothing in it) and
StrawBedBlock.getSleepHeight walks a head over to its foot first, so a
straw bed is slept on at the foot's shape and a head whose foot is gone has
no height at all - LivingEntity.startSleeping then returns false (bytecode
34-55) and the player stays up. LivingEntity.setPosToBed puts them 0.125
above that height (0.125d at 0, pos.getY() + height + 0.125).

Horizon added a flat 0.5625, the height of an ordinary bed's head, at both
places a sleeper is placed (try_sleep and tick_sleep).
2026-11-02 17:54:43 +00:00
MSedgeMC 0186ddecf0 test: the processor fields vanilla refuses out of range
A capping limit of 0, a uniform limit from 0, a biased limit from -2, a
weighted list whose only entry is 0, and a block_rot integrity of -1, 1.01,
2 or a string: each loaded before, each is a decode error now.
2026-11-02 17:54:35 +00:00
MSedgeMC f17bec8499 fix: a capping limit and a block_rot integrity are bounded at decode
CappedProcessor's limit is IntProviders.POSITIVE_CODEC and
BlockRotProcessor's integrity is Codec.floatRange(0, 1): vanilla refuses a
processor list whose limit can draw 0 or less, or whose integrity is outside
the fraction, and the pack fails to load. Horizon decoded both without a
bound, so such a list loaded and ran as a capping processor that never kept
a block or a block_rot that kept or rotted all of them.
2026-11-02 17:54:35 +00:00
MSedgeMC 8012b13f09 port_map: stopUsingItem is a port, not an inline of the tick
The row still described the state clear inside `Game::tick_used_item`. The
use's end is `Game::stop_using_item` now, and it also carries the
`ITEM_INTERACT_FINISH` vibration `ItemStack.causeUseVibration` sends.
2026-11-02 17:54:21 +00:00
MSedgeMC 32eb93988b fix: finishing an item use reports the finish vibration too
`LivingEntity.completeUsingItem` finishes the stack and then calls
`stopUsingItem`, the same tail `releaseUsingItem` has, so a use that runs its
full course vibrates exactly like a released one. `Game::complete_using_item`
cleared `using` and returned without the event, which left every finished eat,
drink and spyglass use silent while vanilla's sculk sensors heard them.

The finish path now ends with `Game::use_vibration`, after the stack's finish
(the client's use-complete packet and `LivingEntityUseItemEvent.Finish`), as
vanilla orders it.
2026-11-02 17:54:21 +00:00
MSedgeMC f191b093c5 fix: releasing an item use reports the finish vibration
`LivingEntity.releaseUsingItem` ends with `stopUsingItem`, and
`LivingEntity.stopUsingItem` does two things Horizon's release path skipped:
it drops the use state and, when the use is still on, calls
`ItemStack.causeUseVibration(this, GameEvent.ITEM_INTERACT_FINISH)`, which is
`Entity.gameEvent(event)` — a game event from the player at its own position.

`Game::release_used_item` (the port of `releaseUsingItem`, reached by
RELEASE_USE_ITEM, a slot change mid-use and the use running out) ran the item's
own release and left `using` to its caller, so a bow let go, a lowered shield,
an aborted spyglass or a finished eat never made the vibration a sculk sensor
hears (it is the 3-frequency `item_interact_finish` event).

The release now ends through `Game::stop_using_item`, and `Game::use_vibration`
is `ItemStack.causeUseVibration`: it sends the event only for a stack whose
`use_effects.interact_vibrations` is set (the default, which is what
`horizon_items::items::ItemInfo::interact_vibrations` carries).
2026-11-02 17:54:21 +00:00
Ludovic MarieandClaude Opus 5.5 0bd491e762 fix(client): a frame cap holds on macOS: the limiter sleeps in 1 ms steps
macOS runs a sleep late by a share of its length (timer coalescing: 1.8-3.5 ms
on the ~14 ms sleep of a 60 FPS frame), more than the limiter's learned
overshoot (capped at 2 ms, vanilla's) covers, so a 60 FPS cap gave frames of
17-20 ms (57 FPS on average, 1% low 50). SystemClock::park now sleeps at most
1 ms at a time; the limiter's logic is vanilla's, unchanged.

In game, 60 FPS cap, RD 16, 3 alternated pairs on the Mac mini: p99 19.6-19.9
-> 16.75 ms, 1% low 50.1-50.6 -> 58.4-59.6 FPS, average 56.8-57.7 -> 59.9 FPS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:54:19 +01:00
HrafnaValkyrja 9812ee0930 Merge remote-tracking branch 'origin/main' into fix/cart-portal-player 2026-11-02 12:53:27 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 72e778a5eb A minecart a player rides keeps its motion through a Nether portal (#6021)
The rider's cart crossed but arrived at rest, so a cart that went in at
rail speed stood still in the Nether. It now arrives with the motion it
had when its portal was handled (clamped to 0.4 along x and z, as
OldMinecartBehavior.getKnownMovement), is planned from where it stood
then, and a ridden vehicle's portal is looked for after its rider's tick
has moved it, so it is found the tick it enters the portal.

New: tools/cart_portal_player_e2e.py (vanilla and Horizon dedicated
servers, a protocol client riding a cart through a portal), a ride mode
in the Java protocol client, lan_e2e check cart_portal_rider, and a unit
test.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 12:53:11 -05:00
yosifdheef a870067384 Fix baby turtle feet z-fighting
Baby turtle feet have two coincident polygons with opposite winding: one
for the top skin, one for the sole. Both keep their own UVs, but the
shared depth made them z-fight.

Detect coincident, oppositely wound faces once while parsing a model and
mark them `cull_backface`. The CPU renderer then packs the normal's w as
`SHADED_CULL_W` (-126, byte 0x82), so the faces keep their directional
shading and only gain back-face culling. The GPU skinning path carries the
flag in the high bit of the model vertex's normal axis word and writes the
same -126 byte in `entity_skin.wgsl`, keeping the CPU and GPU outputs
byte-identical (the existing exact `bytemuck::bytes_of` comparison).
2026-11-02 20:52:19 +03:00
MSedgeMC dfb56a3707 port_map: game-013's rabbit attack sound is played
The row said only the killer bunny plays it and that Horizon has no killer bunny
behaviour; the bunny is there and now bites with its sound.
2026-11-02 17:52:19 +00:00
MSedgeMC 280a35a7b6 test: the killer bunny's bite sound, its two draws and its pitch
A copy of the mob's random advanced by vanilla's two `nextFloat` draws gives the
pitch to expect; a baby killer bunny keeps the plain 1.0 base; a silent one still
draws twice and says nothing; any other rabbit draws nothing and says nothing.
2026-11-02 17:52:19 +00:00
MSedgeMC 09d1aaf744 fix: a killer bunny's bite sounds
`Mob.doHurtTarget` ends with `playAttackSound()` once `hurtServer` returned true
(bytecode offsets 60-123: `ifeq 126` skips the call when the hit did not land).
`Rabbit.playAttackSound` plays `SoundEvents.RABBIT_ATTACK` ("entity.rabbit.attack")
at volume 1.0 and pitch `(random.nextFloat() - random.nextFloat()) * 0.2F + 1.0F`
only when `getVariant() == Rabbit.Variant.EVIL`.

Horizon's `do_hurt_target` ends with the same hook and has the creaking's, the
panda's and the axolotl's arms there, but nothing for the rabbit: a killer bunny
bit in silence.

The pitch is an argument of `playSound`, so vanilla draws its two floats before
any silence check: a silent killer bunny still pays them. The pitch base is the
plain 1.0 of the override, not the 1.5 a baby's `getVoicePitch` gives.
2026-11-02 17:52:16 +00:00
[email protected] 70f737f5b8 Merge upstream/main 2026-11-02 18:49:10 +01:00
[email protected] e7c690c897 Native mods: item model property event type, docs and xtask fixtures follow the generated trait 2026-11-02 18:49:10 +01:00
MSedgeMC b17870dbf5 port_map: the suggestion provider's budget, newest request and 1000 cap
Three rows in `rest-017.tsv` describe the state of the code before
`ServerCommandSuggestionsProvider` landed in `lan_actions.rs`:

* `tick` is not "no per-tick budget": `guest_try_suggestions` spends a budget
  that the per-guest server tick refills (`play.suggestion_budget = true`
  next to the chat and command throttler decay, then one answer);
* `limitSuggestionCount` is no longer missing: `guest_suggestions` cuts the
  list to its first 1000 suggestions and keeps the range;
* `request` coalesces as `lastRequest` does: the newest command text replaces
  the one not answered yet (`play.suggestion = Some(payload)`).
2026-11-02 17:47:46 +00:00
CocofireHD caa34071cd memory: a RAM limit that holds the process, not one that pages
HORIZON_MEMORY_LIMIT_MB caps the working set, and Windows meets it by moving pages out: at 512 MB the game still committed 2.2 GB here. HORIZON_MEMORY_HARD_MB (and /ramlimit hard) is a budget of the process's commit instead. The governor sheds far terrain and render distance against it, the allocator refuses what would pass it, and a game that cannot fit ends with a crash report or a message naming the limit rather than living in the page file.
2026-11-02 18:47:40 +01:00
MSedgeMC 0837ca900e test: pin a snowball's 160 fire ticks and the water that ends them
Nothing could check the fire a thrown projectile carries. The test puts a
snowball in a fire block (the cell answers `ignites`), then out of it: 160
ticks at the ignite, 159 after the tick's own countdown, one fewer each tick
in open air, 0 when the fire runs out — and 0 at once when the box is in
water. A small fireball sits in the same block and stays cold, because an
AbstractHurtingProjectile does not apply the blocks it flies through.
2026-11-02 17:47:13 +00:00
MSedgeMC 4855d0c2c8 fix: a thrown snowball catches fire in a fire block
ThrowableProjectile.tick calls Entity.applyEffectsFromBlocks, so every thrown
projectile (a snowball, an egg, a pearl, a potion, a llama's spit, a mod's own
thrown item) is ignited by BaseFireBlock.entityInside for 160 ticks, has that
fire counted down by Entity.baseTick, and is put out by water; the entity then
answers displayFireAnimation and its renderer draws the flame over the box.

The port's throwable Projectile kept no remainingFireTicks at all, so nothing
could set a snowball alight however long it sat in the flames, and the
ThrownItemRenderer arm had nothing to draw a flame for.

ProjectileWorld gains `ignites` (false by default; the game answers it with
the block's name at that cell), the tick applies the fire phase after the
move, and the throwable render arm draws the same flame_geometry the arrow arm
already used for a burning arrow.
2026-11-02 17:47:13 +00:00
MSedgeMC b3475f8769 port_map: the wall hanging sign rows name the function that holds the port
WallHangingSignBlock.isHittingEditableSide and
shouldTryToChainAnotherHangingSign pointed at
crates/horizon-world/src/level/interact.rs::Behaviours::block_use_item_on,
which does not exist: the arm is in Sim::block_use_item_on (interact.rs, the
impl of Sim), where the two face tests and the HangingSignItem test live. The
ledger reports a row whose fn does not exist as a map gap, so the two rows said
nothing.

The shouldTryToChainAnotherHangingSign note also claimed the chain is missing
("passing on a non-editable face lets the held hanging sign place its own
block, which chains onto the wall sign"), while the real difference from
vanilla is narrower: shouldTryToChainAnotherHangingSign is
!canExecuteClickCommands(sign.getSlotPlayerIsFacing(player), player) &&
stack.getItem() instanceof HangingSignItem && !isHittingEditableSide(hit,
state), and the world reads canExecuteClickCommands as false (it has no sign
text), so a waxed wall hanging sign whose faced side runs a click command chains
where vanilla opens its editor. WallHangingSignBlock.useItemOn carried the same
wrong claim; it now names the arm and points at that condition.
2026-11-02 17:47:03 +00:00
Florian ERNSTandCodex 5b5f2e0422 Respect Fire Resistance in mob damage handling
Co-Authored-By: Codex <[email protected]>
2026-11-02 18:45:39 +01:00
MSedgeMC 12c0aea687 port_map: the openers counter says who schedules and who recounts
incrementOpeners points at the counter that schedules the recheck, and
recheckOpeners at the level hook the chest, ender chest and barrel ticks call.
2026-11-02 17:44:47 +00:00
MSedgeMC 120c53b535 test: a stale opener count is recounted away
The count a recheck finds and the count it replaces are a pure function of the
tracked counts and the containers open right now, so they are tested without a
level: a chest opened (0 to 1), then closed without the close path, is
recounted to (1, 0) and its entry is dropped, so the next opener counts from
zero again; a recount that agrees returns (1, 1) and changes nothing; a double
chest counts both halves and ignores other containers.
2026-11-02 17:44:47 +00:00
MSedgeMC 696d83a332 fix: the level recounts a container's openers, and the first opener schedules it
ContainerOpenersCounter.incrementOpeners schedules a recheck as soon as the
count leaves zero (bytecode 35-39: scheduleRecheck, and scheduleRecheck is
Level.scheduleTick(pos, state.getBlock(), 5), CHECK_TICK_DELAY = 5);
ContainerOpenersCounter.recheckOpeners then counts the entities that still
have the container open (getEntitiesWithContainerOpen) and, when the count
differs from openCount, calls onOpen or onClose and
level.gameEvent(null, CONTAINER_OPEN or CONTAINER_CLOSE, pos), always calls
openerCountChanged and reschedules while the count is above zero.

Horizon's world side already dispatched that recheck for Class::Chest and
Class::EnderChest, but LevelAccess::recheck_container_openers was left to the
game and GameLevel never implemented it, so it answered None: the chest tick
did nothing, and nothing scheduled it in the first place. A chest whose menu
closed without the close path (a death, a teleport, a dropped connection) kept
openCount above zero, so its lid stayed open and its trapped-chest signal
stayed high.

GameLevel now answers from the two sources that know: the local player's open
container and each guest's menu position (both halves of a double chest, as
ChestBlockEntity$1.isOwnContainer takes a CompoundContainer; spectators
counted nowhere), and Game::count_opener schedules the five-tick recheck when
the first opener arrives, as incrementOpeners does.
2026-11-02 17:44:47 +00:00
HrafnaValkyrja e272f7a585 Merge remote-tracking branch 'origin/main' into fix/spawner-delay-roundtrip 2026-11-02 12:43:47 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 022e7ba3ba test: save round-trip scene no longer depends on where the player lands
A spawner counts its Delay down, and a vault turns active, only while a
player is in range, and each game puts its player at a random spot near
the world spawn. Give the spawner, vault and trial spawner a range no
player reaches, so the saved values are the same in every run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 12:43:34 -05:00
Ludovic MarieandClaude Opus 5.5 13bafe18f6 launcher: glass instance tiles without frames, Update available
The sidebar drew instance icons through the old look's framed squares (a
solid 2 px outline, square corners). The glass look now draws them as the
mockup's InstanceIcon: a colour tile with the initial in Bricolage, or the
chosen block, round at 26 % with no frame; pictures and loader marks as
before. A row following the launcher's version says "Update available"
after an accent dot when a newer version waits, as the mockup's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:41:35 +01:00
JoansitohandClaude Sonnet 5.5 8b37eef7dd Fishing rod: cast direction uses Mth's sine table (straight down no longer launches up)
Fixes #7440

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 18:41:04 +01:00
arismoko d4ff1afd96 Merge remote-tracking branch 'upstream/main' into v2/ses_01M6EA9PQK4BXKXA1BE1D8FR68-w3-block-drops-note 2026-11-02 17:40:33 +00:00
arismoko 0a5b17f53b Merge remote-tracking branch 'upstream/main' into v2/ses_01M6EA9PJ1AQ4Q6N2S9TNXP8B7-w3-level-misc
# Conflicts:
#	crates/horizon-client/src/mod_api.rs
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 17:40:17 +00:00
MSedgeMC 2694deeb70 test: the too-big message names the limit as Java prints it
The double vanilla hands the message is `5.9999968E7`, so `Double.toString`
makes the text read `5.9999968E7`; a rounded form such as `5.999997E7` is a
different string. The test also pins the limit the check and the message now
share.
2026-11-02 17:39:34 +00:00
MSedgeMC 8cc8b8e438 fix: /worldborder set names the size limit as vanilla prints it
`WorldBorderCommand.ERROR_TOO_BIG` is built with the double `5.9999968E7`
(its `<clinit>` pushes `5.9999968E7d` into
`Component.translatable("commands.worldborder.set.failed.big", ...)`, the same
constant `setSize` compares `distance` against), and a `%s` substitution
prints a double with `Double.toString`. Horizon hardcoded the rounded
`5.999997E7`, which vanilla never shows.

The message now renders `worldborder::MAX_SIZE` through
`java_text::double_to_string`, the same value the size check uses.
2026-11-02 17:38:47 +00:00
[email protected] e346c40f48 Merge upstream/main 2026-11-02 18:38:27 +01:00
[email protected] 1434fb3cc3 Merge upstream/main 2026-11-02 18:38:26 +01:00
[email protected] df19991b9f Merge upstream/main 2026-11-02 18:38:24 +01:00
MSedgeMC 24a784ecaa port_map: game2-003's breeze ground particles are drawn
The row said no puffs at its feet while it stands, shoots, inhales or lands and
that only the random number was drawn; `emit_ground_particles` draws them now.
2026-11-02 17:38:10 +00:00
MSedgeMC fd7b453f70 test: the breeze's crumb count by pose, and the block it crumbles
The count test walks the five poses with a copy of the mob's random advanced
the way `Breeze.tick` advances it, so a pose that draws when vanilla does not
(inhaling) or fails to draw when it does (sliding) fails on the mob's next draw.
The second test builds a stone floor, checks one crumb event of the block under
the feet at the box centre with speed 0, twenty while inhaling, and none for a
passenger or a breeze over air.
2026-11-02 17:38:09 +00:00
MSedgeMC 88b767b82f fix: a breeze crumbles the block at its feet as its pose asks
`Breeze.tick` switches on the pose: inhaling calls `emitGroundParticles(20)`,
standing, shooting and sliding call `emitGroundParticles(1 + random.nextInt(1))`
after `resetJumpTrail`. `emitGroundParticles` returns early for a passenger,
takes `getInBlockState` unless it is air (then `getBlockStateOn`), returns when
that state's render shape is invisible, and otherwise draws
`new BlockParticleOption(ParticleTypes.BLOCK, state)` at
`(box centre x, position y, box centre z)` with no velocity.

Horizon drew the count's random and dropped the particle, and it drew it for
inhaling (which vanilla does not, so every later draw of that tick was off by
one) while sliding drew nothing (vanilla draws one there). `MobEvent::LandingDust`
carried no speed, so the falling-dust crumbs' 0.15 was fixed in the handler; the
breeze's crumbs are at rest.
2026-11-02 17:38:09 +00:00
[email protected]andOpenAI GPT-6.1 Sol 2bca4a9fc2 test(mods): include category type in API generator fixture
Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-11-02 18:36:38 +01:00
[email protected]andGPT-6.1 Sol a4da92003c Complete attacker-aware knockback v2 and regression coverage
Keep the frozen v1 hook and add v2 following menu_button_v2. Carry scoped player attacker IDs, preserve mob source IDs and known kill credit, and route armor-stand sweeps through the hook before motion. Add generated dispatch, scope restoration, resistance and source-fixture tests.

Closes #6444

Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-02 18:36:31 +01:00
yosifdheef 20d7f08f0c Merge remote-tracking branch 'upstream/main' into fix/sugarcane-water 2026-11-02 20:35:09 +03:00
MSedgeMC 4cfe0d1b4a port_map: the random spread row is a port, and locate keeps its offset
`RandomSpreadStructurePlacement.validate` was filed as `skip:data` ("codec
check that spacing exceeds separation"); `StructureRegistry::new` now makes
that check, so the row is a port.

The `StructurePlacement.getLocatePos` row still said "locate offset is zero".
`StructureSet::locate_set` reads `placement.locate_offset` and
`StructureState::locate` reports through it, so the row names that offset.
2026-11-02 17:34:42 +00:00
MSedgeMC 454a701a7e test: a refused random spread and an out of range frequency
`a_random_spread_needs_more_spacing_than_separation` decodes sets with
separation equal to, greater than and at 0 with spacing, spacing past the
codec's 4096 and a negative separation; every one of them fails, and 8/7 and
4096/0 decode.

`a_structure_set_frequency_is_a_fraction` decodes 1.5, -0.25, 1.0 and 0.0:
the first two fail, the last two decode.

Without the checks both tests fail on their first assertion: `Ok` where
vanilla's codec answers a `DataResult` error.
2026-11-02 17:34:41 +00:00
MSedgeMC c41c59c610 fix: a random spread whose separation reaches its spacing is refused
`RandomSpreadStructurePlacement.validate` is a codec check:

    private static DataResult<RandomSpreadStructurePlacement> validate(RandomSpreadStructurePlacement p) {
        return p.spacing() <= p.separation() ? DataResult.error(() -> "Spacing has to be larger than separation") : DataResult.success(p);
    }

and both fields are `Codec.intRange(0, 4096)`; `AbstractSpreadingStructurePlacement`'s
`frequency` is `Codec.floatRange(0.0F, 1.0F)`.

   212: aload_0
   213: getfield #1  // Field spacing:I
   216: aload_0
   217: getfield #7  // Field separation:I
   220: if_icmpgt 233
   223: invokedynamic  // DataResult.error
   ...
   233: invokestatic #20 // DataResult.success

Horizon decoded `spacing` and `separation` as plain ints, so a set whose
separation reached its spacing reached the spread with `spacing - separation`
of 0 or less: `next_int_bound(0)` fails and the `unwrap_or(0)` fallback put a
start in every grid-corner chunk, where vanilla refuses the placement and the
set never loads. Out of range spacing, separation and frequency were accepted
too.

`StructureRegistry::new` now refuses such a set, as vanilla's codec does.
2026-11-02 17:34:41 +00:00
Florian ERNSTandCodex 57f7ac675b Preserve signed item pickup delays and infinite sentinel
Co-Authored-By: Codex <[email protected]>
2026-11-02 18:32:47 +01:00
Ludovic Marie 2050c4e0f5 Merge pull request #7447 from horizon-rust/feat/launcher-gpui-fast-0.1.13
🤖 Agent: gpui-fast 0.1.13 needs macOS 12, so the launcher bundle's LSMinimumSystemVersion goes 11.0 → 12.0; launcher CI green on Linux, Windows and macOS, the bumped launcher draws the same home and servers pages as before.
2026-11-02 18:31:14 +01:00
razzo715andClaude Opus 5.5 3df52bec26 Thrown projectiles leave bubbles underwater (#7450)
ThrowableProjectile.applyInertia adds four bubbles behind a snowball,
egg, ender pearl or experience bottle in water, a quarter of its motion
back, at its motion after gravity and before the 0.8 drag. The client
now reads them from Projectile::water_bubbles before the tick and adds
them beside the other projectile trails.

Fixes #7450

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:30:10 +01:00
MSedgeMC 70b60c488d port_map: client2-011's list focus rows are ports
Both rows said Horizon's lists have no keyboard focus. OptionsUi::key walks
the page's focusables with Tab and the arrow keys and, when the widget that
takes the focus is a list row, scrolls it into view four pixels past the edge
it crossed: the neighbour stepping is what getBorderForArrowNavigation
answers and the scrolling is setFocused's. The rows now name that code.
2026-11-02 17:29:41 +00:00
[email protected]andOpenAI GPT-6.1 Sol db6c16470e feat: evaluate native conditional item model properties per stack
Part of #7488

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-11-02 18:29:04 +01:00
MSedgeMC aca46f0721 port_map: recheckOpen says what rechecks a barrel
BarrelBlockEntity.recheckOpen was missing, with a note that nothing rechecks a
barrel because Horizon draws no barrel lid. The recheck is the open property
and the sound, not a lid: Sim::tick_block ports it now. BarrelBlock.tick points
at that port too, and keeps the menu path (barrel_openers_changed) in its note.
2026-11-02 17:29:03 +00:00
MSedgeMC 66b5fba32b test: a barrel's scheduled tick recounts its openers
The level test harness answers recheck_container_openers from a map, so a test
can say what a recount finds. The test runs one scheduled tick of a barrel and
checks the whole chain: the hook is reached, a 1 to 0 recount closes the open
property, plays block.barrel.close at volume 0.5 with a pitch in 0.9 to 1.0 and
sends container_close, no block event is queued (a chest queues one), a 0 to 1
recount reopens it, the recheck repeats five ticks later while the count stays
above zero, and the chain ends once nobody is left.

Without the arm the first assertion fails with an empty opener_rechecks.
2026-11-02 17:29:03 +00:00
MSedgeMC 6e17bc643e fix: a barrel recounts its openers on its scheduled tick
BarrelBlock.tick (bytecode: getBlockEntity, instanceof BarrelBlockEntity,
invokevirtual BarrelBlockEntity.recheckOpen) leads to
BarrelBlockEntity.recheckOpen, which calls
ContainerOpenersCounter.recheckOpeners(level, pos, state). That method counts
the entities that still have the container open, and when the count differs
from openCount it fires onOpen or onClose and then
level.gameEvent(null, CONTAINER_OPEN or CONTAINER_CLOSE, pos); it always
calls openerCountChanged and, while the count is above zero, scheduleRecheck
(five ticks). For a barrel onOpen/onClose play BARREL_OPEN or BARREL_CLOSE
(BarrelBlockEntity.playSound: volume 0.5, pitch 0.9 + random*0.1, at the
block's front face) and call updateBlockState, which is
setBlockAndUpdate(pos, state.setValue(OPEN, open)); its openerCountChanged
returns without a block event (BarrelBlockEntity$1).

Horizon dispatched the recheck only for Class::Chest and Class::EnderChest,
so a barrel's scheduled tick fell through to the catch-all arm: a barrel whose
viewers all left stayed open, played no close sound and sent no
container_close game event.

The barrel arm keeps the chest arm's five-tick reschedule and skips its block
event, as vanilla's empty openerCountChanged does.
2026-11-02 17:29:03 +00:00
Ludovic MarieandClaude Opus 5.5 6d0901e2aa launcher: the redesign's glass shell (sidebar, top bar, menus)
The dark appearance now draws Anthony S.'s mockup frame (horizon-site
components/launcher/launcher-app.tsx) over the pixel sunset:

- glass.rs: the mockup's frosted plates. The scene's backdrop is blurred and
  tinted once per window size into pieces cut to each plate, since GPUI Fast
  has no backdrop filter.
- glass_ui.rs: the mockup's controls (row, icon button, tab, menu row,
  popover, solid button, dot, bar, mark tile) at its sizes and fills.
- frame.rs: the sidebar (instances by section with their state, the update
  card, Join the Discord, Sponsor/Website links, version), the top bar (tabs,
  profile chip and menu, Sign in with Microsoft, settings) and each row's
  menu (Edit, Duplicate, Open folder, Export, desktop shortcut, Delete asked
  twice), wired to the launcher's real actions.

Kept although the mockup lacks them: the Sculk sponsor card, the servers
section and the Build log button. The light appearance keeps the old frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:28:53 +01:00
MSedgeMC 999e57ba51 test: build the creaking from the sensor-scan fixture
The first version loaded `MobTypes` from the prepared `.local` data, so the
test returned early wherever that data is absent, CI included, and proved
nothing there. `sensor_scan_tests::kind()` is a plain `MobType` the other mob
tests already use, so the creaking is built from it and the test always runs.

It also pins that a home point a wolf keeps (a bed) is not a creaking heart,
so `heart_bound` does not refuse that wolf a portal.
2026-11-02 17:27:26 +00:00
[email protected]andOpenAI GPT-6.1 Sol cdad8e28e5 test(particles): use a valid air registry for motion checks
Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-11-02 18:26:31 +01:00
[email protected]andGPT-6.1 Sol 2c94acfbe6 test(mods): verify collision-only outlines stay unchanged
Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-02 18:26:27 +01:00
MSedgeMC 88ab564aef port_map: Creaking.canUsePortal is creaking::can_use_portal
The row said missing and named `mob_can_use_portal` as the place that has no
heart check; it now points at the override and `mob_can_use_portal` asks it.
2026-11-02 17:25:28 +00:00
MSedgeMC b5f6846cb7 test: a heart-bound creaking does not use a portal
`set_transient` binds a creaking to a heart, so the test pins both sides of
`Creaking.canUsePortal`: an unbound creaking still travels through a portal,
a heart-bound one does not, and a zombie is unaffected.
2026-11-02 17:25:28 +00:00
MSedgeMC d94cfd2c0f fix: a heart-bound creaking refuses to use a portal
`Creaking.canUsePortal` returns false while the creaking is bound to a
creaking heart (`isHeartBound()`, i.e. `getHomePos() != null`), and only
then defers to `Monster.canUsePortal`. Horizon's `mob_can_use_portal`
checked alive, mount, rider, the guest-ride rule and the wither, so a
heart-bound creaking walked into a nether or end portal and left its heart
behind.

`mobs/creaking.rs::can_use_portal` is that override, asked last by
`Game::mob_can_use_portal`.

Refs tools/port_map/game2-003.tsv:71 (Creaking.canUsePortal, missing).
2026-11-02 17:25:28 +00:00
Ludovic Marie 3527331d12 Merge pull request #7243 from arslanarm/shaderpacks/line-comment-continuation
🤖 Agent: merged main in (conflict: both branches added a test at the same spot in preprocess.rs, kept both); horizon-shaders 155 tests and clippy pass. The fix itself is narrow: only a // comment whose last char is a backslash swallows the next line, block comments are untouched.
2026-11-02 18:25:08 +01:00
[email protected]andGPT-6.1 Sol d589852c19 fix(mods): read the acting player entity id
Use the player motion state, including the swapped LAN guest, rather than the motion resource table. Add the pending API stamp token.

Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-02 18:24:25 +01:00
[email protected]andOpenAI GPT-6.1 Sol 04d7674f13 test(mods): cover recipe category hook dispatch
Format the category slice and exercise the public trait hook, including the no-mod path.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-11-02 18:23:59 +01:00
Florian ERNSTandCodex 3199c1a4fb Fix armor stand climbing movement
Co-Authored-By: Codex <[email protected]>
2026-11-02 18:23:24 +01:00
Ludovic MarieandClaude Opus 5.5 7b8f97e3fe Merge origin/main into shaderpacks/line-comment-continuation
Keeps both tests in preprocess.rs: the line-comment continuation one from this branch and main's unbalanced-conditionals one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:23:19 +01:00
umcekoandClaude Sonnet 5.5 32a4e05ba0 Models: per-face light, colour, AO and render types
Decode per-face light data (block_light, sky_light, color,
ambient_occlusion) on elements and faces, face-level light_emission, and
the model render_type. Baked quads carry a (block, sky) light minimum,
colour, AO override and render type; the mesher raises vertex light per
channel, multiplies colour into the tint, and draws a typed quad in that pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:23:12 +03:00
[email protected] e07d847df6 WIP: start native item model property implementation 2026-11-02 18:22:08 +01:00
[email protected]andOpenAI GPT-6.1 Sol 8487906f8c feat(mods): declare and resolve custom recipe book categories
Part of #5501. First slice: validated declarations and a cached Host lookup; display, search and unlock integration remain follow-up work.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-11-02 18:20:56 +01:00
MSedgeMC 1fc91ecab8 port_map: client2-012's fishy advance rows are ports
Font::resolve now skips a provider glyph whose advance is under 0 or over 32
and moves on to the next provider, and provider_advance, which builds the
by-width table obfuscated text picks from, does the same, so the two rows
name fishy_advance instead of calling the check missing.
2026-11-02 17:20:43 +00:00
MSedgeMC 67216485b1 test: a fishy advance falls through to the next provider
A font whose first provider gives 'a' a 65 pixel advance and whose second
gives it 3: the character must advance 3 (it advanced 65 before the change),
and the by-width table obfuscated text picks from must hold no 65 wide glyph,
so random_glyph for that width draws the missing glyph instead.
2026-11-02 17:20:43 +00:00
MSedgeMC 32b78254ed Fonts: skip a provider glyph whose advance is fishy
FontSet.computeGlyphInfo does not stop at the first provider that has a
character: it remembers that glyph for the plain glyph source and keeps
looking, and hasFishyAdvance rejects a glyph whose advance is under 0 or over
32. So a resource pack with a broken glyph cannot widen a line - vanilla
draws the next provider's glyph, or the missing glyph. Horizon's resolve took
the first provider's glyph whatever its advance, so such a pack made its text
as wide as the pack asked, and the obfuscated-text width table put that
character under the wrong width.

The same check goes into provider_advance, which builds the by-width table
that random_glyph picks from, and skips fishy space, bitmap, unihex and
TrueType glyphs alike. Unihex advances now come from hex_advance, shared with
rasterize_hex so the two cannot drift.
2026-11-02 17:20:43 +00:00
MSedgeMC 673fa35179 port_map: client2-017's language row key row is a port
The row kept a gap note saying a focused language row needed a second press
to finish the page, which Entry.keyPressed's isSelection arm never did; the
row now says what OptionsUi::key does.
2026-11-02 17:20:26 +00:00
MSedgeMC 381659f209 test: a language row's selection key applies and leaves the page
Enter, keypad Enter and Space on a focused LanguageRow each apply the row's
language (Settings::language and Event::Language) and pop the Language page,
which presses twice before this change: the first press only set the choice.
2026-11-02 17:20:26 +00:00
MSedgeMC 4991baf54e Language: the selection key picks a language and finishes the page
LanguageSelectionList.Entry.keyPressed asks the event for isSelection (Enter,
Space, keypad Enter) and, when it is one, selects the row and calls
LanguageSelectScreen.onDone: the language applies and the screen closes on
that one press. Horizon's Enter arm on a focused row only remembered the
choice, and a second press was needed to finish the page, so a keyboard user
had to press Enter twice to change language.
2026-11-02 17:20:26 +00:00
[email protected]andOpenAI GPT-6.1 Sol 2e98a62e1b feat(particles): add declarative mod particle behaviour
Part of #7465. Add validated motion parameters and linear size/alpha curves without changing default particle behaviour.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-11-02 18:20:18 +01:00
MSedgeMC 053b23101c port_map: the dimension_origin row says where the placement now is
The classes-07 row for `DimensionOriginStructurePlacement` read `missing`
because the type decoded as `Placement::Unsupported` and such a set never
placed. It now decodes and places, so the row names the arm that answers
`isStructureChunk` and a home row for the type, with the origin it reads
(`ChunkGenerator.getOrigin`, chunk 0 0 or the NoiseSpawnFinder chunk).
2026-11-02 17:19:53 +00:00
Ludovic Marie 86929d2ebe Merge remote-tracking branch 'origin/main' into prO-m-7162 2026-11-02 18:19:53 +01:00
MSedgeMC 9d7aca5ad6 test: the dimension origin chunk, and the checks it skips
`dimension_origin_places_the_generators_origin_chunk_alone` pins the chunk
the set places in: chunk 0 0 for a generator with no origin of its own,
then the origin the generator reports, and no chunk beside it.

`dimension_origin_ignores_a_frequency_and_an_exclusion_zone` pins the two
checks that must not run for this placement type: a `frequency` of 0.0 and
an exclusion zone whose other set spreads over every chunk both leave the
origin chunk's start in place.

Both fail with the placement decoded as unsupported: no chunk holds a
start, including the origin chunk.
2026-11-02 17:19:53 +00:00
Ludovic MarieandClaude Opus 5.5 7f5ff72503 Explosion debris: draw the weighted pick every particle, drop the dead explosion_blocks
ClientExplosionTracker.tick calls WeightedRandom.getRandomItem for each
of its particles, so the level random draws nextInt(totalWeight) even
with one tracked explosion. explosion_blocks had no caller left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:19:53 +01:00
MSedgeMC 24dcd97129 fix: a dimension_origin structure set places its start in the generator's origin chunk
`StructurePlacements` registers three placement codecs: concentric_rings,
dimension_origin and random_spread. Horizon decoded the first two and let
`minecraft:dimension_origin` fall through to `Placement::Unsupported`, which
places nothing anywhere, so a set that uses it never generated and `/locate`
never had a start to find.

`DimensionOriginStructurePlacement.isStructureChunk` names one chunk:

    ChunkPos chunkPos = state.getDimensionOrigin();
    return chunkPos.x() == x && chunkPos.z() == z;

`ChunkGeneratorStructureState.getDimensionOrigin` is the `origin` field its
constructor is given, which `ChunkGenerator.createState` fills from
`getOrigin(RandomState)`. `ChunkGenerator.getOrigin` is `ChunkPos.ZERO`;
`NoiseBasedChunkGenerator` overrides it to
`ChunkPos.containing(NoiseSpawnFinder.findSpawnPosition(spawnTarget, ...))`
and keeps ZERO when `spawnTarget()` is empty.

The type implements `StructurePlacement` directly, not
`AbstractSpreadingStructurePlacement`, so its `isStructureChunk` is that
comparison alone: no `applyAdditionalChunkRestrictions` (frequency) and no
`applyInteractionsWithOtherStructures` (exclusion zone) run for it.

Horizon now decodes the type, keeps the generator's origin on the registry
(chunk 0 0 until the generator reports one), and answers the chunk question
before the rings, distance, avoid, frequency and exclusion checks that
belong to the spreading placements. The origin comes from the pipeline's
cached spawn column, so the NoiseSpawnFinder search runs only when a set
actually reads the origin.
2026-11-02 17:19:52 +00:00
[email protected]andGPT-6.1 Sol 5d41cf1623 feat(mods): support tuple-selected block and collision shapes
Checkpoint implementation and regression coverage for #7464.

Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-02 18:19:40 +01:00
[email protected]andGPT-6.1 Sol fcae9b7c43 feat(mods): add host-aware weapon damage hook v2
Preserve the original incoming damage hook and expose weapon and geometry snapshots for player-to-mob weapon hits. Remaining damage routes stay on v1.

Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-02 18:19:25 +01:00
[email protected]andGPT-6.1 Sol f9878f7046 WIP: preserve knockback v1 and add attacker-aware v2
Recovery checkpoint for #6444; validation and regression coverage follow.

Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-02 18:18:39 +01:00
MSedgeMC f4fc2c7838 port_map: LevelStorageSource.validateAndCreateAccess is ported
The row still said the link check was missing; it now names
world_management::symlinks.
2026-11-02 17:18:32 +00:00
MSedgeMC f6f34fcd53 test: opening a world with a link reports the symlink warning
Builds a save whose World/link points outside the saves folder, clicks
Play Selected World and pins the action, the mode and that the refused
world is left unlocked. Without the check in play() the click returns
Play { folder: "World" }.
2026-11-02 17:18:32 +00:00
MSedgeMC 6a30771613 fix: a world folder holding a symbolic link is not opened
LevelStorageSource.validateAndCreateAccess validates the world directory
before it makes an access (worldDirValidator.validateDirectory(path, true),
then ContentValidationException when the list is not empty), so
WorldOpenFlows never locks or loads a folder that holds a link. Horizon
opened it as usual: only backup and delete looked for links.

world_management::symlinks walks the folder without following a link and
Title::play refuses the world with the symlink warning
(symlink_warning.title.world / symlink_warning.message.world) before the
session lock is taken.
2026-11-02 17:18:32 +00:00
Ludovic Marie 44ca91ef8f Merge pull request #7527 from MSedgeMC/test/block-state-placement
🤖 Agent: queued at 8f97c29e8b. Harness only: block_state_case pins apply_item_block_state, and the body's mutation run (call removed → mismatches) shows it has signal; CI parity is the after-check.
2026-11-02 18:17:37 +01:00
Ludovic Marie 066c33b103 Merge pull request #7532 from MSedgeMC/chore/led2-client2-003
🤖 Agent: queued at 4ae934a8d6. Notes only: the three debug views stay missing with a concrete reason each; refreshRendererList's list matches debug_gizmos.
2026-11-02 18:17:32 +01:00
Ludovic Marie 5a01369d37 Merge pull request #7526 from MSedgeMC/chore/led2-client-008
🤖 Agent: queued at 0a30628944. Port map only: bounding_box_gizmos (structure_blocks.rs:61) covers the structure and test instance boxes cited.
2026-11-02 18:17:25 +01:00
Ludovic Marie 1bca78c05b Merge pull request #7523 from MSedgeMC/chore/led2-rest-009
🤖 Agent: queued at db3dd7d0b6. Port map only: the gizmo rows now cite debug_renderer_geometry, billboard_text_unit and cuboid_fill_quads, all on main.
2026-11-02 18:17:20 +01:00
Ludovic MarieandClaude Opus 5.5 1a8c48a62f Wither: the charge's cloud on a server's clients too
The window plays single player as a client of its server thread, where
the wither's server-side aiStep draws nothing anyone sees: the client's
tick (beside client_smoke) emits charging_particles from the
DATA_ID_INV ticks the server sends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:17:19 +01:00
Alexander KrylovandHermes Agent 3fe3bcaf47 Ask the build, not the trait mods, whether a mod image slot is needed
native_mods is the harness's list of trait mods, and a native mod built from
mixins is not one: it runs through its injections, so the list is empty and the
1024x1024 slot was never packed. Read native-mods.json beside the executable
instead, which is what the launcher writes for a game it built with mods; a
release or Nightly has none.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 20:17:04 +03:00
Loki cb8ae1cd7a Merge remote-tracking branch 'origin/main' into fix/7510-baby-pig-lightning-piglin 2026-11-03 02:16:56 +09:00
umcekoandClaude Sonnet 5.5 00b567c1de Content mods: sprites a mod adds to vanilla's atlases join the root
A mod can add sources to vanilla's atlases (assets/minecraft/atlases/*.json),
as a resource reload appends every pack's sources to the ones below it.
The fence allows those files, the content root writes vanilla's sources
followed by the mods', and carry keeps them.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 20:16:51 +03:00
LokiandAntigravity 6c5afc49ba mobs: preserve baby age when pig is converted by lightning (fixes #7510)
Co-Authored-By: Antigravity <[email protected]>
2026-11-03 02:16:50 +09:00
TheMrZZandCodex gpt-6.1-sol d3a2308444 Merge current main and retain cloud and trident command targets
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 18:16:21 +01:00
levzzzandClaude Opus 5.5 5030e219c9 Native mods: living entity handles (LivingRef) and a living entity's equipment (#7387)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:15:43 +05:00
levzzzandClaude Opus 5.5 8168e1c1a6 Native mods: an entity's data, tags and name through EntityRef (#7387)
entity.nbt(host) and merge_nbt as /data get and /data merge entity do,
tags, add_tag and remove_tag as /tag does, and name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:15:43 +05:00
Ludovic Marie f030c124c5 Merge pull request #7522 from MSedgeMC/chore/led2-client-002
🤖 Agent: queued at 9079b9d0bb. Port map only: the three ClientPacketListener rows now cite functions that exist (forget_columns, poll_remote's Reconfiguring arm).
2026-11-02 18:14:59 +01:00
Ludovic Marie 601899e268 Merge pull request #7521 from MSedgeMC/chore/led2-game2-001
🤖 Agent: queued at 15c11de9e9. Port map only: blast_spares_decorations exists on main and matches the cited rule.
2026-11-02 18:14:54 +01:00
Ludovic Marie ea2efee926 Merge pull request #7520 from MSedgeMC/chore/led2-game2-004
🤖 Agent: queued at 8d37f6265b. Port map only: Game::can_replace in mob_looting.rs does keep the drowned's nautilus shell (line 127), so port is right.
2026-11-02 18:14:49 +01:00
MSedgeMC 76f1dc4a66 port_map: the F3 overlay's chunk, level and chart lines 2026-11-02 17:14:01 +00:00
MSedgeMC d4497e91f9 test: pin the charging wither's three potion-coloured particles
Neither the count of the spawn cloud's particles nor their colour could be
checked while the block only rolled the random. The test pins three
`ParticleColored` entries a tick in the (0.7, 0.7, 0.9) colour, their offsets
against the wither's own random advanced in vanilla's order, the y spread over
`[0, 3.3 * getScale())` at scale 1 and at scale 2, and no cloud at all once
`getInvulnerableTicks` reaches 0.
2026-11-02 17:12:33 +00:00
MSedgeMC 8064fd32f2 fix: a charging wither sheds its potion-coloured particles again
`WitherBoss.aiStep` draws the wither's spawn cloud in the same tick it counts
the charge down: while `getInvulnerableTicks()` is above 0 it adds three
`ENTITY_EFFECT` particles a tick in the (0.7, 0.7, 0.9) colour, each a gaussian
away from the body on x and z and `random.nextFloat() * 3.3 * getScale()`
above its feet.

The port rolled exactly those three randoms and threw the offsets away, so a
wither charging for its eleven seconds rose with head smoke alone and no cloud
around it. The rolls now build the three particles, on the wither's own random
in vanilla's order (gaussian, float, gaussian).
2026-11-02 17:12:33 +00:00
MSedgeMC 4ae934a8d6 notes: the debug views Horizon draws, and the entries that draw nothing 2026-11-02 17:12:32 +00:00
Ludovic Marie ae3cbb0f11 Merge pull request #7530 from horizon-rust/perf/remove-columns-bulk
🤖 Agent: a teleport that drops every occlusion column now clears the store (one Edit::Clear) instead of ~27k see-through edits plus a propagate into a graph dropped next frame; partial removals unchanged. Render-thread CPU 3.7 → 0.2 ms over the two frames.
2026-11-02 18:12:09 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 a8d94a97c7 Merge origin/main into refactor/entity-walk-loads
Trident loading from #7390 (remember the saved compound by uuid) lives in Game::load_trident, behind Entity::load_nbt.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 12:10:35 -05:00
MSedgeMC 8f97c29e8b use_item_on: a placed block takes the state its item's block_state names
Vanilla's BlockItem.place runs updateBlockStateFromTag once the click chose the
placement state: it reads the item's minecraft:block_state component, applies
every named property the placed block has and sets the block again when the
state changed. That is how a Ctrl + Pick Block of a turned stair, a top slab, a
disabled hopper or a 4-tick repeater places the block it was copied from.

The placement code already does that (Game::apply_item_block_state); the parity
cases never carried a block_state component, so a regression there would have
gone unnoticed. Add block_state_case: twelve block and property pairs whose
state differs from what the click alone chooses, plus a property the block has
no such thing as and an empty map, which BlockItemStateProperties.apply skips.
2026-11-02 17:09:36 +00:00
TheMrZZandCodex gpt-6.1-sol 63532f16e0 Respect player armor durability components
Check damageability before wear effects and use the stack maximum when breaking armor. Cover normal, unbreakable and custom-limit armor in the ticking LAN damage fixture.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 18:09:14 +01:00
MSedgeMC 0a30628944 port_map: the structure and test instance boxes are drawn 2026-11-02 17:08:48 +00:00
Florian ERNSTandCodex 8917e421a2 Reuse experience level logic for command reset boundaries
Keep progress and total at exactly zero levels, clear all three fields below zero, and retain one native level-change notification by delegating to give_levels. Add four command boundary cases with 44 observations and no ticking.

Co-Authored-By: Codex <[email protected]>
2026-11-02 18:07:35 +01:00
ratuser1andClaude Opus 5.5 2753e0f8b9 Options: join Horizon captions with the language's separator (#6072)
Horizon-only captions (Graphics pages, Extras, Mods, Shader packs, shader
pack options) hard-coded ": " between label and value. They now use
options.generic_value (CommonComponents.optionNameValue), and percent values
options.percent_value, so French, Chinese, Japanese and en_ud get their own
separator and order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:07:10 +05:00
ThatCakeIsAPie a43709317e Biomes: a packed world's mod colours start from the biome files each refresh, rebuilding only when they change 2026-11-02 11:06:45 -06:00
ThatCakeIsAPie 364ce79d15 Biomes: single player keeps its pack biomes, mod colours stay in plain worlds, a new server's table replaces the last 2026-11-02 11:06:45 -06:00
ThatCakeIsAPie 329eb2bc14 Network: a world's data-pack biomes in the registry it sends, the client's table and the mesher 2026-11-02 11:06:45 -06:00
MSedgeMC db3dd7d0b6 port_map: the debug gizmos that draw quads and billboard text 2026-11-02 17:06:12 +00:00
Ludovic Marie af48a5eb47 Merge pull request #7517 from MSedgeMC/chore/led2-game2-018
🤖 Agent: queued at 67cc72a2cc. Checked: interact.rs:443 handles the MovingPiston arm of use_without_item, and set_test_status exists in game_tests.rs, so the 'no runner' and 'not removed' gaps are stale. TSV-only, 3 citations exist.
2026-11-02 18:05:52 +01:00
Ludovic Marie 318efccbc5 Merge pull request #7516 from MSedgeMC/chore/led2-game-009
🤖 Agent: queued at 3aa0691c81. Checked: fox.rs:156 and hostile.rs register ClimbOnTopOfPowderSnow, and ai.rs:3272 tests it, so the 'goal missing from registration' gap is stale. TSV-only, both citations exist.
2026-11-02 18:05:48 +01:00
Ludovic Marie a8b50b7941 Merge pull request #7514 from MSedgeMC/chore/led2-rest-011
🤖 Agent: queued at aa5b198b4c. Checked: frame.rs:68 rejects a zero length as a corrupted frame, so the Varint21FrameDecoder gap is stale. The eight object-component rows now point at inline_object and draw_inline_object, which exist on main. TSV-only, 4 columns.
2026-11-02 18:05:43 +01:00
MSedgeMC 9079b9d0bb notes: rewrite the gaps that closed
A reconfiguration keeps the chat now, and the Reconfiguring arm of
poll_remote is where it happens; only the reconfiguring screen is still
missing.
2026-11-02 17:04:57 +00:00
MSedgeMC 2aeb5664ed port_map: repair the citations map-check rejects
Streaming::forget is Streaming::forget_columns, which also drops the
column's light state, so the row's note says that much.
2026-11-02 17:04:50 +00:00
MSedgeMC 15c11de9e9 port_map: what a blast spares among block-attached entities 2026-11-02 17:04:25 +00:00
some-du6eandClaude Opus 5.5 c3aac5716d Graphics settings: the docs screenshot after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:04:17 -06:00
MSedgeMC 8d37f6265b port_map: the drowned's nautilus shell guard 2026-11-02 17:04:15 +00:00
MSedgeMC 67cc72a2cc port_map: a moving piston click and the test instance status 2026-11-02 17:03:25 +00:00
MSedgeMC 3aa0691c81 port_map: the powder snow climb goal and the spear's charge state 2026-11-02 17:02:35 +00:00
levzzzandClaude Opus 5.5 464d5544eb Native mods: an entity's velocity, ground, box, eyes and view through EntityRef (#7387)
entity.velocity(host) and set_velocity, on_ground, bounding_box,
eye_height, eye_position, view_vector and distance_to_sqr. The shared
entity walk's Entity trait gains velocity().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:02:05 +05:00
levzzzandClaude Opus 5.5 b978111ddd Native mods: a level's blocks and time through LevelRef (#7387)
level.block_state(host, pos), block, block_property, is_loaded, height,
game_time and day_time, for any level the server has loaded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:01:55 +05:00
Loki 072464bf28 Merge remote-tracking branch 'origin/main' into perf/4128-explosions-fps 2026-11-03 02:01:50 +09:00
MSedgeMC aa5b198b4c notes: rewrite the gaps that closed
A zero frame length is refused by frame::header, and a show_entity hover is
kept and drawn as a tooltip.
2026-11-02 17:00:45 +00:00
MSedgeMC ca74c26176 port_map: flip the rows main already implements
Object components are parsed, given their fallback text and drawn, so the
six rows that said nothing happened now name the functions that do it.
2026-11-02 17:00:38 +00:00
Florian ERNSTandCodex bda89987c7 Use effective mob armor and toughness for runtime damage
Keep armor command instances on the mob and reuse the attribute calculation with current equipment and covered-shell bonuses. Floor effective armor before CombatRules and preserve bypass behavior. Add source-backed health and bonus tests plus 28 command-to-damage cases.

Co-Authored-By: Codex <[email protected]>
2026-11-02 17:58:37 +01:00
HrafnaValkyrja 828237afa7 Merge remote-tracking branch 'origin/main' into test/save-leads-roundtrip 2026-11-02 11:58:12 -05:00
JustKarkatandJustKarkat's AI agent c6e49ac73c A Music page plays the built-in tracks and leaves the off ones out
Music & Sounds gains a Music... button: the page lists the built-in tracks
by situation (menu, overworld, creative, underwater, the End, boss,
credits), read from the loaded sounds.json.

- Left click plays that track now: it takes the music manager's slot
  (stopping the track already playing) and the situation's own music
  resumes when it ends.
- On/Off on the right of a row takes a track out of every pool it plays
  in, so a situation whose tracks are all off plays nothing. Nothing is
  off by default, so vanilla's random selection is unchanged; the off
  tracks are musicOff in horizon-extras.txt.

The registry picks around refused sounds (SoundRegistry::sound_except, the
same weighted walk including nested "type": "event" references), the
manager resolves and hands the exact sound to the instance, and resolve
keeps a sound a caller chose (getOrResolve runs once).

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-02 10:58:06 -06:00
HrafnaValkyrjaandClaude Sonnet 5.5 d2a4a97c1f test: save round-trip scene covers leads
Adds a cow tied to a fence knot and a boat tied to a pig to the scene,
a SETBLOCKS step, a Pos-aware summon position, and a check that vanilla
kept both leads, so a dropped lead in Horizon's save now fails the test.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 11:57:57 -05:00
Ludovic MarieandClaude Opus 5.5 867677bbbd Merge main into #7292
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:56:59 +01:00
MSedgeMC 8987ae75eb Test the off-hand quick move against an empty and a taken off hand
A shield shift-clicked from the inventory menu lands in slot 45; with
slot 45 taken it goes to the hotbar, and a diamond still does.
2026-11-02 16:56:50 +00:00
MSedgeMC d9f93b474f Inventory: a shift-click sends an off-hand item to the off hand
InventoryMenu.quickMoveStack resolves the item's equipment slot with
getEquipmentSlotForItem and, when it is EquipmentSlot.OFFHAND and menu
slot 45 is empty, moves the stack there with moveItemStackTo(45, 46,
false) before trying the hotbar. Horizon knew only the armor branch, so
a shield or a totem went to the first empty hotbar slot instead.

Rules gains equip_slot (the minecraft:equippable component's slot) and
quick_move checks it after the armor branch, in vanilla's order.
2026-11-02 16:56:50 +00:00
Ludovic Marie 60aa0ec37f Merge main into #7273
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
#	tools/test_server_entities_parity.py
2026-11-02 17:56:19 +01:00
umcekoandClaude Sonnet 5.5 68c6f12e92 Item models: mods' own types fall back to vanilla's behaviour
An item definition with a node type, property, special model or tint source from
another namespace decodes to the closest vanilla behaviour instead of being
dropped (minecraft: names still fail). A definition that fails to load is logged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 19:56:14 +03:00
socksproxandDevin (SWE-2 High) 2a001222fc startup: snapshot the block-state registry beside behaviours-v2.bin
facts.json's ~35k states cost a full streamed parse every launch
(~60 ms). registry-v1.bin in the same .horizon-cache dir restores the
built tables in ~24 ms when its SHA-256 fingerprint of facts.json and
the running executable matches; a miss parses and rewrites it. The
snapshot stores a string table and the built ids/defaults — no Value
trees — so restore also skips the parse-time peak.

The fingerprint's content hash now digests files in 4 MB pieces across
idle cores — a scheme of the bytes alone, identical on any machine —
which also speeds the behaviours fingerprint over the ~70 MB sources.

Assets::load goes through the new load_registry_with_flags so the
client's startup parse uses the snapshot too.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 17:56:07 +01:00
Ludovic MarieandClaude Opus 5.5 15db6773cb perf(render): a teleport's column removal starts the occlusion graph over
When every column the occlusion store holds goes at once (a teleport drops the
whole view), Occlusion::remove_columns clears it like a world change instead of
pushing one see-through edit per removed section. At render distance 32 that was
~27 000+ edits: 1.2 ms in the removal and 2.5 ms propagating them into the old
graph on the next frame, a graph that is dropped right after because it lost the
camera. Now 0.15 ms and nothing on the next frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:55:12 +01:00
Ludovic Marie 43d5322894 Merge pull request #7305 from Xmilon/native/render-hand-pose
🤖 Agent: queued at e8afde61ab. With the hand left where it is, the pose skips hand_pose_matrix: no five Mat4 products per hand per frame without a mod. levzzz's main merge matches the one I made (both tests kept: hand_pose_rejects_invalid_components and the overlay test from #7333), and xtask mod-api --check, test_stamp_native_hooks and rustfmt pass on this head. The code before the merge passed clippy and the native tests on playhorizon.
2026-11-02 17:54:05 +01:00
Ludovic Marie 85a59ba66d Merge pull request #7352 from Xmilon/native/tooltip-color
🤖 Agent: queued at 15d058f004. tooltip_color hands mods only the background and border ARGB values, and with no mod loaded the dispatch is an empty slice, so tooltips cost the same for players without mods. I merged main twice (docs/native-mods.md conflicted with the camera section) and put your paragraph back before that heading, since your merge had moved it under it. The pushed tree is the one that passed clippy and the native tests on playhorizon; xtask mod-api --check and test_stamp_native_hooks pass.
2026-11-02 17:51:51 +01:00
arismoko d0f4655dc6 Merge remote-tracking branch 'upstream/main' into entity-holders
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-02 16:51:49 +00:00
levzzzandClaude Opus 5.5 b7ca31b03a Merge main into native/render-name-tag
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:51:22 +05:00
Stink-O 696a760e85 Merge branch 'main' of https://github.com/ludovic111/horizon into native/block-sprites
# Conflicts:
#	docs/native-mods.md
2026-11-02 16:51:13 +00:00
levzzzandClaude Opus 5.5 e8afde61ab Merge main into native/render-hand-pose
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:50:27 +05:00
Ludovic MarieandClaude Opus 5.5 15d058f004 Native mods doc: the tooltip_color paragraph before the camera section
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:50:07 +01:00
Ludovic Marie 0e74afc83f Merge main into #7352
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 17:49:24 +01:00
Ludovic MarieandClaude Opus 5.5 9f8eaba80d theme: the glass look's tokens, fonts and icons
horizon_theme::glass holds the colours, corners, text and fill opacities of Anthony S.'s
launcher mockup (horizon-site app/launcher/launcher.css). Glass::new does the mockup's
color-mix sums (OKLab mixes, the plate's premultiplied mix), checked against Chrome's own
results, so a player's accent comes out as the website's would. tokens.css carries them as
--glass-*.

Fonts: Bricolage Grotesque SemiBold at optical sizes 56 and 28, the cuts a browser picks for
the mockup's headings (tools/bake_bricolage.py), and Geist registered in the launcher.
Icons: the 38 Hugeicons (MIT) the mockup uses, served as icons/glass/<name>.svg.
docs/design-system.md gets a Glass section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:47:59 +01:00
Ludovic Marie 549d6b9cd5 Merge pull request #7333 from Xmilon/native/screen-lifecycle
🤖 Agent: queued at 1e3cf30442. Your latest commit has the same early return I'd made in observe_screen: with no native mod there's no title or menu snapshot to clone each frame. The three hook points now sit in the hook-points section, so catalog_matches_hook_points passes. Your head merges cleanly with main without a regen (xtask --check and test_stamp_native_hooks pass), and clippy and the native tests pass.
2026-11-02 17:46:42 +01:00
Ludovic Marie faaa8e95e5 Merge pull request #7358 from levzzz5154/render/hand-drawn
🤖 Agent: queued at 1b71694071. native_hand_drawn runs after the hand pass, and the render tests show the pass draws exactly as before when no mod hooks it. I merged main because the old base lacked the xtask test fix and verify would fail. With that merge, xtask mod-api --check and test_stamp_native_hooks pass, and so do clippy (render, client and client-lib) and the native_render tests on playhorizon.
2026-11-02 17:46:38 +01:00
Ludovic MarieandClaude Opus 5.5 f96c062b3a Merge main into #7405
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:46:18 +01:00
some-du6eandClaude Opus 5.5 06224fab3c Graphics settings: the screenshot build workflow leaves this pull request
Workflow changes go through the maintainer, in a pull request of their own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:45:46 -06:00
some-du6eandClaude Opus 5.5 34a1db9665 Merge main into the graphics settings screen
Main's new rows go on the pages: Find BlueMap and BlueMap Radius under
General → Distance, Frame Generation under Quality → Image, the seven
block-entity caches as Performance's Block Entities group after Culling,
and Translucent Batching under Advanced → Frames (greyed where the device
can't, hidden on macOS). The shortcut row keeps main's one translated
"Shortcut: %s" label and shows its key apart. With "Scroll wheel changes
settings" on, a hovered stepped value takes the wheel as CycleButton does;
sliders, tick boxes and a clicked or focused row leave it to the list.
Enter does nothing on a row greyed out while focused. split_ui and
split_profiles draw their titles with graphics_style::title. Translations
drop the keys of the three old screens; main's translations win where
both sides added a key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:45:38 -06:00
Ludovic Marie 4fb283702a Merge pull request #7392 from HrafnaValkyrja/fix/entity-hooks-review
🤖 Agent: queued at 78022a1ff9. The cancelled-crystal path returning ERROR_DUPLICATE_UUID-style failure and the spike crystals joining before their fire are both visible to mods, and the third change stops ids that are never announced (dragon parts, other levels' entities) from keeping the flush walking every tick, a small per-tick saving. Clippy and the native tests pass with main merged.
2026-11-02 17:44:58 +01:00
Ludovic Marie e0ce0deabb Merge pull request #7390 from HrafnaValkyrja/fix/trident-selector
🤖 Agent: queued at 478ae85c20. Tridents are now reachable by @e, /kill and /data like any other entity (EntityTypeTest.forClass(Entity.class)), and NoGravity is written only when set (Entity.saveWithoutId). Clippy and the trident tests pass with main merged.
2026-11-02 17:44:53 +01:00
Ludovic MarieandClaude Opus 5.5 1dce4c2bd3 Generation: empty the shared biome and carver caches when the pool idles
The overworld pipeline keeps the biomes of up to 16384 chunks (and the
carvers' biome lookups) until the cache is full. After a ramp to RD 32
that is ~33 MB that nothing reads again until the player moves on. When
every worker has been idle for 5 s, the streaming pool now empties them,
next to the per-worker caches it already released. Both caches are
already emptied whole at their limit, so a chunk generated afterwards
comes out the same.

horizon-ctl memory gains `server_generation`: the proto chunks the
server thread holds (bytes by part, sections by bits per entry), the
light views and the generator's caches, so the generation ring can be
measured without the alloc-census build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:44:12 +01:00
Ludovic MarieandClaude Opus 5.5 1b71694071 Merge main into #7358
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:42:35 +01:00
k3yhandClaude Opus 5.5 79ae7cfe19 /loot rolls loot_table entries
The items engine, which /loot rolls tables with, had no case for a
minecraft:loot_table entry, so a table that picks another table (the trial
chambers' reward tables, many data pack and mod tables) dropped nothing:
'/loot spawn ~ ~ ~ loot minecraft:chests/trial_chambers/reward' answered
'Dropped 0 items'. Chests were right: they roll through horizon_world::loot.

A chosen loot_table entry now rolls its table (by id or inline) in full and
applies its own functions to each stack (NestedLootTable); a table already
rolling further out adds nothing (LootContext.pushVisitedElement).

/loot also counted items where vanilla's CommandResponseTracker counts
stacks: two stacks of two returned 4 instead of 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:42:05 -05:00
Ludovic Marie a7621348a9 Merge pull request #7380 from MSedgeMC/fix/mob-leash-offsets
🤖 Agent: queued at a8892e3a87. Checked against the 26.3 bytecode: Pig, Rabbit and Strider use 0.6f × eye height, Parrot and Ocelot 0.5f, all with the bbWidth × 0.4 forward shift, and HappyGhast returns Vec3.ZERO. That's exactly the new branches, done in f32 like vanilla. Clippy and the lead tests pass with main merged.
2026-11-02 17:41:57 +01:00
levzzzandClaude Opus 5.5 7b77a5ec98 Merge main into modapi/entity-handles
Took main's generated Host files and regenerated the mod API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:41:52 +05:00
Ludovic MarieandClaude Opus 5.5 d821e95b87 Pose horse markings and slime outer cubes on the GPU
With improved transparency off, EntityBatch ignores the OIT_ENTITY tag:
an entityTranslucent layer (a horse's markings, a slime's outer cube) is a
plain draw with a tag, as the eyes render type's layers are (#7287). Such
mobs now take the GPU path then too, their jobs tagged as the CPU tags the
vertices. SkinModels::eyes becomes SkinModels::tagged (Renderer::skins_tagged).

The every-mob test gives the horse markings and expects the horse and the
slime posed with the tagged layers; new stress scene stress_markings (1000
marked horses and slimes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:39:02 +01:00
levzzzandClaude Opus 5.5 1e3cf30442 Screen lifecycle hooks: nothing to observe with no native mod
observe_screen cloned the open screen's title and menu every frame for
everyone; it now returns at once when no native mod is loaded (the list
is fixed at startup).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:38:40 +05:00
Ludovic MarieandClaude Opus 5.5 8fd758551d Name mob layers without allocating each frame
Layer's model and texture names and body()'s are Cow: fixed names are
borrowed, names built from parts (villager type, profession and level,
armour, farm animals' climates) are interned once per thread (bounded at
4096 names, owned past that). MobType::model_name gives the species' model
key without a copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:38:17 +01:00
[email protected] cf507c4996 Merge upstream/main 2026-11-02 17:37:16 +01:00
[email protected] 42ab8ca1dc Merge upstream/main 2026-11-02 17:36:58 +01:00
[email protected] 6807a02809 Merge upstream/main 2026-11-02 17:36:40 +01:00
huiandZCode AI agent 30e83372d9 Native mods: a mod's own ingredient kind matching at craft time
The load-time hook only names items. Matching a concrete stack - its
components, usually - is native_ingredient_test(kind, data, stack,
accept): the ingredient's test calls it with the kind and JSON the
load-time hook saw, the stack being matched and the verdict, already
true (the list named the item). Set accept to false to refuse the
stack (ICustomIngredient.test). The hook may be called many times a
craft; the answer must be the same for the same stack.

Ingredient carries what matching needs now: the kind and its JSON for
a mod's own kind, and NeoForge's shapes' parsed children - matching a
stack asks each child its own way (any for compound, all for
intersection, base-but-not-subtracted for difference), a mod's kind
among them answering through its hook, as on NeoForge. The flat item
list the book and the network copy see is unchanged. The furnace
index holds every recipe an item names now, not just the first: a
refused stack falls through to the next recipe whose test takes it.

Part of #5367.

Co-Authored-By: ZCode AI agent <[email protected]>
2026-11-02 17:36:33 +01:00
Ludovic Marie b09827e934 Merge pull request #7429 from MSedgeMC/chore/ledger2-rest-024
🤖 Agent: queued at f0bbdbec96. All eight rows now point at world_upgrader.rs's RegionStorageUpgrader methods instead of the old upgrade_regions; the 8 citations exist on main, and tryProcessOnePosition is rightly a port now that only changed chunks are written.
2026-11-02 17:33:44 +01:00
Ludovic Marie c695e9f3fa Merge pull request #7428 from MSedgeMC/chore/ledger2-rest-023
🤖 Agent: queued at 21686a6019. Checked: lan_chat.rs:66 kicks with disconnect.spam, so pointing the row at detect_rate_spam instead of the creative-drop throttle is right. TSV-only, citation exists.
2026-11-02 17:33:39 +01:00
Ludovic Marie b11e152501 Merge pull request #7426 from MSedgeMC/chore/ledger2-rest-019
🤖 Agent: queued at 8de70a30b4. Checked: datapack.rs:598 reads recipe, recipe_filter, loot_table, advancement and trade_set files, so the gap claiming only functions are read was wrong. TSV-only, citation exists.
2026-11-02 17:33:34 +01:00
Ludovic Marie 07ebc5e1ca Merge pull request #7421 from MSedgeMC/chore/audit-client2-016-click
🤖 Agent: queued at 0b1c2fbb0f. Checked: trading.rs:509 sets m.dragging from on_scroller on press, so the 'click doesn't start a drag' gap is stale. TSV-only, citation exists.
2026-11-02 17:33:29 +01:00
Ludovic Marie a593d71b82 Merge pull request #7420 from MSedgeMC/chore/audit-client2-018-gap
🤖 Agent: queued at 7cbd9eaa76. Dropping the stale gap is right now that the ghost-slot tooltip lives in ghost_stack (#7414's row); draw_book_panel still owns the page tooltip. TSV-only, citation exists.
2026-11-02 17:33:25 +01:00
Ludovic Marie 0c762af6b2 Merge pull request #7415 from MSedgeMC/chore/audit-client2-016
🤖 Agent: queued at 81b798b861. Moving from port to inline with the faces gap is right: player_list returns text lines only and draws no head sprites. TSV-only, citation exists on main.
2026-11-02 17:33:20 +01:00
Ludovic Marie e963d83272 Merge pull request #7414 from MSedgeMC/chore/audit-client2-018
🤖 Agent: queued at f915616c37. Checked: stations.rs:2879 asks ghost_stack before the real slot, so the tooltip comes from ghost_stack and pointing the row there is right. TSV-only, 4 columns, citation exists on main.
2026-11-02 17:33:15 +01:00
Loki f975439f55 Merge remote-tracking branch 'origin/main' into fix/7442-experience-orb-water-flow 2026-11-03 01:32:49 +09:00
LokiandAntigravity 8d83627776 entities: push experience orbs in flowing water (fixes #7442)
Co-Authored-By: Antigravity <[email protected]>
2026-11-03 01:32:45 +09:00
Florian ERNSTandCodex c54a594be1 List custom bossbars in Java HashMap order
Track reply traversal with the existing Identifier and JavaHashMap implementations, retaining session resize/removal history and rebuilding after load. Keep the existing bar store for packet publication and persistence, with focused and command-list regressions.

Co-Authored-By: Codex <[email protected]>
2026-11-02 17:31:23 +01:00
levzzzandClaude Opus 5.5 6df1c99bdb Merge main into #7305
Kept the hand pose hook beside main's item frame hook in native_hooks.rs
and regenerated the mod API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:28:20 +05:00
Stink-O 3fa60709e2 Merge branch 'main' of https://github.com/ludovic111/horizon into native/block-sprites
# Conflicts:
#	crates/horizon-client/src/game/mod_api_host_generated.rs
#	crates/horizon-client/src/mod_api.rs
#	crates/horizon-client/src/mod_api/generated_host.rs
2026-11-02 16:27:06 +00:00
Ludovic Marie 02c8607ee8 Merge pull request #7383 from socksprox/t3/centralize-worker-thread-count
🤖 Agent: queued at c09ee1f683. With every worker pool sized from one place, a pool can no longer pick its own count and oversubscribe the cores. The defaults keep each pool's previous size. I merged main because the old base lacked the xtask test fix and verify would fail. Clippy across render, client and client-lib plus the worker tests pass.
2026-11-02 17:25:56 +01:00
Ludovic Marie e4f8aa3d1d Merge pull request #7348 from 4404c/fix/fence-knot-click
🤖 Agent: queued at be3977105b. Sending a guest's Interact with a knot id to use_knot closes the gap the old comment named. Knot hits are now decided by the client's entity pick, not by re-deriving the knot box from the block hit point, which is how vanilla's LeashFenceKnotEntity.interact is reached. knot_at_id scans knot_ids only per Interact packet, not per tick. Clippy and the lead tests pass with main merged.
2026-11-02 17:25:52 +01:00
Ludovic Marie 5437851339 Merge pull request #7391 from elythepreciouscat/perf/particle-light-cache
🤖 Agent: queued at af73537958. The 64-slot cache lives only for one render call and compares the exact block before reusing a light value, so a collision just reads the light again: same light as before, with one lookup per block instead of one per particle in dense clouds. Clippy and the particle tests pass on playhorizon.
2026-11-02 17:25:47 +01:00
oxi-does-devandOpenCode GLM 5.3 81bea7acc5 Select a world with the middle mouse button
AbstractSelectionList.mouseClicked hands a click to the row under the
pointer whatever the button, and ObjectSelectionList.Entry accepts them
all. WorldSelectionList.setSelected then runs the selection callback that
SelectWorldScreen.updateButtonStatus reads, so a middle click on a row
selects the world and enables Play, Edit and Delete in vanilla 26.3,
without opening it.

Horizon's title only handled a left press, and the game's handler ignored
every other button over the title while the loading one handled the left
button alone. A middle click therefore did nothing and the footer stayed
disabled (#7384).

Title::select_world_at selects the row at the pointer without joining,
and both title mouse handlers send a middle press there. The left button
still joins, through its icon or a click on the selected row.

Co-Authored-By: OpenCode GLM 5.3 <[email protected]>
2026-11-02 19:25:45 +03:00
TheMrZZandCodex gpt-6.1-sol 3e4c4d7f89 Include simulated area-effect clouds in entity selectors
Expose live clouds through command candidates, identity and data adapters, tags and score holders, and removal. Extend the Java/Rust command corpus with default-fuse creepers, direct-cloud controls, data queries, filtering and lifecycle checks.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 17:22:05 +01:00
Ludovic MarieandClaude Opus 5.5 d518320301 launcher: the redesign's pixel sunset (scene.rs)
A port of the website's HorizonScene (horizon-site components/horizon-scene.tsx), as
Anthony S.'s launcher mockup draws it (wideSun x 0.72, scale 1.1, rise 0.1): same seeds,
random draws in the same order, the same colour sums, so a window of the same size shows
the same picture. Its golden test holds values computed by the website's own code.

The scene is its own GPUI view: a frame of it redraws nothing else (GPUI Fast copies the
launcher's pages from the last frame). The parts that never move are baked into images per
window size; stars, the meteor, clouds, the sun and fireflies are a few hundred quads.
Dark appearance only for now; Light keeps the dawn sky.

Images are straight alpha with a one-texel edge border: GPUI Fast blends sprites by their
alpha and samples its atlas linearly, so a stretched image without one shows a seam.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:21:34 +01:00
Ludovic MarieandClaude Opus 5.5 eb2b96b6b2 Draw framed maps without formatting or copying per map per frame
map_texture formatted the slot's sprite name (horizon/map{slot}) and
map_vertices cloned the map's decoration list for every map drawn each
frame (a lobby's wall of framed maps holds up to 256). The 256 slot names
are built once (hud::map_slot_sprite) and the decorations are borrowed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:21:04 +01:00
Ludovic Marie 1952b56c6c Merge pull request #7315 from Xmilon/native/camera-read
🤖 Agent: queued at 3f636a7936. I made two fixes. The camera snapshot is now built only when a native mod is loaded: it cost two matrix products, a projection and a String every frame for everyone. And view.is_some() didn't compile, since view is a Mat4; your later commit fixed it the same way. Merged with main: clippy, the native tests and xtask --check pass.
2026-11-02 17:21:02 +01:00
Ludovic Marie c968ca644e Merge pull request #7330 from Xmilon/native/fov-modifier-tooltip
🤖 Agent: queued at f132bcc2ba. valid_fov_modifier falling back to vanilla's base for NaN, infinity or out-of-range values keeps a broken mod from breaking the camera. Your FovPlayer stub fix passes all 19 xtask tests. Merged with main (native_hooks.rs conflicted with #7299's movement hook; I kept both), and clippy, the fov test and xtask --check pass.
2026-11-02 17:20:58 +01:00
Ludovic Marie a8d24a4681 Merge pull request #7326 from Xmilon/native/nametag-item-frame
🤖 Agent: queued at 237cebd62d. The item frame hook only runs for frames holding an item, and with no mod it is one dispatch over an empty slice, so frames cost the same for players without mods. You noted nothing was built: after merging main (the generated file conflicted, so I regenerated it), clippy, the native tests, xtask mod-api --check and test_stamp_native_hooks all pass on playhorizon.
2026-11-02 17:20:53 +01:00
Ludovic Marie 1db31d584c Merge pull request #7466 from horizon-rust/fix/flame-arrow-fire-7356
🤖 Agent: the arrow's fire now travels as Entity.DATA_SHARED_FLAGS_ID bit 0x01, which vanilla's dedicated server also pairs a burning arrow with (server_entities transcript: pairing 0=1); the client stores it without counting it down, as it already does for mobs.
2026-11-02 17:20:49 +01:00
HrafnaValkyrja 6c8cff2630 Merge remote-tracking branch 'origin/main' into refactor/entity-walk-loads 2026-11-02 11:20:35 -05:00
arismoko ef3d35dd97 Retry siege setup after a canceled attempt 2026-11-02 16:16:37 +00:00
Ludovic MarieandClaude Opus 5.5 f132bcc2ba Merge #7330's head into the main merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:15:14 +01:00
Ludovic MarieandClaude Opus 5.5 237cebd62d Merge #7326's head into the main merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:15:14 +01:00
k3yhandClaude Opus 5.5 a65b941358 Death loot fixture: pass the level random die takes now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:15:13 -05:00
Ludovic Marie 3f636a7936 Merge #7315's head into the main merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-02 17:15:13 +01:00
Ludovic MarieandClaude Opus 5.5 ee65238495 Merge main into #7305
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:13:18 +01:00
Florian ERNSTandCodex 72817d326e Sort forceload query replies by signed packed chunk IDs
Match ForceLoadCommand LongStream.sorted using the existing ChunkPos packing. Extend the command corpus with mixed-sign list order and empty, singleton, point-query, and removal controls.

Co-Authored-By: Codex <[email protected]>
2026-11-02 17:12:16 +01:00
Ludovic MarieandClaude Opus 5.5 b2799a2e4c Merge main into #7315
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:12:14 +01:00
Ludovic MarieandClaude Opus 5.5 ab1a992973 Merge main into #7330
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:11:27 +01:00
oxi-does-devandOpenCode GLM 5.3 cd18f6c74f Clear a container's keyboard focus when it closes
Gui.setScreen runs Screen.clearFocus on the screen it takes down, but
Horizon kept the keyboard focus in PadMenu after an inventory closed. The
inventory key bound to Tab moves the focus on Tab and then closes the
screen in the same press, so the screen it moved on stayed focused: a
button was left highlighted after the inventory went away. The remote
close path (single player runs on the server thread, remote.rs) never
reached close_container and cleared nothing.

Clear the focus where the container teardown starts: close_container, the
remote menu teardown (remote_close_local), and the Creative screen's two
close arms. The focus, the click origin and the slider edit go, as
ComponentPath.applyFocus(false) does.

Co-Authored-By: OpenCode GLM 5.3 <[email protected]>
2026-11-02 19:09:55 +03:00
Arslan ArmanulyandClaude Opus 5.5 ec8cebc6a8 Shader packs: scale.<program> draws a composite-style pass into part of its buffers
OptiFine's "Composite render scale", `scale.<program>=<scale> [offsetX
offsetY]`, was not read: such a program drew its whole buffers. With a
probe pack of our own, Iris 1.11.7 draws a composite program (probed) only
into the viewport (offsetX * width, offsetY * height, scale * width,
scale * height) from GL's bottom-left corner, offsets included; its
texture coordinates span that viewport, and its buffers keep what they
held outside it. Deferred programs follow OptiFine's doc; prepare programs
are treated the same (unverified). Horizon now does the same: the pass
loads its attachments instead of clearing them and sets that viewport.

Found while investigating #6812.

Fixes #7453

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:08:46 +05:00
Arslan ArmanulyandClaude Opus 5.5 9d20f0cc84 Shader packs: a custom uniform's smooth reads a leading number as its id, as in Iris
Iris 1.11.7 reads the first argument of `smooth` as the call's id when it
is written as a number and more arguments follow: `smooth(1, 5, f)` is 5
and `smooth(5, f)` is f (observed with a probe pack of our own). Any other
first argument is the value (`smooth(1.5 * 2, 6.5)` is 3). Horizon read the
first of three arguments as the value. Iris drops a uniform whose `smooth`
has four arguments and no number first, or five; Horizon now leaves those
out too, so they read 0.

Found while investigating #6812.

Fixes #7452

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:08:46 +05:00
MSedgeMC 97712ecdd5 use_item_on: doors, trapdoors and fence gates opened by hand
DoorBlock, TrapDoorBlock and FenceGateBlock useWithoutItem turn the block to
its other open when the material's BlockSetType can be opened by hand, play
the open or close sound and send block_open or block_close; a fence gate closed
across the player turns to face them first, drawing its sound pitch from the
level random; and an iron one passes, so the click falls through to what the
hand holds. The behaviour is on main; no harness case clicked a door, a
trapdoor or a fence gate.
2026-11-02 16:07:15 +00:00
testatanucleare13-createandCopilot cf3d1e1187 Fix loot mine for content mod blocks
Co-authored-by: Copilot <[email protected]>
2026-11-02 16:06:12 +00:00
MSedgeMC 713582bbdd Test that a cave spider, husk and wither skeleton effect reaches a mob
doHurtTarget against mob 2 must carry poison 140 (normal), hunger 140
(effective difficulty 1.5) and wither 200 with the hit; without the fix the
hit is a plain AttackMob and the mob keeps no effect (0 ticks). On easy the
cave spider's timer is 0, so the hit stays plain.
2026-11-02 16:05:26 +00:00
MSedgeMC e8191c9e8f A mob's hit effect reaches the mob it hit
CaveSpider, Husk and WitherSkeleton.doHurtTarget add their effect to the
LivingEntity the hit landed on, not to the player alone: the bytecode tests
`target instanceof LivingEntity`. Horizon guarded all three with
`who == Who::Player` and sent a mob target a plain AttackMob, so a cave
spider's bite, a husk's bare-handed blow and a wither skeleton's hit gave
the damage and no effect at all.

A hit on a mob now goes out as MobEvent::AttackMobWithEffect, which the game
applies through Mob::add_effect once mob_hits_mob says the hit landed, the
same rule AttackPlayerWithEffect follows.
2026-11-02 16:05:26 +00:00
Florian ERNSTandCodex 637d236297 Honor configured creeper fuse across timing, fall swelling, and rendering
Load and save the local max-swell value instead of only preserving raw Fuse NBT. Keep the default 30 and existing remote synchronization, and add timing, serialization, fall, and rendering regressions plus command survival checks.

Co-Authored-By: Codex <[email protected]>
2026-11-02 17:04:02 +01:00
k3yhandClaude Opus 5.5 47a37f3bab Mobs drop their DeathLootTable
Mob.readAdditionalSaveData reads DeathLootTable and DeathLootTableSeed, and
getLootTable rolls that table instead of the type's. Horizon never read them:
a summoned or spawner mob with its own table (a data pack's or a mod's boss)
dropped its type's loot. They are now read, saved back, and rolled at death.

LootTable.getRandomItems also rolls a table with no random_sequence with the
level's random (or the seed's own, when set); Horizon dropped nothing for
such a table, which is most data pack and mod tables.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:03:53 -05:00
Ludovic Marie f696adc899 Merge pull request #6650 from xMelonekMaX/fix/dialog-container-close
🤖 Agent: queued at 2d257a0bdd. The new dialog_close check passes against a real 26.3 server on playhorizon, through the local frame proxy injecting ContainerClose (0x11). All 10 sub-checks pass: waiting screen, parents, warning, lectern, replacement dialog, key release, death. book_menu still passes all 4. Main merged in (2d257a0bd).
2026-11-02 17:02:31 +01:00
Ludovic MarieandClaude Opus 5.5 9962001d0a Pose each mob's model once per frame, not once per layer
A villager draws its model four times (body, type, profession, level), a
spider twice (body, eyes), and each draw set up the poses again, walked the
part tree into fresh maps and recomputed every part's matrices.

- Layers over the body's own model copy the body's poses instead of running
  setupAnim again (no custom model on either).
- Poses carry a version (new at each change, kept by copies); Skin::draw
  reuses the last walk's part matrices when the model, root matrix and
  version repeat, pushing the same jobs with the new paint.
- The body's and layers' Poses come from a per-thread pool, so their maps
  keep their capacity instead of growing and rehashing per mob.
- The spider's legs use static part names (16 format! per draw before).
- MobType.layers is an FxHashMap; humanoid_armor returns before body() when
  the mob wears nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:02:31 +01:00
Ludovic MarieandClaude Opus 5.5 c34132a0aa Merge main into #7326
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:00:37 +01:00
Ludovic Marie c5bf8999ae Merge pull request #7329 from MSedgeMC/chore/vol-c-rest-008
🤖 Agent: queued at 1a6ccac6de. I merged main in: #7327 had set TypedDataComponent.encodeValue to a port in the same file, so I kept main's row and your extended notes on the other four rows. pois_in_range and dye_color_mix_in_recipes resolve on main. TSV only.
2026-11-02 17:00:08 +01:00
Ludovic Marie a27e9b4560 Merge pull request #7433 from horizon-rust/perf/teleport-sweep
🤖 Agent: the clear-everything path in forget_columns relies on pending sections only ever naming held columns (compile_started is inserted only for loaded columns, stale mesh results are dropped by the chunks check), so clearing when no column is left loses nothing.
2026-11-02 16:58:06 +01:00
Ludovic Marie a8b77cef51 Merge pull request #7300 from Joansitoh/fix/2783-jockeys-by-spawn-reason
🤖 Agent: queued at b3c0ba9c1e. Main merged in for #7337's xtask test fix. Same review as before: only natural spawns roll the jockey mounts, and the mount tests pass.
2026-11-02 16:58:00 +01:00
Ludovic Marie e0a437326a Merge pull request #7369 from MSedgeMC/chore/mapgap-rest-022
🤖 Agent: queued at a129fa4b6b. Main merged in so verify picks up #7337's xtask test fix (the red was main's xtask test, not this PR). Citations re-checked earlier against main. TSV only.
2026-11-02 16:57:54 +01:00
Ludovic Marie 39308bec17 Merge pull request #7368 from MSedgeMC/chore/mapgap-rest-003
🤖 Agent: queued at 72336fa383. Main merged in so verify picks up #7337's xtask test fix (the red was main's xtask test, not this PR). Citations re-checked earlier against main. TSV only.
2026-11-02 16:57:49 +01:00
Ludovic Marie 1b6aad054f Merge pull request #7367 from MSedgeMC/chore/vol-e-rest-014
🤖 Agent: queued at 186ed92f7b. Main merged in so verify picks up #7337's xtask test fix (the red was main's xtask test, not this PR). Citations re-checked earlier against main. TSV only.
2026-11-02 16:57:44 +01:00
Ludovic Marie e1c9d7d083 Merge pull request #7350 from MSedgeMC/chore/vol-e-rest-020
🤖 Agent: queued at ed08c0d9c6. Main merged in so verify picks up #7337's xtask test fix (the red was main's xtask test, not this PR). Citations re-checked earlier against main. TSV only.
2026-11-02 16:57:39 +01:00
Ludovic Marie 97fc01a618 Merge pull request #7349 from MSedgeMC/chore/vol-e-rest-018
🤖 Agent: queued at dc691ee6a9. Main merged in so verify picks up #7337's xtask test fix (the red was main's xtask test, not this PR). Citations re-checked earlier against main. TSV only.
2026-11-02 16:57:34 +01:00
Ludovic Marie 0fe3bd217d Merge pull request #7339 from MSedgeMC/chore/vol-c-game-010
🤖 Agent: queued at b1a1b582c6. Main merged in so verify picks up #7337's xtask test fix (the red was main's xtask test, not this PR). Citations re-checked earlier against main. TSV only.
2026-11-02 16:57:30 +01:00
testatanucleare13-createandCopilot 91a96f3b4b CI: copy missing language assets into prepared resources
Co-authored-by: Copilot <[email protected]>
2026-11-02 15:56:42 +00:00
Ludovic Marie 1a6ccac6de Merge main into #7329
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/port_map/rest-008.tsv
2026-11-02 16:55:38 +01:00
[email protected]andGPT-6.1 Sol a8cdcce9d6 fix: preserve name tag fast paths without native mods
Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-02 16:53:03 +01:00
Ludovic MarieandClaude Opus 5.5 a129fa4b6b Merge main into #7369
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:53:01 +01:00
Ludovic MarieandClaude Opus 5.5 72336fa383 Merge main into #7368
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:52:22 +01:00
MSedgeMC f0bbdbec96 Port map: rest-024 rows the tree answers 2026-11-02 15:52:01 +00:00
MSedgeMC 21686a6019 Port map: rest-023 rows the tree answers 2026-11-02 15:51:52 +00:00
Florian ERNSTandCodex 61c54f727b Name armor stands through named name-tag interactions
Apply the custom-name component to live armor stands, consume a tag even in creative, and retain PASS for unnamed tags. Cover component and dead-target behavior locally and add host/guest inventory and name checks to the LAN frame scenario.

Co-Authored-By: Codex <[email protected]>
2026-11-02 16:51:51 +01:00
MSedgeMC 8de70a30b4 Port map: rest-019 rows the tree answers 2026-11-02 15:51:37 +00:00
levzzzandClaude Opus 5.5 b21901f676 Native mods: entity and level handles, and an entity's position, type, UUID and level (#7387)
EntityRef (the world's open epoch and an entity id) and LevelRef (a
dimension), made from an event's id by Host::entity and Host::level. A
helper whose catalog entry names a receiver also becomes a method of that
handle, generated beside Host: entity.position(host) calls
host.entity_position(entity). First reads: position, block position,
rotation, type, UUID and level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 20:50:56 +05:00
Ludovic Marie c1f5855e8d Merge pull request #7338 from MSedgeMC/chore/vol-c-rest-017
🤖 Agent: queued at af03b845d1. Main merged in for #7337's xtask test fix. Watchdog::run is the port; /setidletimeout stays a real gap. TSV only.
2026-11-02 16:49:43 +01:00
Ludovic Marie 0b08331746 Merge pull request #7325 from MSedgeMC/chore/vol-c-rest-001
🤖 Agent: queued at 0976c2b8a6. Main merged in for #7337's xtask test fix. The 15 math.rs citations still resolve. TSV only.
2026-11-02 16:49:34 +01:00
Ludovic Marie be1a816f4a Merge pull request #7316 from some-du6e/fix/bucket-hook-beside
🤖 Agent: queued at a3740ffa49. Main merged in for #7337's xtask test fix. Same review as before: both hooks now hear hit.beside(), which closes the claim bypass from #6783.
2026-11-02 16:49:27 +01:00
MSedgeMC 69808d0fc4 use_item_on: a clicked dragon egg teleports
DragonEggBlock.useWithoutItem teleports the egg instead of using what the hand
holds: up to 1000 random offsets within 16/8/16, the first air block inside the
build height with a block under it gets level event 2015 with the packed
difference, the egg moves there and the clicked one is removed. The behaviour is
on main; no harness case mentioned dragon eggs.
2026-11-02 15:49:24 +00:00
Ludovic Marie cd1d56c5b9 Merge pull request #7336 from MSedgeMC/chore/vol-c-rest-004
🤖 Agent: queued at 6047ef2d48. Main merged in so verify picks up #7337's xtask test fix (the red was main's, not this PR's). Options::parse as the argument loop checks out. TSV only.
2026-11-02 16:49:21 +01:00
Ludovic Marie a90c08d4c3 Merge pull request #7381 from horizon-rust/fix/old-cpu-mimalloc-popcnt
🤖 Agent: the only hand-written change in the vendored mimalloc is mi_popcount on MSVC x86/x64 taking the portable count (no POPCNT). The real proof is the first nightly after this merge: game-build's new 'Baseline x86-64 CPUs only' step must pass on the cl.exe-built horizon-client.exe.
2026-11-02 16:49:16 +01:00
Ludovic MarieandClaude Opus 5.5 186ed92f7b Merge main into #7367
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:49:14 +01:00
TheMrZZandCodex gpt-6.1-sol 2f32ceb119 Clarify the vanilla method mapped by command state normalization
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 16:48:38 +01:00
Ludovic MarieandClaude Opus 5.5 ed08c0d9c6 Merge main into #7350
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:47:37 +01:00
Ludovic MarieandClaude Opus 5.5 b3c0ba9c1e Merge main into #7300
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:46:46 +01:00
Ludovic MarieandClaude Opus 5.5 dc691ee6a9 Merge main into #7349
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:45:36 +01:00
NazaandClaude Opus 5.5 be3977105b Merge main: the mod API placeholder test is fixed there
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:44:49 -03:00
MSedgeMC 0b1c2fbb0f Port map: the merchant scroller's press starts the drag
MerchantScreen.mouseClicked said clicking the scroller does not start a
drag. Game::merchant_click sets dragging inside the 139-px track past
seven offers and Game::merchant_drag follows the pointer, so the note
now describes that.
2026-11-02 15:44:34 +00:00
MSedgeMC 7cbd9eaa76 Port map: the ghost slot's tooltip is drawn, drop the stale gap
RecipeBookComponent.extractTooltip still said there is no tooltip for
ghost slots. Game::ghost_stack answers GhostSlots.extractTooltip and
draw_slot_tooltip asks it before the slot's own stack, so the note now
says where the ghost tooltip comes from.
2026-11-02 15:44:27 +00:00
Ludovic MarieandClaude Opus 5.5 b1a1b582c6 Merge main into #7339
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:41:47 +01:00
Florian ERNSTandCodex 69bee308c2 Clamp mob max-health values without altering raw bases or health
Use the existing mob attribute base storage and computed-value path for max-health command reads, writes, and reset. Add command parity cases for bounds, raw getters, health preservation, and survival.

Co-Authored-By: Codex <[email protected]>
2026-11-02 16:40:38 +01:00
Ludovic MarieandClaude Opus 5.5 af03b845d1 Merge main into #7338
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:37:46 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 8162a02e53 Merge origin/main into refactor/entity-walk-loads
Spectral arrows, llama spit and shulker bullets load through Projectile::load_nbt.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 10:37:33 -05:00
MSedgeMC 81b798b861 Port map: ClientActivePlayersTooltip.extractImage draws no faces
The row claimed a port, but vanilla's extractImage draws a 10x10 player
face per sample entry and the name beside it. Horizon's tooltip only
has the names: player_list builds the name lines and ui::tooltip draws
them as text, and no face is drawn for them.

The row is now inline, with the missing image recorded as a player gap.
2026-11-02 15:36:59 +00:00
MSedgeMC f915616c37 Port map: point GhostSlots.extractTooltip at ghost_stack
The row cited Game::draw_ghost, which only builds and paints the ghosts
(GhostSlots.extractRenderState) and draws no tooltip. The stack the
vanilla method returns is Game::ghost_stack, which draw_slot_tooltip
asks before the slot's own stack.
2026-11-02 15:36:51 +00:00
Ludovic MarieandClaude Opus 5.5 0976c2b8a6 Merge main into #7325
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:36:46 +01:00
Ludovic Marie 0cf1b3d209 Merge pull request #7409 from horizon-rust/perf/block-entity-scans
🤖 Agent: the mod block-entity pass now runs only when BlockEntities::has_mods (the index mods_near already trusts); without any it drew nothing. The frustum scan reuses visible_block_entities' no-fade shortcut. Small gain (0.4 % main thread in the RD 32 village), exact.
2026-11-02 16:36:17 +01:00
Ludovic Marie 34e19eb7ea Merge pull request #7317 from MSedgeMC/fix/horse-llama-variant-component
🤖 Agent: queued at 02de4be732. Keeping the markings in extra.variant2 while the component sets only the coat matches Horse.setVariant's 'id & 0xFF | getTypeVariant() & -256', and the test checks the packed Variant int (6 | 2 << 8) survives a save. Trader llamas inherit the llama arm, as in vanilla. Merged with main: clippy clean, and the horse tests pass with real 26.3 data.
2026-11-02 16:36:13 +01:00
Ludovic Marie 5472b35fc4 Merge pull request #7403 from horizon-rust/ci/claim-refuse-trackers
🤖 Agent: checked that a refused /claim on a tracker makes no API call (the test asserts no assign), so a labelled issue never gets a stray assignee.
2026-11-02 16:36:04 +01:00
LokiandAntigravity 3042ed59ab minecarts: verify chest minecart container interaction in lan_e2e (fixes #5812)
Co-Authored-By: Antigravity <[email protected]>
2026-11-03 00:34:37 +09:00
Ludovic MarieandClaude Opus 5.5 a3740ffa49 Merge main into #7316
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:33:49 +01:00
[email protected] 7b55eac3fb Native mods: the screen lifecycle hooks sit in the hook points section the catalog test reads 2026-11-02 16:31:56 +01:00
Ludovic MarieandClaude Opus 5.5 6047ef2d48 Merge main into #7336
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:29:24 +01:00
Ludovic Marie 0ca03e3887 Merge remote-tracking branch 'origin/main' into fix/old-cpu-mimalloc-popcnt 2026-11-02 16:29:15 +01:00
yosifdheef 616ddd3563 Merge remote-tracking branch 'upstream/main' into fix/sugarcane-water 2026-11-02 18:27:57 +03:00
gorkandOpenCode 744aebf303 Draw the sunrise fan as geometry, not a test in every sky pixel
The sunrise/sunset fan was still a per-pixel Moller-Trumbore test over up to
six of its sixteen triangles in every sky pixel (#3143 measured 1.8 ms at
1080p on an Intel UHD 730 after the normals moved out of the shader; #3993
cut the scan but kept it per pixel). This rasterizes the same camera-space
fan as sixteen triangles: the apex vertex carries weight 1 and the rim 0,
so the rasterizer interpolates the barycentric apex weight the shader used
to compute. The fragment shader emits the sunrise colour with that weight
as alpha, blended over the fan-free sky; the destination alpha is preserved
so HDR is unchanged.

The fan's clip positions are projected once per frame with the sky's
unjittered, unposed view (the view `fs_sky`'s `view_ray` uses), and the
960-byte buffer goes through the existing staging belt. Shader packs and the
water mirror keep the per-pixel path.

On lavapipe at 2176x1224, render distance 6, time 13000 and an 8 s fly, the
GPU pass went from 44.80 to 39.56 ms and p50 frame time from 36.43 to 32.61
ms (FPS 22.0 to 24.8). The RX 9070 XT is neutral at this scene, as expected:
its fan cost is small, but the saved work scales with sky pixels on the
iGPUs #6887 reports.

Part of #6887.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash Free) <[email protected]>
2026-11-02 16:27:12 +01:00
MSedgeMC f395135ea5 Hatch a ghastling with its own setup, not an adult's
DriedGhastBlock.spawnGhastling calls setBaby(true) before addFreshEntity.
AgeableMob.setBaby -> setAge crosses the age boundary there, so
HappyGhast.ageBoundaryReached runs babyGhastSetup while the mob is still
being hatched: its box, its flying navigation and its brain are a
ghastling's before it joins the level.

Horizon's hatch path wrote extra.age directly after spawn_mob had already
registered the adult's goals (HappyGhastFloat, the tempt goal,
FloatAround) with a ground navigation and the brain stopped, so the
ghastling spent its first tick flying adult goals and only its first
aiStep (ensure_setup) corrected that. age_boundary_reached also had no
HappyGhast arm, so the crossing ran nothing for it at all.

The hatch now goes through finalize::set_age, which dispatches the
crossing to happy_ghast::ensure_setup like the other ageable species'
ageBoundaryReached arms.

The port_map row for HappyGhast.ageBoundaryReached named ensure_setup
without the dispatch that reaches it; it now points at the dispatch.
2026-11-02 15:24:16 +00:00
k3yhandClaude Opus 5.5 a54a0a733d Commands find content mods' loot tables
/loot ... loot <id> checks the id against the loot tables listed from the
resources root, which read only data/minecraft/loot_table: a content mod's
tables (data/<ns>/loot_table) generated and filled chests but the command
answered "Can't find element". List the tables of every namespace, on the
disk and in a prepared root's data file, as the structure templates are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:23:55 -05:00
Ludovic MarieandClaude Opus 5.5 00a93a176f claim: refuse trackers and proposals
ari asked for a way to mark issues nobody should claim whole. Issues
labelled tracker, proposal or not claimable now get a one-line refusal
to /claim pointing at the issues they list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:23:31 +01:00
Ludovic MarieandClaude Opus 5.5 2e540fe373 launcher: GPUI Fast 0.1.13
Bump gpui-fast and gpui-fast-platform to 0.1.13 (Zed decd2399b1: UAX #14
wrapping, cached-view opacity fixes, executor-clocked animations, macOS
frame pacing, Windows renderer recovery). Its Apple renderer needs
macOS 12, so Horizon.app's minimum goes from 11.0 to 12.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:22:56 +01:00
Akirabane c7a4d4bc00 Merge remote-tracking branch 'origin/main' into fix/7115-console-effect-sync 2026-11-02 16:22:39 +01:00
[email protected] 1d07a530d2 xtask: the generated-code stub knows FovPlayer 2026-11-02 16:22:37 +01:00
Ludovic MarieandClaude Opus 5.5 d1ebc01827 Skip two per-frame block entity scans that find nothing
Every frame, the mod block entity pass walked every loaded block entity
(every resident section, at any distance) and asked each whether a
vanilla renderer draws it, to find the mods' among them: furnaces,
hoppers, barrels and every other unrendered one went through it, in a
world with no mod block entity at all. It now runs only when the world
holds one (`BlockEntities::has_mods`, the index `mods_near` and level
access already rely on); without any, it found none.

`visible_block_entities_in_frustum` looked up each section's upload
time and measured its age on every frame, even with no section fading
in. It now takes the shortcut `visible_block_entities` already has:
when no section was uploaded within the fade, every section is whole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:20:15 +01:00
Ludovic MarieandClaude Opus 5.5 f70a3de53f Merge main into #7358
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:20:01 +01:00
Ludovic Marie 7504abf07f Merge pull request #7366 from MSedgeMC/chore/mapgap-game-002
🤖 Agent: queued at efa9d63169. Every port/inline/home citation resolves on main (checked: hub.rs::guest_body). 1 rows changed, all four-column. TSV only.
2026-11-02 16:19:56 +01:00
ElyTheCat af73537958 Merge remote-tracking branch 'origin/main' into perf/particle-light-cache-main 2026-11-02 18:49:48 +03:30
Ludovic Marie 36256545f8 Merge remote-tracking branch 'origin/main' into t3/flame-arrow-fire-overlay 2026-11-02 16:19:26 +01:00
Ludovic MarieandClaude Opus 5.5 66a18454ca lan_e2e flame-arrows: give the arrow after the bow, sample after the draw
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:19:26 +01:00
MSedgeMC 7da932e807 Test a sitting tamed cat's and wolf's search for a mate
`a_sitting_tame_mob_still_looks_for_a_mate` runs `Breed::can_use` over five
pairs of tamed cats and wolves: a sitting cat pairs with a sitting or a
standing partner, a sitting wolf pairs with a standing one, and neither
starts on a partner that is sitting.

The mobs come from the crate's own synthetic cat and wolf types, so the
cases run without the extracted vanilla data; only the world the goal looks
in needs it.
2026-11-02 15:17:10 +00:00
MSedgeMC 41cc4874e5 A tamed cat or wolf ordered to sit still looks for a mate
`Cat.canMate` needs both cats tamed and in love, and `Wolf.canMate` the same
for two wolves, refusing only a partner that `isInSittingPose()`: the sitting
pose sits on the partner side, and only for a wolf. `Breed::can_use` refused
any tamed cat or wolf that was sitting as the one looking for a mate, so a
cat left sitting (the state a tamed cat spends most of its time in) never
bred, and a sitting wolf never walked off to a standing partner.

The partner filter already had the rule right (`!(species == Species::Wolf &&
s.sitting)`), so this is the seeker's own check losing the pose it does not
have in vanilla.

Corrects the `Cat.canMate` row of `tools/port_map/game-011.tsv`.
2026-11-02 15:17:10 +00:00
Ludovic MarieandClaude Opus 5.5 c09ee1f683 Merge main into #7383
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:15:36 +01:00
Ludovic Marie 9a6f7dd5a1 Merge pull request #7382 from MSedgeMC/chore/vol-e-rest-015
🤖 Agent: queued at 7b0a0efc7d. Every port/inline/home citation resolves on main (checked: extra.rs::Game::report_resolved_profile, vanilla.rs::fill_modes, commands.rs::Game::cmd_locate_poi). 8 rows changed, all four-column. TSV only.
2026-11-02 16:13:58 +01:00
Ludovic Marie c19ebdaf3b Merge pull request #7375 from MSedgeMC/chore/vol-e-rest-025
🤖 Agent: queued at 30d23de3b3. Every port/inline/home citation resolves on main (checked: pistons.rs::Sim::moving_piston_tick, pistons.rs::Sim::moving_piston_final_tick, piston_push.rs::extended_progress). 10 rows changed, all four-column. TSV only.
2026-11-02 16:13:51 +01:00
Ludovic Marie 04c3249fb8 Merge pull request #7374 from MSedgeMC/chore/stale1-client-004
🤖 Agent: queued at a345219dad. Every port/inline/home citation resolves on main (checked: game.rs::falling_block_visible). 1 rows changed, all four-column. TSV only.
2026-11-02 16:13:45 +01:00
Ludovic Marie d751957264 Merge pull request #7373 from MSedgeMC/chore/vol-e-rest-016
🤖 Agent: queued at 985fd6e8f0. Every port/inline/home citation resolves on main (checked: world_cmds.rs::Game::spread_spawn_y, world_cmds.rs::Game::spread_safe, world_cmds.rs::Game::cmd_stopsound). 4 rows changed, all four-column. TSV only.
2026-11-02 16:13:38 +01:00
Ludovic Marie a09a32dfa6 Merge pull request #7372 from MSedgeMC/chore/stale1-client-003
🤖 Agent: queued at b15ee4a662. Every port/inline/home citation resolves on main (checked: remote.rs::Game::remote_minecart_lerp). 1 rows changed, all four-column. TSV only.
2026-11-02 16:13:32 +01:00
Ludovic Marie 1b7df05643 Merge pull request #7365 from MSedgeMC/chore/stale2-client-008
🤖 Agent: queued at 984b742830. Every port/inline/home citation resolves on main (checked: test_instances.rs::Game::test_instance_marker_geometry). 1 rows changed, all four-column. TSV only.
2026-11-02 16:11:34 +01:00
Ludovic Marie 97ef1bdbcc Merge pull request #7364 from MSedgeMC/chore/mapgap-classes-07
🤖 Agent: queued at 30b37d8748. Every port/inline/home citation resolves on main (checked: update.rs::Sim::update_neighbors_at_except_oriented). 1 rows changed, all four-column. TSV only.
2026-11-02 16:11:28 +01:00
Ludovic Marie f95e22cae1 Merge pull request #7363 from MSedgeMC/chore/mapgap-game-009
🤖 Agent: queued at 0a13a0d2e2. Every port/inline/home citation resolves on main (checked: aquatic.rs, goals.rs, dolphin.rs). 6 rows changed, all four-column. TSV only.
2026-11-02 16:11:21 +01:00
Ludovic Marie 8fd704f533 Merge pull request #7362 from MSedgeMC/chore/stale1-game2-016
🤖 Agent: queued at 838bf539a7. Every port/inline/home citation resolves on main (checked: containers.rs::locked_message). 1 rows changed, all four-column. TSV only.
2026-11-02 16:11:16 +01:00
Ludovic Marie 3557917a72 Merge pull request #7361 from MSedgeMC/chore/mapgap-classes-05
🤖 Agent: queued at 5a6990c676. Every port/inline/home citation resolves on main (checked: blending.rs). 1 rows changed, all four-column. TSV only.
2026-11-02 16:11:09 +01:00
Ludovic Marie 60c8c77a6c Merge pull request #7359 from MSedgeMC/chore/stale1-game2-010
🤖 Agent: queued at 665ff9893f. Every port/inline/home citation resolves on main (checked: projectiles.rs::clip_including_border). 1 rows changed, all four-column. TSV only.
2026-11-02 16:11:03 +01:00
Ludovic Marie f8f40f1b7c Merge pull request #7357 from MSedgeMC/chore/vol-e-rest-024
🤖 Agent: queued at ec325b8357. Every port/inline/home citation resolves on main (checked: world_upgrader.rs::status_key, world_upgrader.rs::status_key, world_upgrader.rs::verify_chunk_pos). 6 rows changed, all four-column. TSV only.
2026-11-02 16:10:57 +01:00
ThatCakeIsAPie 6a6ef2de28 Parity: a biome data-pack fixture, and the chunk oracle uses a pack's level stem 2026-11-02 09:10:21 -06:00
Akirabane 112c4e4906 Merge remote-tracking branch 'origin/main' into fix/7115-console-effect-sync 2026-11-02 16:10:06 +01:00
AkirabaneandClaude Opus 5.5 947f63bb12 Test that an effect synced after its command is sent once at 600
A hub with one guest: an effect of 30 s synced before its first tick is
sent at 600 and not again as the next ticks count it down
(LivingEntity.tickEffects refreshes only at a multiple of 600 left); synced
after one tick, it goes out at 599, #7115's transcript.

Refs #7115

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:10:04 +01:00
AkirabaneandClaude Opus 5.5 29fe5ed4db Send an effect a console command gives at its full duration
A dedicated server's console now syncs the players' effects right after
its command, as a player's own commands already do (run_guest_command).
Since #6404 (354e2e632) the guests tick before the hub syncs, so an effect
the console gave was counted down once before it was sent:
`/effect give Bob minecraft:speed 30 1` reached Bob as 599 ticks, not 600.

Vanilla runs the console after the players' tick
(DedicatedServer.tickConnection: MinecraftServer.tickConnection, then
handleConsoleInputs), and ServerPlayer.onEffectAdded sends
ClientboundUpdateMobEffectPacket at once. The sync sits in console_command,
so the stdin console, RCON (rcon_line) and the control socket's `console`
request all get it. #6404's tick order is unchanged.

Fixes #7115

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:10:03 +01:00
Ludovic Marie bb22b56b0b Merge pull request #7351 from MSedgeMC/chore/stale1-game2-007
🤖 Agent: queued at 8af100b198. Supersedes #7343 and also repairs row 21's dangling boats.rs::Game::tick_riding cell; all 5 citations resolve on main. TSV only.
2026-11-02 16:09:11 +01:00
Ludovic Marie 374bf93bfe Merge pull request #7346 from MSedgeMC/chore/vol-e-rest-013
🤖 Agent: queued at da13f6f606. The new head adds citations, and all 7 resolve on main. The resolveAgainst follow-up (no '..' normalisation check in folder-backed pack readers) still stands. TSV only.
2026-11-02 16:09:05 +01:00
Ludovic Marie d69462e466 Merge pull request #7328 from masa-masa-0314/fix/launcher-settings-save-errors
🤖 Agent: queued at 64faa0482a. Same review as before (the prune now waits for a successful save). I merged main in so verify picks up #7337's xtask test fix; the earlier red was main's xtask test, not the launcher.
2026-11-02 16:09:00 +01:00
Ludovic Marie 59b42d97c0 Merge pull request #7332 from MSedgeMC/chore/vol-d-game2-002
🤖 Agent: queued at f620fed9a7. remove_framed_map is the port, not a tick. Main merged in for #7337's xtask test fix. TSV only.
2026-11-02 16:08:55 +01:00
Ludovic Marie 72ff7fc980 Merge pull request #7327 from MSedgeMC/chore/vol-d-rest-008
🤖 Agent: queued at 3b40418730. TypedDataComponent.encodeValue as hash.rs Component::code checks out. Main merged in so verify picks up #7337's xtask test fix (the earlier red was main's, not this PR's). TSV only.
2026-11-02 16:08:49 +01:00
Ludovic MarieandClaude Opus 5.5 360725a0ee perf: a far teleport's forgotten columns leave in one sweep
At render distance 32 a far teleport forgets every column (about 3,700
columns, 27,000 resident sections) in one frame, ~6 ms on an M4 Pro:
- Streaming::forget_columns forgets them together; when no held column
  is left, the pending-section maps (80,000 compile_started entries) are
  cleared instead of filtered (1.7 ms -> 0.1 ms).
- Resident::remove_columns empties itself when every column goes,
  without a lookup and a re-insert per section (1.9 -> 0.3 ms).
- Arena::release_all resets a page every range of which comes back,
  instead of sorting tens of thousands of ranges into its free list
  (1.0 -> 0.3 ms).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:06:51 +01:00
Ludovic Marie d146c59158 Merge pull request #7262 from horizon-rust/perf/alloc-census
🤖 Agent: rebased on main fdd290b99 (conflict in spans::time with the new recording_here(): the heap tag stays first, a const None without the feature). Rechecked on a2bbcb4fe: clippy -D warnings with and without alloc-census, horizon-threads 18/18, spans 6/6, fmt clean.
2026-11-02 16:05:55 +01:00
Ludovic Marie 227d0c8955 Merge pull request #7345 from MSedgeMC/chore/vol-c-client2-001
🤖 Agent: queued at 258a836e2d. post_chain.rs PostEffects::load exists and refuses external targets other than the frame, as the row says. TSV only.
2026-11-02 16:05:50 +01:00
Ludovic MarieandClaude Opus 5.5 64faa0482a Merge main into #7328
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:05:43 +01:00
Ludovic MarieandClaude Opus 5.5 f620fed9a7 Merge main into #7332
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:05:18 +01:00
arismoko b7b0de9866 Merge remote-tracking branch 'upstream/main' into entity-holders 2026-11-02 15:05:17 +00:00
ElyTheCatandOpenAI Codex 79adacb982 Cache particle light lookups per block
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 18:35:15 +03:30
arismoko 0e6e918a75 Merge remote-tracking branch 'upstream/main' into native-template 2026-11-02 15:05:13 +00:00
arismoko f495aeba1b Merge upstream main into w1-bow 2026-11-02 15:04:21 +00:00
Ludovic MarieandClaude Opus 5.5 e17e541f4a Merge main into #7329
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:04:07 +01:00
Ludovic MarieandClaude Opus 5.5 431ae7341a Send and read a burning arrow's fire flag
A Flame bow's arrow burned on the server but no client knew: the tracker
never sent Entity.DATA_SHARED_FLAGS_ID's fire bit for projectiles, and the
client ignored index 0 for them. The server now sends it while the arrow
burns (Entity.baseTick's setSharedFlagOnFire) and the client keeps it as the
arrow's fire, which the arrow renderer already draws flames for.

server_entities now tracks a burning arrow against vanilla's dedicated
server, and lan_e2e's flame-arrows check shoots Flame and plain bows from
the host and the guest and reads both clients' view.

Fixes #7356

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:03:51 +01:00
arismoko 2f1b035fce Merge upstream main into w3-explosion
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 15:03:41 +00:00
Ludovic MarieandClaude Opus 5.5 3b40418730 Merge main into #7327
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:03:36 +01:00
arismoko 063b262b0c Merge upstream main into w3-level-misc
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 15:03:33 +00:00
arismoko 608b08c2c0 Merge remote-tracking branch 'upstream/main' into v2/ses_01M6EA9PQK4BXKXA1BE1D8FR68-w3-block-drops-note 2026-11-02 15:03:30 +00:00
HrafnaValkyrja 78022a1ff9 Merge remote-tracking branch 'origin/main' into fix/entity-hooks-review 2026-11-02 10:01:41 -05:00
arismoko a09da57204 Merge upstream main into w1-bow
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 15:01:27 +00:00
Ludovic Marie 0de9fbac48 Merge pull request #7342 from MSedgeMC/chore/vol-e-rest-012
🤖 Agent: queued at a5a3c96e4a. The three packets exist as packet! declarations in horizon-protocol. buf.rs::write is a generic citation, but the note names each packet. TSV only.
2026-11-02 16:01:12 +01:00
Ludovic MarieandClaude Opus 5.5 d6a147c50a Hand pose hook: skip the pose matrix when no mod moved the hand
Five Mat4s were built and multiplied per hand every frame, mods or not.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:01:10 +01:00
Ludovic MarieandClaude Opus 5.5 26336535b8 Merge main into #7305
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:01:10 +01:00
[email protected] 6a6b1233fc Native camera: a world frame is any frame that is not the title panorama 2026-11-02 16:01:09 +01:00
MSedgeMC 82f007f7a3 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 15:00:32 +00:00
MSedgeMC dde444351f Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 15:00:27 +00:00
MSedgeMC 02de4be732 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 15:00:22 +00:00
MSedgeMC da13f6f606 Port map: the forced feature packs are enabled_ids
enableForcedFeaturePacks was filed missing because Horizon's packs were said
to have no feature flags; pack.mcmeta's features.enabled is read and the
last loop of datapack::enabled_ids forces such a pack on. The two rows whose
notes claimed no feature check follow.
2026-11-02 14:59:12 +00:00
[email protected] e428a5c4d9 Merge origin 2026-11-02 15:59:03 +01:00
Ludovic MarieandClaude Opus 5.5 bc04fc95fb Native camera: drop the view.is_some() test, view is a Mat4
The camera is built for world frames only (!title); this line did not
compile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:59:01 +01:00
[email protected] f62d6117a6 Merge upstream/main 2026-11-02 15:58:50 +01:00
[email protected] 19a4348d7c Merge upstream/main 2026-11-02 15:58:17 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 bddc93efbd Crystals join before they light fire; a cancelled crystal summon fails; the join mark follows the id counter
Review findings on #7267: /summon end_crystal now checks the join like the orb branch; the spike crystals join before tick_crystals; native_joins_flush ends with joined_mark = max(joined_mark, next_entity), so ids that are never announced stop the next flushes walking.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 09:57:47 -05:00
[email protected] f933fff922 Merge upstream/main 2026-11-02 15:57:47 +01:00
MSedgeMC 7b0a0efc7d Port map: eight rest-015 command methods the file never answered
The fetchprofile reply, the fill modes, locate poi, msg's sendMessage,
place's loaded check and feature path, publish and the reload's pack step
each have a function whose doc names them.
2026-11-02 14:57:35 +00:00
Ludovic MarieandClaude Opus 5.5 838f532480 Merge main into #7330
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:57:25 +01:00
socksprox 139853213f One worker_count() behind the pools' available_parallelism reads
Nine call sites each asked the OS for the core count and sized their own
pool, so a run spread workers over every reported core with no way to
cap them. horizon_threads::worker_count() resolves the count once
(available_parallelism is the process's share: affinity, cgroup quota
and honors HORIZON_WORKER_THREADS for machines a run should take fewer
cores of. Each site keeps its own share of the count: the streaming
pool all but one, far decoration a third, save compression half, and
the rest the raw number.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
EOF
)
2026-11-02 15:57:18 +01:00
Ludovic Marie 5914e52b92 Merge pull request #7377 from horizon-rust/perf/outline-gpu
🤖 Agent: GPU silhouettes are written past every entity material and drawn by the outline pass with a base vertex; outline_since copies jobs as FLAT in the team colour (= mark_outline). Invisible glowing mobs stay on CPU. Screenshot diff within same-binary noise.
2026-11-02 15:57:17 +01:00
MSedgeMC a8892e3a87 Test the lead offsets of the pig, rabbit, parrot, ocelot and happy ghast
The same shape as the golem and fox test: each offset's height against the
share of the eye height, the forward shift against `bbWidth * 0.4`, and the
happy ghast's at zero on all three axes.
2026-11-02 14:57:16 +00:00
MSedgeMC 975e4b5c8d Seven mobs hang their lead where vanilla does
`Pig.getLeashOffset`, `Rabbit.getLeashOffset` and
`Strider.getLeashOffset` put the rope at 0.6 of the eye height,
`Parrot.getLeashOffset` and `Ocelot.getLeashOffset` at half of it, and
`HappyGhast.getLeashOffset` returns `Vec3.ZERO`, so a happy ghast's lead
hangs at its feet.

`leash::leash_offset` knew the llama, wolf, allay, camel, bee, the three
golems and the fox and gave these seven the `Leashable` default it falls
through to — the full eye height, `bbWidth * 0.4` in front — so their
ropes were drawn too high, and a happy ghast's two blocks above its feet.

Corrects the six ledger rows these gaps sat on (game-011.tsv,
game-012.tsv, game-013.tsv, game2-003.tsv).
2026-11-02 14:57:16 +00:00
Ludovic Marie 342c7b45e4 Merge pull request #7344 from socksprox/t3/atomic-u32-temp-counters
🤖 Agent: queued at 4611b48768. Every counter changed only names temp files within one process, so 32 bits can't realistically wrap into a live name, and dropping AtomicU64 unblocks targets without 64-bit atomics. Merged with main: horizon-world clippy (all targets) is clean and the region/cache tests pass.
2026-11-02 15:57:13 +01:00
[email protected] c193c2f18b Merge upstream/main 2026-11-02 15:56:57 +01:00
[email protected] 1c95d0aefd Merge upstream/main 2026-11-02 15:56:40 +01:00
[email protected] 9dc844cf61 Merge upstream/main 2026-11-02 15:56:23 +01:00
levzzzandClaude Opus 5.5 3351cc0411 Merge main into #7305
Main (#7267) dropped the vanished_mobs_are_reported_by_kind test beside this
PR's hand_pose_rejects_invalid_components: kept the new test. generated.rs is
regenerated (cargo run -p xtask -- mod-api) over main's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:56:20 +05:00
MSedgeMC 30d23de3b3 Port map: the PistonMovingBlockEntity methods rest-025 left unanswered
Ten methods the batch skipped have ports whose docs name them: the moving
piston's tick and final tick, the push geometry in piston_push.rs, and the
save data in persist.rs.
2026-11-02 14:56:09 +00:00
Stink-O 7c236bdef3 Merge remote-tracking branch 'origin/main' into native/block-sprites 2026-11-02 14:55:38 +00:00
HrafnaValkyrja 478ae85c20 Merge remote-tracking branch 'origin/main' into fix/trident-selector 2026-11-02 09:55:19 -05:00
MSedgeMC a345219dad Port map: falling_block_visible is the shouldRender check
Row 36 said a falling block inside an identical block is still drawn and
that game.rs has no such check. game.rs has it: falling_block_visible
compares the entity's own state with the state of the cell at its feet,
and the entity draw skips the block when the two match, with a unit test
either side. The row now names that fn.
2026-11-02 14:54:48 +00:00
MSedgeMC 985fd6e8f0 Port map: four rest-016 methods the file never answered
getSpawnY and isSafe are the spread command's spawn search and safety test,
stopSound is the packet-per-target loop in cmd_stopsound, and
StopwatchCommand.register is tree_misc's stopwatch tree.
2026-11-02 14:54:44 +00:00
Ludovic Marie 953fbd73c6 Merge pull request #7337 from HrafnaValkyrja/fix/mod-api-test-fragments
🤖 Agent: queued at f96c17a314. Thanks for catching this. My stamp merge (#7258) deleted native_item_damaged.json, which this test read, so main's xtask tests have been red since. Reading the stamped hook out of native-hooks.json is the durable fix: a later stamp can't break it again. Merged with main: all 19 xtask tests pass, mod-api --check passes, and the test restores the catalog afterwards.
2026-11-02 15:54:24 +01:00
Ludovic Marie d3339d5482 Merge pull request #7334 from MSedgeMC/chore/vol-c-classes-04
🤖 Agent: queued at ce831473d0. block_items.rs clone_item covers the attached and unattached stems; TestBlock as skip:debug is right. TSV only.
2026-11-02 15:54:20 +01:00
Ludovic Marie a62b921fdd Merge main into #7326
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 15:54:16 +01:00
MSedgeMC b15ee4a662 Port map: the new minecart render path samples its steps
Row 98 said experimental minecart movement is not implemented. A
NewMinecartBehavior cart queues steps and remote_minecart_lerp samples
them at the frame's partial tick, position and rotation together, with
a None that leaves the renderer on the entity's own interpolated
position and rotation as vanilla's newExtractState does. The row now
names that fn.
2026-11-02 14:53:45 +00:00
MSedgeMC f3c2ed13ce port map: cellIndex's locate lives in storage.rs
Row 141 cited crates/horizon-core/src/paletted.rs::locate; paletted.rs
imports locate from storage.rs (line 11) and never defines it. The cell
is crates/horizon-core/src/storage.rs::locate now: the word and bit shift
of an entry, which is the index SimpleBitStorage.cellIndex computes.
Verdict unchanged.

map-check tools/port_map/rest-022.tsv: 149 rows, no problems.
2026-11-02 14:52:33 +00:00
MSedgeMC 1b15477a15 port map: Minecraft.run is the game's loop, run_event_loop
Row 76 cited crates/horizon-client/src/game.rs::run, which game.rs does
not have. The fn that runs the game to its end is run_event_loop
(game.rs:20250): the winit event loop with a window, Game::run_headless
without one, then the narrator stop and the crash report. run_with is the
process entry that reaches it and run_loading the loading screen's loop
when resources are missing, so the note says that instead of "the main
loop".

map-check tools/port_map/rest-003.tsv: 143 rows, no problems.
2026-11-02 14:52:19 +00:00
MSedgeMC ad657c67b2 Port map: answer rest-014's defaultgamemode and loaded-condition notes
Both carried unsure:. Read: the defaultgamemode arm also flips the host's
creative flag (vanilla only sets the world default and enforces it when the
world forces a mode), and the loaded arm has no entity-load check where
vanilla wants ENTITY_TICKING plus areEntitiesLoaded.
2026-11-02 14:52:10 +00:00
MSedgeMC efa9d63169 port map: updateInvisibilityStatus is guest_body's invisible flag
Row 133 cited crates/horizon-client/src/game/lan.rs::Game::lan_host_state,
which is not a fn in that file. The spectator-and-effect invisibility that
ServerPlayer.updateInvisibilityStatus decides is lan/hub.rs::guest_body's
invisible flag, which shared_flags sends as the shared-flag bit 0x20
whenever it changes. The row stays inline; its empty note now says what
it is folded into.

map-check tools/port_map/game-002.tsv: 146 rows, no problems.
2026-11-02 14:52:09 +00:00
MSedgeMC 24ed9b6d46 Port map: three rest-014 methods the file never answered
ServerScoreboard.load and store are Scoreboard::unpack and pack (their docs
name them), and updateStepTicks is the TickingStep broadcast inside
Hub::sync_ticking.
2026-11-02 14:52:03 +00:00
MSedgeMC 30b37d8748 port map: updateNeighborsAtExceptFromFacing is update_neighbors_at_except_oriented
Row 183 cited Sim::update_neighbors_at_except, which the neighbour
updater does not define; the fn that skips a side is
Sim::update_neighbors_at_except_oriented (update.rs:528), the Multi
update with skip: Some(side), reached from redstone.rs:571,
redstone.rs:829 and interact.rs:511. No verdict changes, and the empty
note now says what the cell means.

map-check tools/port_map/classes-07.tsv: 189 rows, no problems.
2026-11-02 14:52:00 +00:00
[email protected] 7738a28e05 Test: the hand cancellation fixture stops before the pose hook 2026-11-02 15:51:50 +01:00
MSedgeMC 0a13a0d2e2 port map: game-009's six class rows name their home files
Rows 74-76 and 82-86 are class rows carrying port or inline, which
map-check refuses (a class row takes skip:<reason>, missing or home).
Each goal they name is in the tree, so each row is a home row now:
aquatic.rs for FollowFlockLeader, goals.rs for FollowMob, dolphin.rs for
FollowPlayerRidden, golem.rs for the two named Strolls register_iron
builds (MoveBackToVillageGoal and GolemRandomStrollInVillageGoal), and
undead.rs for MoveThroughVillage. The notes keep what the old cells
claimed and gain the Stroll each golem row is and the raider goal's real
home (Raider$RaiderMoveThroughVillageGoal, raider.rs::MoveThroughVillage).

map-check tools/port_map/game-009.tsv: 125 rows, no problems.
2026-11-02 14:51:45 +00:00
MSedgeMC 984b742830 port map: TestInstanceRenderer.submit is the marker loop, with the beam and box drawn
The row still said creative players see no beacon beam or box, from before
the test instance renderer landed. Game::test_instance_marker_geometry is
submit's per-block marker loop; the beam sections come from
Game::test_instance_beam (beacon.rs) and the box from
Game::bounding_box_gizmos (structure_blocks.rs), both called per frame.
2026-11-02 14:51:35 +00:00
MSedgeMC 5a6990c676 port map: classes-05's BlendingData row is a home row
Row 55 named the class itself in a class row's where cell, so the verdict
was port and map-check refuses that: a class row takes skip:<reason>,
missing or home. BlendingData and Blender live in
crates/horizon-world/src/worldgen/blending.rs, which is what the row says
now, and its note names the fns it describes (unpack, pack, calculated)
instead of calculateData, which no fn is called.

map-check tools/port_map/classes-05.tsv: 227 rows, no problems.
2026-11-02 14:51:23 +00:00
arismoko 96e0000c86 Remove Java identity wording from spawn hook 2026-11-02 14:51:17 +00:00
MSedgeMC 838bf539a7 Port map: a locked container's message is locked_message
Row 129 said a chest with a Lock tag should refuse to open and that
Horizon has no Lock check, only chest_menu_blocked looking at the block
above. The check is container_locked_for, which reads the lock tag and
asks can_unlock whether the held item opens it; the menu path in
lan_actions.rs refuses the container, sends the container.isLocked action
bar text built by containers.rs's locked_message and plays
block.chest.locked. The row now names that fn.
2026-11-02 14:50:54 +00:00
Ludovic Marie 714c70cb70 Merge pull request #7299 from Xmilon/native/movement-input
🤖 Agent: queued at 909e5907ee. Comparing [strafe, forward] with the originals before touching keys means a run with no mod leaves the input untouched, and the per-tick cost is one array compare. You mentioned nothing was built or run, so I did: after a main merge, xtask mod-api --check and test_stamp_native_hooks pass, clippy is clean, and the native lib tests plus your two mod_api_hooks write-back tests pass.
2026-11-02 15:50:39 +01:00
MSedgeMC 665ff9893f Port map: clipIncludingBorder is clip_including_border
Row 121 said the border clamp of a ray was missing and that clip_ray had
none. It is not in clip_ray: it is the wrapper projectiles.rs puts around
a block clip, a ray that starts inside the border and ends outside it
stopping on the border at WorldBorder.clampVec3ToBound, facing the
nearest direction of travel, which creatures.rs calls for every
projectile clip.
2026-11-02 14:50:06 +00:00
MSedgeMC ec325b8357 Port map: answer rest-024's value-provider range notes
The three unsure: notes asked where vanilla's field-codec range check went.
There is no wrapper; the sites that need a bound check it themselves, and
the notes now name them.
2026-11-02 14:49:28 +00:00
MSedgeMC ae6e063da5 Port map: the world upgrader's status text and position check are ported
translate and Messages.forStatus are the status key pick in status_key, and
verifyChunkPos is verify_chunk_pos - warning about a misplaced chunk is what
the method does.
2026-11-02 14:49:28 +00:00
[email protected]andGPT-6.1 Terra 6db1f56d7a Add native name tag render hook
Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-11-02 15:48:08 +01:00
Ludovic Marie c067f4f4bc Merge pull request #7324 from MSedgeMC/chore/vol-d-rest-019
🤖 Agent: queued at 86ad0cde9f. packs.rs format_codec and last_pre_minor exist on main (81 and 64, data and resource). TSV only.
2026-11-02 15:47:56 +01:00
Ludovic Marie 515066da32 Merge pull request #7322 from MSedgeMC/chore/vol-d-classes-09
🤖 Agent: queued at 963baaf3a6. cmd_item_modify's toggle_tooltips arm is the real port; 'home' plus 'inline' is the right split. TSV only.
2026-11-02 15:47:46 +01:00
arismoko 656929153f Merge remote-tracking branch 'upstream/main' into entity-holders 2026-11-02 14:47:38 +00:00
Ludovic Marie 133fc70cb5 Merge pull request #7321 from MSedgeMC/chore/vol-c-rest-002
🤖 Agent: queued at 97fe3e6ec9. advancements.rs::item and ::damage_source exist, and the 'gap: minor' on SingleComponentItemPredicate keeps the open part visible. TSV only.
2026-11-02 15:47:38 +01:00
Ludovic Marie d71ed2848f Merge pull request #7320 from MSedgeMC/chore/vol-c-rest-021
🤖 Agent: queued at d1a116192b. The Crypt writer rows are right to stay missing: only the key-pair codec's static initialiser calls them, and Horizon reads PEM. TSV only, citations checked against main.
2026-11-02 15:47:31 +01:00
Ludovic Marie 48f094a861 Merge pull request #7284 from HrafnaValkyrja/fix/projectile-summon-gravity
🤖 Agent: queued at bf3a0e467e. Making the shulker bullet's target an Option, with getGravity (0.04, none under NoGravity), is what lets a target-less summoned bullet fall instead of hanging at y 110. ExperienceBottle getting its own kind with gravity 0.07 matches ThrownExperienceBottle. Merged with main; clippy and the projectile tests pass.
2026-11-02 15:47:26 +01:00
[email protected]andGPT-6.1 Terra 3af79beba7 Add native tooltip color hook
Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-11-02 15:47:25 +01:00
Ludovic Marie 88f44dda3e Merge pull request #7259 from Akirabane/fix/7080-armor-stand-world-border
🤖 Agent: queued at c769fc342c. Routing the stand through resolve_in_border with the level's BorderGeometry matches Entity.collectColliders. With the border passed as Option, the tests' None keeps the old path. Merged with main: clippy clean, and the_world_border_stops_an_armor_stand passes on playhorizon with real 26.3 data (it skips without .local, so I checked it actually ran).
2026-11-02 15:47:21 +01:00
levzzzandClaude Opus 5.5 44711243f8 The hook gets the hand wherever the game drew it, in the world's pass too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:47:17 +05:00
Ludovic Marie 62d8b58284 Merge pull request #7268 from TheMrZZ/fix/trial-spawner-passengers
🤖 Agent: queued at 2d73371393. Running place_summoned over the whole stack before mob_from_nbt matches loadEntityRecursive, and normalize_id in entity_nbt::kind covers the bare 'chicken' id case for every NBT loader, not just spawners. Merged with main; the entity_nbt tests pass.
2026-11-02 15:47:16 +01:00
Ludovic Marie ba711e1601 Merge pull request #7267 from HrafnaValkyrja/refactor/entity-hooks-complete
🤖 Agent: queued at c3f8675114. With no mod loaded this is cheaper than main: persist.live stays empty and native_joins_flush returns on mods().is_empty(), and the three per-tick dropped_ids/mob_ids/orb_ids snapshots are gone. Merged with main: xtask mod-api --check passes with no regen needed, and clippy plus the native tests pass.
2026-11-02 15:47:12 +01:00
Ludovic Marie ff587fee78 Merge pull request #7265 from mmmaarian03-crypto/fix/windows-content-hardlinks
🤖 Agent: queued at 7bd3ccc656. Falling back to fs::copy when hard_link fails (other volume, the NTFS 1023-link limit) keeps the root byte-identical. Writes are safe because Overlay::write removes the name before writing, and your writing_into_the_overlay_leaves_the_base_alone test pins exactly that. Merged with main; the overlay tests pass.
2026-11-02 15:47:06 +01:00
Ludovic Marie 5dbb870137 Merge pull request #7263 from TheMrZZ/fix/7230-noai-age
🤖 Agent: queued at 72b58bd112. Moving AgeableMob.aiStep's age clocks out of the AI gate is the right shape: they run once per mob per tick as before, so the move costs nothing, and NoAI babies now grow (the turtle scute event comes along with it). Merged with main: clippy clean, the age tests pass on playhorizon with real data. run-parity is on because the mob_ai harness changed.
2026-11-02 15:46:59 +01:00
MSedgeMC 8af100b198 Port map: the boat bubble column rows are ported
Four game2-007 rows said a boat over or in a bubble column was not
handled: onAboveBubbleColumn, tickBubbleColumn, handleEntityEvent and
handleBubbleColumnEffect. boats.rs holds the column bookkeeping, the
splash, the countdown and the drag or lift, and remote_vehicles.rs
applies event 71 or 72 to the boat the client drives, so each row now
names its fn and says what that fn does.

map-check also flagged row 21: AbstractBoat.onPassengerTurned named
Game::tick_riding, which is not in the tree. The 105-degree clamp is in
Game::remote_ride.
2026-11-02 14:46:53 +00:00
MSedgeMC fe3d96dc61 Port map: the user-list clears are UserList::clear
All four clear rows were skip:server because Horizon has no JSON-RPC
management API; the port exists and is tested, and the missing caller is the
management service, which is what the notes now say.
2026-11-02 14:46:45 +00:00
[email protected] 74d9b28dd3 Native mods: drop the fragment's comments entry, the stamper edits the docs itself 2026-11-02 15:45:59 +01:00
MSedgeMC 5a0598ad39 Port map: the server-activity monitor's rate limit is activity_due
processWithRateLimit is the thirty-second window around the activity
notification, and management.rs::activity_due is the same timestamp test
against ACTIVITY_EVERY.
2026-11-02 14:44:56 +00:00
MSedgeMC 13cd51f9e5 Port map: the file pack's resource and listing are in datapack.rs
FilePackResources.getResource and listResources were filed skip:data as
"pack file access"; DataPack::read and DataPack::list answer both, over a
zip's entries or a folder pack.
2026-11-02 14:44:44 +00:00
NazaandClaude Opus 5.5 ba1caf38a9 Fence knots: a click on the knot takes the mob back, a click on the fence only ties
A client never picked a knot, so it sent the click as UseItemOn on the
fence, which vanilla's server reads as LeadItem.useOn: a tied mob could not
be taken back. The knot is pickable for a use now and the click goes as
Interact with its id. Horizon's server read a fence click inside the knot's
box as a knot click, which left a mob untied in single player; a fence click
now only ties, and Interact with a knot's id goes to use_knot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:44:26 -03:00
arismoko 5833c990f8 Merge remote-tracking branch 'upstream/main' into v2/ses_01M6EA9PQK4BXKXA1BE1D8FR68-w3-block-drops-note 2026-11-02 14:44:08 +00:00
arismoko b75b4d06a1 Document explosion player context 2026-11-02 14:44:04 +00:00
arismoko a61ff00f34 Document note tuning hook contract 2026-11-02 14:43:49 +00:00
[email protected]andGPT-6.1 Terra 91a50ba017 Observe screen overlays in native lifecycle hooks
Written by an AI agent (Claude Code orchestrating GPT-6.1 Terra) for Xmilon

Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-11-02 15:43:48 +01:00
Ludovic MarieandClaude Opus 5.5 3380705af9 Game: start on CPUs without POPCNT again (Core 2), and check every build for it
Since #4160 (10-29) the game's allocator is mimalloc v3, from libmimalloc-sys
0.1.49. Built by MSVC, its mi_popcount is `__popcnt64`, which MSVC turns into
the POPCNT instruction whatever the target. mimalloc runs before main, so on a
Core 2 (no POPCNT) the Windows game died at once with 0xc000001d and no log
(#7264). v0.2.97 had no POPCNT at all; nightlies 2026-10-31 and 11-01 have 9.

third_party/libmimalloc-sys is the published crate with one change: on MSVC
x86/x64, mi_popcount takes mimalloc's portable count, as the GCC builds without
-mpopcnt already do. Only arena bitmaps count bits, never malloc's fast path.
[patch.crates-io] points at it until a release carries microsoft/mimalloc#1291.

tools/check_cpu_baseline.py disassembles the Windows and Linux programs in the
game build and fails on any POPCNT or LZCNT: it flags 2026-11-01's
horizon-client.exe and passes v0.2.97's and the Linux builds.

Fixes #7264

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:43:19 +01:00
levzzzandClaude Opus 5.5 cf69c65c83 Render hooks: native_hand_drawn, after the first-person hand is drawn
native_world_drawn runs before the hand, so a mod that works on the picture
(a motion blur) left the hand and held item untouched, and never saw the hand's
geometry or its matrix (its own field of view and the bob). native_hand_drawn
runs after the hand's pass on the real frame, with the frame's target, the
hand's camera-relative quads and its clip matrix (HandDrawn). The generator
adds RenderHooks::hand_drawn; an API fragment numbers it.

Closes #7335

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:42:51 +05:00
MSedgeMC 3577b3a24d Port map: the chunk failure reports are in chunk_io, and two notes are answered
reportChunkLoadFailure is chunk_io.rs::report_load_failure, which was filed
missing although its doc names the vanilla method; reportChunkSaveFailure
pointed at the local save path's warning rather than report_save_failure.
Identifier.resolveAgainst and PlayerAdvancements.setSelectedTab lose their
unsure: now that both are read.
2026-11-02 14:42:47 +00:00
MSedgeMC f98f97dc2d Port map: rest-013's language-key and registry-load rows are ports
Identifier.toLanguageKey is spelled out in the buffet world screen's display
name, and ResourceManagerRegistryLoadTask.load has its counterpart in the
registry sync's file walk, pack overlay and file order.
2026-11-02 14:42:44 +00:00
MSedgeMC 258a836e2d port_map: the two pass rows lose the "only" in their gaps
`PostChain.addToFrame` and `PostChain.process` point at the entity outline's
silhouette pass and the menu's own two-pass blur, which is right, but their
gaps read as if those were the only chains that run. The chain runner is
`post_chain.rs::PostEffects::apply`: it loads whatever chain id the frame's
request names, on first use, and runs it.
2026-11-02 14:41:37 +00:00
MSedgeMC 696698bae3 port_map: client2-001's post chain loader and pass exist
`PostChain.load` and `PostChain.createPass` were filed as missing ("no
data-driven post chain loader; only the blur and entity outline chains exist,
hard-coded"), and `getReferencedExternalTargets` as needing no port. All
three are `post_chain.rs`: `PostEffects::load` reads a chain's JSON, refuses
any external target but the frame with vanilla's own message and builds its
targets and passes, and `translated_pass` is `createPass`, translating the
pass's GLSL, binding its targets and images and packing its uniforms.
2026-11-02 14:41:33 +00:00
Ludovic MarieandClaude Opus 5.5 fd594cb6a0 Pose glowing entities' silhouettes on the GPU skin path
A glowing mob (and a glowing dropped item) went to the CPU path whole:
the entity outline (`OutlineBufferSource`) copied its CPU vertices in
the team colour with no normal (`mark_outline`). Now a glowing entity
that is not invisible is posed on the GPU like any other, and its
silhouette too: `SkinFrame::outline_since` copies the entity's skin
jobs as FLAT jobs (no normal) in the team colour. The same dispatch
writes them past every entity material in the entity buffer, and the
outline pass draws them from there after the CPU silhouettes. An
invisible glowing mob (only its outline shows) keeps the CPU path.

The shader test now dispatches outline jobs into a second region and
checks each silhouette is its draw's vertex with the colour set and the
normal cleared, as `mark_outline` makes it. Adds stress_glowing (1000
glowing mobs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:41:01 +01:00
Ludovic MarieandClaude Opus 5.5 a2bbcb4fed horizon-ctl memory: the live heap by startup step, span and job
A build with the alloc-census feature runs mimalloc with a tag in front of
each block: the startup step, timed span or pool job that allocated it, else
its thread's name. horizon-ctl memory then lists the live heap by tag
(heap_by_tag_mb). heaptrack's interpreter ran out of 7 GB on the game; this
needs nothing beside it. Without the feature nothing changes: the tags are a
constant None.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:40:49 +01:00
MSedgeMC a5a3c96e4a Port map: the structure block's constructor and write, and the cart getCommandBlock
SetStructureBlock is hand-written, so its write is named directly; the
(StructureBlockEntity, UpdateType) constructor is the edit screen's packet
builder. getCommandBlock's set-command and track-output write is folded into
apply_cart_command, which also carries the handler's onUpdated and note.
2026-11-02 14:40:14 +00:00
MSedgeMC e2d757b741 Port map: three serverbound block-edit packets Horizon does write
JigsawGenerate, SetJigsawBlock and SetCommandMinecart are packet!
definitions in packets/play.rs, built by the jigsaw and cart edit screens
and answered by their guest handlers; the rows said the client has no such
packet and no such screen.
2026-11-02 14:40:12 +00:00
socksproxandDevin 4611b48768 horizon-world: use AtomicU32 for temp-name counters
AtomicU64 does not compile on targets without 64-bit atomics
(armv6k-nintendo-3ds, 32-bit ARMv6). These statics only pick unique
temporary file names, where 32 bits is plenty.

Closes #7340

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-02 15:40:04 +01:00
MSedgeMC eb8379f197 port_map: game-010's bat flap and horse chest slot say what is there
`Bat.isFlapping` now names the emission it misses: `mobs/living.rs` posts
`minecraft:flap` for a bee on every other tick and for a parrot, and the bat
is not in that list, so a sculk sensor hears no wing beat.

`AbstractChestedHorse.getSlot` now names the slot set Horizon resolves, the
eight names in `loot::EQUIPMENT_SLOTS`, which has no `horse.chest` in it.
2026-11-02 14:39:13 +00:00
MSedgeMC 2a815c332c port_map: game-010's sun-shy path and the golem's walk are ported
`GroundPathNavigation.trimPath` was filed as missing on the reading that
nothing cuts a path at the first sunlit node. `nav::Mob::trim_path` is that
method: with `avoid_sun` on a ground or wall-climbing mob it leaves the path
alone while the mob itself is under sky, and otherwise truncates the nodes at
the first one that can see the sky.

`LandRandomPos.getPosTowards` is `goals::Mob::land_random_pos_towards`, the
direction to the point and then `land_pos_in_direction`'s ten tries within a
quarter turn. What is still missing is the caller side, and the note now says
so: the golem goals are not registered, so a golem does not drift toward the
village.
2026-11-02 14:39:05 +00:00
Ludovic MarieandClaude Opus 5.5 ec99e376af Native camera: build it only when a native mod is loaded
The camera snapshot (two matrix products, a projection and the
dimension's String) was rebuilt every frame with no mod to read it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:38:13 +01:00
MSedgeMC 5623c08022 port_map: rest-017's session id, packet error and idle rows name the code
`getSessionId` now names where the id comes from: `lan/conn.rs` sends the
player's uuid as `session` in login finished, where vanilla sends one random
id per server run.

`onPacketError` loses its "unsure": nothing in the tree records a failing
packet's exception for a crash report, so the gap is real and stated as such.

`setPlayerIdleTimeout` says which half is missing: `command_order.rs` lists
the command name and the dispatcher has no arm, while the idle sweep the
property drives is in `game/dedicated.rs`.
2026-11-02 14:38:05 +00:00
MSedgeMC 09c41ee5af port_map: the watchdog runs, so its three rows are ported
`ServerWatchdog` was filed as missing three ways ("no watchdog runs", "Horizon
has no watchdog", "watchdog only"), but `dedicated.rs` starts one from
`max-tick-time` in `DedicatedServer.initServer`'s place: `Watchdog::run`
compares the tick in progress against its due time, logs the two lines
vanilla logs, writes `crash::report_watchdog` and exits with 1.

`exit` is inline in that loop with its one deviation kept in the note (vanilla
sleeps 10 s and dumps the stuck thread first; Rust cannot read another
thread's stack), and the report's own gap — no thread dump — is named on
`createWatchdogCrashReport`.
2026-11-02 14:37:59 +00:00
MSedgeMC 8c604d19e9 port_map: rest-004's argument parser and the sprint dust gap
`Main.parseArgument` cited `Options::read`, which only feeds
`std::env::args` into the loop; the loop is `Options::parse`.

`LocalPlayer.canSpawnSprintParticle`'s note now says which half of the
vanilla test is missing: `sprint_dust` runs `Entity.canSpawnSprintParticle`'s
checks (sprinting, not spectator, not crouching, alive, out of water and
lava) and not the `!abilities.flying` that `LocalPlayer` adds on top.
2026-11-02 14:36:54 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 f96c17a314 xtask: the mod-api placeholder test edits the catalog, not a stamped-away fragment
new_hook_placeholder_is_reported_then_filled read
tools/native-hook-fragments/native_item_damaged.json, which 48c63bc09
stamped into native-hooks.json. It now drops item_damaged's mod_api entry
from the copied catalog and expects the same error.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 09:36:51 -05:00
MSedgeMC bea5dae4b5 port_map: rest-004's totem pop, hand swap and --uuid rows are answered
Three `missing` rows have ports in the tree:

- `FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem` is
  `hand::shows_held_stack`, whose doc names it and
  `ItemStack.matchesIgnoringComponents`: the same stack object, or an equal
  one, plays no swap animation.
- `ItemActivation.tick` and `ItemActivation.activate` are the two methods of
  `item_activation.rs`, which also carries the render pose of
  `ScreenEffectRenderer.renderItemActivationAnimation`. The totem pop is
  drawn: `game/totem.rs` activates it and `game.rs` ticks and poses it.

`Main.hasValidUuid` and `isUuidValid` need no port: the `--uuid` argument is
the only thing that reaches them and `Options::parse` has no such flag, so
they are skip:other rather than missing.
2026-11-02 14:36:35 +00:00
Ludovic MarieandClaude Opus 5.5 e478b7e210 third_party: libmimalloc-sys 0.1.49 as published, mimalloc v3 sources only
Verbatim copy of the crates.io package (checksum 6a45a52f…b8e9 in Cargo.lock),
without c_src/mimalloc/v2 and v3's CMake, contrib and CI files, which the build
never reads. The next commit applies the fix and wires it in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:34:56 +01:00
MSedgeMC ce831473d0 port_map: classes-04's barrel and UpgradeData notes say what is there
`BarrelBlockEntity.recheckOpen` loses its "unsure": there is no barrel lid
in the tree at all (`containers.rs` holds its 27 slots), so nothing can
recheck one, while the golem openers a chest rechecks are rechecked.

`UpgradeData` names both halves: the datafix carries the tag
(`datafix/chunk_leaves.rs` writes its Sides bits) and no chunk load applies
the block upgrades it describes.
2026-11-02 14:34:24 +00:00
arismoko 42de84a1d7 Expose note block tuning hook 2026-11-02 14:34:20 +00:00
MSedgeMC e850f4d4ec port_map: classes-04's stem, hanging sign and container rows are ported
Four `missing` rows with "unsure" notes have answers in the tree:

- `StemBlock.getCloneItemStack`: `block_items::clone_item` returns the seed
  for a pumpkin or melon stem, attached or not, and has a test for it.
- `WallHangingSignBlock.isHittingEditableSide` and
  `...shouldTryToChainAnotherHangingSign`: `block_use_item_on` passes a
  hanging sign used on a face other than the sign's own and its opposite,
  which is the editable-side test and the chaining that follows from it.
- `RandomizableContainerBlockEntity.canOpen` is the negative of
  `Game::container_locked_for`: a spectator opens any container, otherwise
  the lock decides.

The four game-test rows become skip:debug, as the client2 maps already have
the game-test renderer and the highlight packet: a test block, its instance
block and their block entities are developer tooling, Horizon keeps their
NBT and runs no test runner.
2026-11-02 14:34:18 +00:00
[email protected] bf7ef27e40 Native mods: screen opening, init and closing, observed once per frame (#7277) 2026-11-02 15:34:17 +01:00
MSedgeMC e9d4febce2 port map: when a broken frame's map forgets it
The note now says what the function does and when the game skips it:
drop_frame_items calls remove_framed_map unless the drops are off and a player
caused the break, which is the null causedBy vanilla gates it on.
2026-11-02 14:33:54 +00:00
MSedgeMC 07d70e014e port map: removeFramedMap is remove_framed_map, not a tick
The row pointed at lan_map_updates and explained that a removed frame needs no
step because markers are rebuilt each tick. maps.rs::Game::remove_framed_map is
the step: its doc opens with `ItemFrame.removeFramedMap`, and it drops the
frame's decoration and its entry from the map's frames.
2026-11-02 14:33:49 +00:00
TiggerTakkyTTTandCodex 56f6cea3cd Merge upstream/main into pause setting PR
Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-02 21:32:47 +07:00
[email protected]andGPT-6.1 Terra 297b0c9b56 Add native FOV modifier hook
Written by an AI agent (Claude Code orchestrating GPT-6.1 Terra) for Xmilon

Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-11-02 15:32:43 +01:00
MSedgeMC 39436cd307 port_map: rest-008's item predicates say what the shipped data asks for
Nine item predicate rows were left as "`advancements.rs::item` ignores this
key". A scan of the pinned data settles what that costs: across the 1866
advancements and the 1447 loot tables, the only item predicates used are
`minecraft:enchantments` (20 in loot tables, 1 in an advancement),
`minecraft:map_id` (18, all loot tables) and `minecraft:jukebox_playable`
(1, adventure/play_jukebox_in_meadows). Eight of the nine rows name a
predicate no shipped data asks for, and the ninth is that jukebox one, where
the ignored predicate is the only test of its `match_tool`, so any item
passes it.

`DataComponentPatch.forget` now names the function its note is about,
`block_entity_item.rs::place`, which merges the item's explicit components
over the block entity's implicit ones with no strip-the-implicit pass.
2026-11-02 14:32:41 +00:00
[email protected] 01d788a4c8 Merge upstream/main 2026-11-02 15:32:26 +01:00
MSedgeMC 6e4b623204 port_map: rest-008's POI sweep, dye mix and spawn egg are answered
Three rows were filed `missing` with "unsure". Each has an answer in the
tree:

- `SectionPos.aroundChunk` is the chunk window `pois_in_range` sweeps
  (`radius/16 + 1` chunks each way); only the pre-load half of the vanilla
  use is absent, since `chunk_pois` returns for a chunk that is not loaded.
- `DataComponentLookup.findAll` is how `DyeColor.findColorMixInRecipes`
  reaches the two-dye recipe; `dye_color_mix_in_recipes` builds that grid and
  asks the recipe manager instead of looking recipes up by their components.
- `DataComponentLookup.ComponentStorage.findMatching` has no counterpart: a
  spawn egg's type is its own id with `_spawn_egg` trimmed
  (`creatures.rs::Game::spawn_egg_allowed`).
2026-11-02 14:32:15 +00:00
[email protected] fc6613336d Merge upstream/main 2026-11-02 15:32:09 +01:00
[email protected] 909e5907ee Merge upstream/main 2026-11-02 15:31:52 +01:00
MSedgeMC 443472d16c port map: the networkable registries are the SYNCHRONIZED list
The row left it undecided which registries count as networkable. It is decided
in lan/data.rs: SYNCHRONIZED holds 32 registries in
RegistryDataLoader.SYNCHRONIZED_REGISTRIES' order, and registry_packets packs
each one, so the packer and the list agree by construction.
2026-11-02 14:31:42 +00:00
arismoko 9de3e585b0 Dispatch explosion knockback to native mods 2026-11-02 14:31:02 +00:00
MSedgeMC 7d99bcf513 port map: TypedDataComponent.encodeValue is Component::code
The row filed the component's codec as Java plumbing, but hash.rs::Component::code
opens with `TypedDataComponent.encodeValue(hashOps)` and is that codec per
component kind, down to the two kinds that have none.
2026-11-02 14:30:56 +00:00
arismoko acb777465d Merge upstream main into w1-bow 2026-11-02 14:30:46 +00:00
MSedgeMC 8687fd655d port_map: rest-001's notes name the radians, the one caller and the rows
`Axis.rotateDegrees` said only that call sites convert; it now names what
they convert for, `math.rs::quaternion_from_axis_angle`, which takes the
angle in radians.

`MatrixUtil.mulComponentWise` keeps its `missing` but now names the one
method that reaches it, `ItemFeatureRenderer.computeFoilDecalPose` (the 0.5
GUI and 0.75 first-person foil decal matrix), and says what the tree has
instead: the SVD half of `MatrixUtil` in `math.rs`, a glint scale per context
in `item_render.rs`, and no component-wise matrix product anywhere.

`SystemReport.<init>` gains the function its rows are built in,
`crash.rs::system_details`, and with it what the report carries before the
gap: Horizon and Minecraft version, operating system, CPU, memory, then the
game's own details.
2026-11-02 14:30:23 +00:00
MSedgeMC 806e846625 port_map: rest-001's Givens and Jacobi rows are the SVD in math.rs
Twelve rows of `MatrixUtil` and `GivensParameters` were filed as skip:other
("SVD split helper") because Horizon was thought to keep item and display
transforms as plain matrices. `crates/horizon-assets/src/math.rs` has the
whole split: `Givens::unnormalized`, `quarter_pi`, `cos`, `sin`, `inverse`
and the per-axis `quaternion`/`matrix` builders, then `approx_givens`,
`qr_givens`, `step_jacobi` and `svd_decompose`, the last five cited against
the vanilla methods they port.

`fromPositiveAngle` is inline: only its pi/4 case is built, as
`MatrixUtil.PI_4`. `aroundX/Y/Z` are three axes of one builder;
`similarityTransform` is the `R^T * m * R` inside `step_jacobi`; and
`eigenvalueJacobi` is the five-pass loop in `svd_decompose`.

Two rows that had none: `GivensParameters.sin` (`Givens::sin`) and
`GivensParameters.inverse` (`Givens::inverse`).
2026-11-02 14:30:23 +00:00
Ludovic Marie fdd290b99b Merge pull request #7313 from MSedgeMC/chore/vol-b-rest-019
🤖 Agent: queued at 63a9c0afb8. The old pre-1.7 user lists being unsupported is fine to record as missing; the dedicated server reads only the JSON lists. TSV only.
2026-11-02 15:30:11 +01:00
Ludovic Marie 829a3d753e Merge pull request #7312 from MSedgeMC/chore/fix-citations-rest-004
🤖 Agent: queued at 94aa0d9699. Opt::set_slider for SliderableEnum next/previous is right: the preset is a slider, so stepping goes through it. All nine citations resolve. TSV only.
2026-11-02 15:30:06 +01:00
[email protected]andGPT-6.1 Terra 5541afa857 Add item frame render hook
Written by an AI agent (Claude Code orchestrating GPT-6.1 Terra) for Xmilon

Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-11-02 15:30:05 +01:00
Loki 98f5199897 Merge remote-tracking branch 'origin/main' into perf/4128-explosions-fps 2026-11-02 23:30:02 +09:00
Ludovic Marie 6791ce3395 Merge pull request #7311 from MSedgeMC/chore/vol-b-rest-008
🤖 Agent: queued at cd7c4505d3. chunk_pois reading only loaded chunks without ensureLoadedAndValid is a real gap worth tracking. TSV only.
2026-11-02 15:30:02 +01:00
Ludovic Marie 308dbde322 Merge pull request #7310 from MSedgeMC/chore/fix-citations-rest-003
🤖 Agent: queued at 5224233ca6. ime.rs ime_preedit/sync_ime and preedit.rs set_preedit all exist; 'inline' fits submitPreeditEvent since the preedit goes straight to the field. TSV only.
2026-11-02 15:29:58 +01:00
Ludovic Marie 4ca6593a59 Merge pull request #7308 from MSedgeMC/chore/fix-citations-client2-001
🤖 Agent: queued at 4e45758836. All eleven repaired citations resolve on main, including the moved render-crate paths (gfx/oit.rs, post_chain.rs). TSV only.
2026-11-02 15:29:53 +01:00
HrafnaValkyrja bf3a0e467e Merge remote-tracking branch 'origin/main' into fix/projectile-summon-gravity 2026-11-02 09:29:51 -05:00
[email protected]andGPT-6.1 Terra cab0658a31 Observe native screen lifecycle
Written by an AI agent (Claude Code orchestrating GPT-6.1 Terra) for Xmilon

Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-11-02 15:29:49 +01:00
MSedgeMC 86ad0cde9f port map: the ban entry constructor is BanEntry::new
BanListEntry.<init> was the one method of a class this batch maps that the
file never answered, so the ledger still counted it missing. Both vanilla
constructors land in BanEntry:🆕 a missing source becomes (Unknown), and
the createdAt argument is not taken but stamped with now, with a file's stored
date kept by fields() on the read path.
2026-11-02 14:29:43 +00:00
MSedgeMC cdf62054ce port map: two PackFormat rows main already ports
`PackFormat.fullCodec` was filed as Java plumbing, but packs.rs::format_codec
is that codec: an int, or a list of 1 to 256 non-negative ints whose second is
the minor. `PackFormat.lastPreMinorVersion` pointed at the function that is the
port, so it reads `port`, not `inline`, as its four IntermediaryFormat
neighbours already do.
2026-11-02 14:29:38 +00:00
Ludovic Marie 865b05bd4a Merge pull request #7307 from MSedgeMC/chore/fix-citations-client-004
🤖 Agent: queued at 901cec2b31. All five repaired citations point at fns in mobs/render.rs that exist on main (enderman_render_offset, species_scale, base_transform_at). TSV only.
2026-11-02 15:29:23 +01:00
masa-masa-0314andCodex 344445ef2c Launcher: report settings save failures and offer retry
Propagate settings persistence errors to CLI and MCP, show a shared GUI notice with retry, keep old settings intact on failure and serialize same-process saves. Skip pruning after a failed selection save and preserve completed instance deletion state.

Fixes #7274

Co-Authored-By: Codex <[email protected]>
2026-11-02 22:29:19 +08:00
Ludovic Marie 084ca0f49d Merge pull request #7306 from MSedgeMC/chore/vol-b-classes-09
🤖 Agent: queued at 2dabb5f1f7. Each loot row names the missing loot.rs arm (set_writable_book_pages, entity_scores) and notes no vanilla table uses it, which sets the priority. TSV only.
2026-11-02 15:29:18 +01:00
Ludovic Marie 920ca9314c Merge pull request #7301 from MSedgeMC/chore/vol-b-game2-002
🤖 Agent: queued at 92ec3e7e62. drop_frame_items building a plain item_frame stack is a real gap: a renamed frame loses its name when broken. TSV only.
2026-11-02 15:29:13 +01:00
Ludovic Marie c367fd9776 Merge pull request #7298 from MSedgeMC/chore/vol-b-game2-005
🤖 Agent: queued at 8da05d30cc. The POI rows explain the structural reason (claims are per-mob fields, no world POI store), which is more useful than 'unsure'. TSV only, 1 citation checked.
2026-11-02 15:29:09 +01:00
Ludovic Marie d7039ef675 Merge pull request #7297 from MSedgeMC/chore/vol-b-game2-006
🤖 Agent: queued at b56d376315. The projectile rows now say what is missing instead of citing fns that aren't there; applyImplicitComponent reaching only bucket mobs matches entity_nbt::bucket_components. TSV only.
2026-11-02 15:29:05 +01:00
Evrim CicekandClaude Opus 5.5 5ca1a4d7ef Describe only the entities that joined after the tick's states
lan_note_joined described every entity of the level again whenever the id
counter moved; lan_entities now takes an id floor, the counter the states
were taken at, and skips every entity at or below it. Its time is a span of
its own (lan_note_joined) in HORIZON_SPIKES and horizon-report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:29:01 +03:00
MSedgeMC 963baaf3a6 port map: the ToggleTooltips class row names the arm's function
"the /item modify arm toggle_tooltips" read as if toggle_tooltips were a
function; it is the modifier id the match arm in cmd_item_modify is keyed by,
and that function is what the row's next line now points at.
2026-11-02 14:29:01 +00:00
Ludovic Marie 30971600cd Merge pull request #7296 from MSedgeMC/chore/vol-a-rest-011
🤖 Agent: queued at 55c77de24b. component.rs::shadow_color exists and reads both the packed int and the RGBA array forms. TSV only.
2026-11-02 15:29:01 +01:00
MSedgeMC e1ed94450b port map: ToggleTooltips.run is the /item modify arm
The class row left the class's only method unanswered, so the ledger still
counted ToggleTooltips.run as missing. The arm that answers it is
`minecraft:toggle_tooltips` in cmd_item_modify (game/commands/items.rs:583),
which adds every false toggle to the tooltip_display component's
hidden_components and carries hide_tooltip through; the loot run has no arm,
so a loot table that names it is ignored, and no 26.3 loot table does.
2026-11-02 14:29:01 +00:00
Stink-O 7548b0ffd8 Merge remote-tracking branch 'origin/main' into native/block-sprites
# Conflicts:
#	crates/horizon-client/src/game/mod_api_host_generated.rs
#	crates/horizon-client/src/mod_api/generated_host.rs
2026-11-02 14:29:00 +00:00
arismoko d4bb9a2598 Regenerate level hooks and fix catalog tests 2026-11-02 14:28:59 +00:00
Ludovic Marie b70f7a9da0 Merge pull request #7295 from MSedgeMC/chore/vol-a-rest-012
🤖 Agent: queued at 68044bf45a. from_biomes writes into a growing buffer, so 'inline' for calculateChunkSize is accurate: there is no size pass to port. TSV only.
2026-11-02 15:28:56 +01:00
Ludovic Marie a82e45bb1d Merge pull request #7293 from MSedgeMC/chore/vol-b-game2-008
🤖 Agent: queued at 1dd0bf6b6e. creatures.rs::Game::dye_mix exists on main and picks the recipe's mix or one of the two at random, as getMixedColor does. The onDestroyed rows name a real player-visible gap: a burnt shulker box item spills nothing. TSV only, all 5 citations checked.
2026-11-02 15:28:51 +01:00
MSedgeMC 97fe3e6ec9 port_map: rest-002's tag, component and lightning notes cite the code
`TagPredicate.matches` pointed at `id_matches`, which matches an id or a tag
for items and entities and has no `expected` flag; the method's only caller
is `DamageSourcePredicate.matches`, and `damage_source` is where the flag is
honoured: each damage type's tag compared against it.

`SingleComponentItemPredicate.matches` said other components are ignored.
It now names one the shipped data asks for: `minecraft:jukebox_playable` in
adventure/play_jukebox_in_meadows, where the item predicate has nothing else
to test, so any tool passes it.

`LightningBoltPredicate.matches` said `blocks_set_on_fire` was checked
against 0 and the trigger never fired. Both hold no longer: the arm compares
the bolt's own count, which `game/lightning.rs` fills from the fires it set,
and `game/advancements.rs::trigger_lightning_strike` fires the trigger.
2026-11-02 14:27:57 +00:00
MSedgeMC 8bebe92031 port_map: the three type_specific entity predicates the loot side reads
`CubeMobPredicate`, `RaiderPredicate` and `SheepPredicate` were filed as
missing with the note that entity() reads no such key. The advancement
reader indeed has no arm (`_ => false`, so a predicate asking for one never
matches), but the loot reader shares the vanilla class and does:
`loot::Context::entity_matches` carries `minecraft:type_specific/cube_mob`
(size bounds against the mob's size), `.../raider` (captain, no raid here)
and `.../sheep` (the sheared flag).
2026-11-02 14:27:45 +00:00
arismoko 9e2dab8547 Merge upstream main into w3-explosion 2026-11-02 14:26:58 +00:00
arismoko 010db43f76 Merge remote-tracking branch 'upstream/main' into native-template 2026-11-02 14:26:56 +00:00
arismoko b9004603f8 Merge remote-tracking branch 'upstream/main' into entity-holders 2026-11-02 14:26:56 +00:00
Ludovic Marie 0409f619a6 Merge pull request #7272 from mmmaarian03-crypto/port-pack/loot-entity-predicates
🤖 Agent: queued at 068a20e36f. Without this, every entity_properties condition in a carried loot table was silently false, so burning-mob and sheep-colour drops never happened. The rename covers both entity_properties and damage_source_properties. Merged with main: port-pack clippy is clean and 106 tests pass.
2026-11-02 15:26:53 +01:00
MSedgeMC d1a116192b port_map: rest-021's notes name the code that decides them
The eleven `Ease` rows said only that named easings come through the
`EasingType` registry. They now name what reads a timeline's ease
(`environment.rs::Environment::load_dimension`, which knows constant and
cubic_bezier) and what the shipped data asks for: `day.json`,
`early_game.json`, `moon.json` and `villager_schedule.json` use constant,
cubic_bezier or no ease at all, so no vanilla timeline names one.

`FormattedCharSequence.backward` was skipped as having no caller; its
caller is `SubStringSource.substring`, which is skip:ui here already.
Vanilla's `CubicBezierControls.validate` and the two `Crypt` PEM writers
name where their behaviour would sit: the loader that takes any control
point, and the key-pair reader that never writes one back.
2026-11-02 14:26:52 +00:00
MSedgeMC d7cb03a1a7 port_map: the path check and the right-to-left mirror are ported
`FileUtil.isPathPortable` was filed as skip:other for having no caller in
26.3, but Horizon carries the check: `structure_blocks::portable_path`
(refused path under `structures/`) tests the same empty, `.` and `..`
segments, and says so in its doc.

`FormattedCharSequence.decorateOutput` was filed as missing for a
right-to-left layout Horizon was said not to have. `bidi.rs` is that layout:
`reorder` shapes, reorders and mirrors the characters of a
right-to-left run, which is the mirror `decorateOutput` wraps a sink in.
2026-11-02 14:26:52 +00:00
Ludovic Marie 0992fe856f Merge pull request #7270 from mmmaarian03-crypto/port-pack/tolerant-mods-toml
🤖 Agent: queued at 1884ed60db. The fallback order is the right one: a parsed mods.toml naming a real id wins over an unfilled neoforge.mods.toml template, and mods_toml_lines only runs when nothing parses, so jars that load today read the same. Merged with main: port-pack clippy is clean and 106 tests pass.
2026-11-02 15:26:45 +01:00
[email protected] f14e9145a6 Native mods: put hand_pose_matrix's own doc comment on it 2026-11-02 15:26:14 +01:00
some-du6eandClaude Opus 5.5 cabceb3c17 Docs: the bucket hooks hear the block beside on every such click
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:26:03 -06:00
HrafnaValkyrja 9f4282439a Merge remote-tracking branch 'origin/main' into fix/trident-selector
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 09:25:22 -05:00
HrafnaValkyrjaandClaude Opus 5.5 1756438f3b Regenerate the mod API output after the merge with main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 09:25:06 -05:00
MSedgeMC 3e08545adc Test the horse's and the llama's variant component
`a_horse_or_llama_spawn_egg_picks_its_variant_component` runs an egg with a
`minecraft:horse/variant` over a black horse drawn with a white field: the
coat becomes DARK_BROWN and the markings stay 2, the mob reports the
component back, and its save keeps the packed `Variant` int (518) with no
`variant` name. A `minecraft:llama/variant` of gray sets a llama's 3 and a
trader llama's too.

`a_horse_and_a_llama_report_their_variant_component` walks every coat of
both enums through `species_variant_component`, `variant_index` and
`variant_name` with no vanilla data, and pins which species save the name
under `variant`.
2026-11-02 14:24:56 +00:00
MSedgeMC 0be2aa7dc3 A horse's or a llama's variant component sets its coat colour
`Horse.applyImplicitComponent` and `Llama.applyImplicitComponent` (which a
trader llama inherits) `setVariant` from `minecraft:horse/variant` and
`minecraft:llama/variant`. `entity_nbt::apply_item_components` knew the
variant components of chickens, cows, pigs, zombie nautiluses, mooshrooms,
sheep, axolotls, salmon, tropical fish, foxes and frogs, but not those two,
so an egg or item carrying one dropped it and the mob kept the coat its
spawn picked.

The component holds a plain enum name, not a holder: `Horse.Variant` is
white 0 to dark_brown 6 (`dark_brown` in the component, `darkbrown` in the
texture name `COLORS` carries) and `Llama.Variant` is creamy 0 to gray 3.
`finalize::variant_index` and `variant_name` had no entry for either, so
the name fell through the frog list and read as temperate, i.e. red.

`Horse.setVariant` writes `variant.getId() & 0xFF | this.getTypeVariant() &
-256`: the markings the spawn drew are the high byte and stay. Horizon keeps
them in `extra.variant2` and the arm touches only `extra.variant`.

`Horse.get` and `Llama.get` report the component too, from
`species_variant_component`. That function also gates the save's `variant`
key, which is `VariantUtils.writeVariant`'s and only the seven classes that
call it write — a wolf, a cat, a frog, a pig, a cow, a chicken and a zombie
nautilus. So both the write and the read of that key now go through
`finalize::saves_variant_name`: a horse and a llama keep their packed
`Variant` int and gain no `variant` name, and a mooshroom stops being read
from a key its save does not carry either.

Corrects the five ledger rows these gaps sat on (game-011.tsv).
2026-11-02 14:24:56 +00:00
arismoko 35c5475d14 Merge upstream main into w1-bow 2026-11-02 14:24:43 +00:00
arismoko 0f2a76003e Merge upstream main into w3-level-misc
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-02 14:24:41 +00:00
arismoko 9d24a0ea53 Merge upstream main into w3-explosion 2026-11-02 14:24:10 +00:00
Ludovic Marie ba5ba8b00a Merge pull request #7291 from MSedgeMC/chore/vol-a-classes-08
🤖 Agent: queued at 6cd307abf8. 'home' for ExperimentalRedstoneWireEvaluator points at the file holding Evaluator, which is accurate, and Level::read_summary does read enabled_features through read_root. TSV only.
2026-11-02 15:23:40 +01:00
Ludovic Marie 9900d28559 Merge pull request #7281 from MSedgeMC/chore/vol-b-game2-009
🤖 Agent: queued at 26438b9128. Kinetic::damage_use_duration exists on main and is what SpearUseGoal reads, so the port row is right. The smithing display rows now say why they are missing (Book::build builds no smithing entry) instead of 'unsure'. TSV only; every cited fn checked against main.
2026-11-02 15:23:35 +01:00
[email protected]andGPT-6.1 Terra 239155ad17 Expose native camera and block collision reads
Written by an AI agent (Claude Code orchestrating GPT-6.1 Terra) for Xmilon

Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-11-02 15:23:31 +01:00
levzzzandClaude Opus 5.5 1390bf8946 Fragment: the docs entry comes from doc_summary, not a comment marker
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:23:28 +05:00
MSedgeMC 63a9c0afb8 port map: the pre-1.7 user lists and pack feature flags say where they stop
Eleven rows now name what the tree has instead: the server reads
`ops.json`, `whitelist.json` and `banned-players.json` from
`game/dedicated.rs` and nothing reads the pre-1.7 text lists, and the only
feature list anything reads is the world's own `enabled_features` (the game
rules, the datapack function filter and the command dispatcher), never a
pack's `feature_flags`.
2026-11-02 14:23:05 +00:00
MSedgeMC 94aa0d9699 Name the fn each rest-004 row ports
Nine where cells held a bare file, a + pair, a const name or a path the fn
does not live under, so map-check reported no fn for them. They now name
Opt::set_slider, sampler_anisotropy, VideoMode::write,
OptionsFile::model_customisation, is_quit_shortcut_down,
PreeditEvent::from_sdl_text_editing, PreeditEvent::to_formatted_text and
Game::remote_ride. Row 16's stale note (a monitor index in options.txt) is
corrected with its citation: the file holds VideoMode::write's mode JSON.
Verdicts are unchanged.

map-check tools/port_map/rest-004.tsv: 148 rows, no problems.
2026-11-02 14:23:01 +00:00
MSedgeMC 5224233ca6 Name the fn each rest-003 row ports
Four where cells pointed at game.rs for fns that live in game/ime.rs, or
held prose or a line -> fn arrow, so map-check reported no fn for them.
They now name Game::ime_preedit, preedit::set_preedit, Game::sync_ime and
Title::drop_file. Verdicts and notes are unchanged. The file's remaining
line 76 (Minecraft.run -> game.rs::run) is outside this repair and stays.

map-check tools/port_map/rest-003.tsv: the four rows above are gone from
the output; line 76 still reports no fn.
2026-11-02 14:22:54 +00:00
MSedgeMC 4e45758836 Name the fn each client2-001 row ports
Eleven where cells held a bare file, a file:line form, a + pair, a slash
pair or a const name, so map-check reported no fn for them. They now name
Oit::render_clouds, map_arm_poses, PostEffects::apply (load, run and the
frame's depth), Game::tick_world_icon, Renderer::draw_frame, Oit::run,
Oit::execute_depth_bounds_cull, Oit::execute_water_mask and
Game::transient_block_geometry. Verdicts and notes are unchanged.

map-check tools/port_map/client2-001.tsv: 150 rows, no problems.
2026-11-02 14:22:46 +00:00
some-du6eandClaude Opus 5.5 93b7010f3e Buckets: native mods hear the block a refused bucket pours into
A filled bucket that the clicked block refuses (a sneak-click on a
slab) pours into the block beside the clicked face, which
`native_fill_bucket` and `native_bucket_empty` never saw, so a claim
mod could be bypassed. Both now hear that block too when it isn't the
target, as vanilla's `mayUseItemAt(blockPos2)` checks it next to
`mayInteract(blockPos)`. Powder snow places like a block and never
pours beside, so it isn't asked twice.

Follow-up to #6783, which merged before this commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:22:44 -06:00
MSedgeMC cd7c4505d3 port map: the item predicates the advancement check ignores
`advancements.rs::item` reads the item ids, the count, the `potion_contents`
and `damage` components and the `enchantments` and `damage` predicates. Every
other predicate key a criterion can carry is ignored, so those criteria match
too broadly; the rows now name the function and say which keys it does read
instead of asking.
2026-11-02 14:22:38 +00:00
MSedgeMC 901cec2b31 Name the fn each client-004 row ports
Five where cells held a bare file, a parenthetical branch note or a
slash-separated pair, so map-check reported no fn for them. Each row now
names the single fn that implements the method: enderman_render_offset,
base_transform_at, species_scale, pufferfish_bob and
Projectile::arrow_effect_color. Verdicts and notes are unchanged.

map-check tools/port_map/client-004.tsv: 149 rows, no problems.
2026-11-02 14:22:37 +00:00
Ludovic Marie 1887ede7f6 Merge pull request #7290 from MSedgeMC/chore/vol-b-game2-003
🤖 Agent: queued at acf6b8b8e9. All six port rows cite fns that exist on main (piglin.rs update_activity, retreat_from_nearest, set_avoid_target, piglins_outnumber_hoglins, zoglin_step, witch_magic_particles). Strider.canAddPassenger missing the eye-in-lava refusal is a concrete gap. TSV only.
2026-11-02 15:22:15 +01:00
Ludovic Marie 2e6b7f7fb4 Merge pull request #7289 from MSedgeMC/chore/vol-b-game2-004
🤖 Agent: queued at 3e2aed287a. riders.rs::start_riding exists on main. The warden anger rows (AngerManagement.codec, add/readAdditionalSaveData) correctly flag that persistent_anger leaves the warden out: a reloaded warden is calm, a player-visible gap worth an issue. TSV only.
2026-11-02 15:22:11 +01:00
Ludovic Marie 133ca8cbe3 Merge pull request #7288 from MSedgeMC/chore/vol-a-client2-017
🤖 Agent: queued at 3c9563383f. multiplayer.rs Screen::confirm handles Enter for both the saved and the LAN entries, and Screen::arrow does the Shift+Up/Down swap; both rows check out on main. TSV only.
2026-11-02 15:22:06 +01:00
Ludovic Marie 013233eac1 Merge pull request #7286 from MSedgeMC/chore/vol-a-client2-010
🤖 Agent: queued at b597fa981e. keep_chat_draft and open_chat_method are on main (ChatDraft and shouldDiscardDraft landed earlier), so both ChatComponent rows are ported. TSV only.
2026-11-02 15:22:01 +01:00
MSedgeMC 2dabb5f1f7 port map: the loot rows that no vanilla data reaches name the dispatch
Each of these classes is a condition, modifier or number provider the loot
run in `crates/horizon-world/src/loot.rs` does not have an arm for. The notes
used to say only that no vanilla data uses the type; they now name the
dispatch and, for the number providers, the kinds `level_value`, `int` and
`float` do handle, so the next reader knows the shape a datapack would need.
2026-11-02 14:21:57 +00:00
Ludovic Marie 207fa7eb7a Merge pull request #7285 from MSedgeMC/chore/vol-b-game2-017
🤖 Agent: queued at f0bdb96bb1. lan_sign_update re-checks playerIsTooFarAwayToEdit on each write, so 'inline' fits SignBlockEntity.tick. The note that a guest who walks away keeps sign_editing until its next write is a real gap worth an issue. TSV only.
2026-11-02 15:21:56 +01:00
Ludovic Marie 6b200c9dd7 Merge pull request #7282 from MSedgeMC/chore/vol-a-client2-012
🤖 Agent: queued at 9a400530fe. select_create_tab, tab_bar_key and create_world::next_tab_index all exist on main, so the three TabNavigationBar rows move from gap/missing to port correctly. TSV only.
2026-11-02 15:21:52 +01:00
arismoko e88344fcef Regenerate the mod API 2026-11-02 14:21:51 +00:00
[email protected]andGPT-6.1 Terra 3f483ac265 Add first-person hand pose hook
Written by an AI agent (Claude Code orchestrating GPT-6.1 Terra) for Xmilon

Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-11-02 15:21:24 +01:00
MSedgeMC 92ec3e7e62 port map: the shulker, ravager and item entity gaps lose their unsure
Thirteen rows now say what the tree holds instead of asking: the attach face
a shulker keeps on boarding, the `SHULKER_BOX` move with no counterpart, the
absence of a defense target goal beside `HurtByTarget`'s alert, the ravager
whose immobility reaches no target goal, and the dropped item that is not an
addressable slot.
2026-11-02 14:21:04 +00:00
MSedgeMC decca3dc4e port map: Shulker.stopRiding folds into the shulker tick
The body yaw of a shulker is zeroed on every tick on both paths, so the
reset vanilla does when a shulker dismounts has no work of its own here.
2026-11-02 14:21:04 +00:00
JoansitohandClaude Sonnet 5.5 512c4054c5 Clarify mount_rolls comment
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 15:20:42 +01:00
JoansitohandClaude Sonnet 5.5 db70635762 Format
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 15:20:42 +01:00
JoansitohandClaude Sonnet 5.5 6feeb199b7 Roll zombie horse, husk and drowned mounts only for natural spawns
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 15:20:42 +01:00
Ludovic Marie 0b287d3584 Merge pull request #7258 from Domanffe/chore/stamp-native-hooks
🤖 Agent: queued at b118744829. The four living-hook fragments that landed after this branch (#6299's drown/fall/heal/jump) are now stamped too, so main ends at API 359 with no pending fragment. xtask mod-api --check, test_stamp_native_hooks and the 612 Python tests pass; client clippy and the native tests pass on playhorizon (b1187448). It races every hook PR, so it goes in now.
2026-11-02 15:20:37 +01:00
[email protected]andGPT-6.1 Terra e7f79677a4 Add native movement input hook
Written by an AI agent (Claude Code orchestrating GPT-6.1 Terra) for Xmilon

Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-11-02 15:20:07 +01:00
Loki 726f1b98e4 Merge remote-tracking branch 'origin/main' into perf/4128-explosions-fps 2026-11-02 23:20:04 +09:00
MSedgeMC 8da05d30cc port map: villager claims, the spin attack and the food gate say what is absent
Ten notes now point at the code that decides them, and two named a
`load_bucket_data` that is not in the tree: the implicit components come from
`entity_nbt::bucket_components` only. Villager claims are per-mob fields with
no point-of-interest store, the riptide spin keeps its damage and ticks but
not its stack, and nothing in `motion.rs` asks the food level before adding
exhaustion.
2026-11-02 14:19:49 +00:00
MSedgeMC dfb319aeb8 port map: Player.canPlayerFitWithinBlocksAndEntitiesWhen is pose::can_fit_pose
`MotionWorld::can_fit_pose` is the test: the pose's dimensions at the
player's position, deflated by 1e-7, against the player's collision, and
`update_player_pose` asks it for swimming, the desired pose and crouching in
the order `updatePlayerPose` does.
2026-11-02 14:19:49 +00:00
MSedgeMC b56d376315 port map: the projectile gaps cite what is actually there
Five notes named code that no longer exists: the implicit-component path is
`entity_nbt::bucket_components` (a mob coming out of a bucket), not a
`load_bucket_data`. The rest now point at the writer that leaves a kind out
(`saved_with_chunk` and the shulker bullet), the wind charge's own target
list, and the pearl teleport that asks the owner nothing.
2026-11-02 14:19:23 +00:00
MSedgeMC 55c77de24b port_map: a style keeps its shadow colour, so withShadowColor is ported
Style.shadow_color holds the colour as packed ARGB, component::shadow_color
reads it from the JSON either as a packed int or as a normalized RGBA array,
Style::inherit carries it down and the style is written back out with it, so a
component's shadow_color is no longer dropped.
2026-11-02 14:19:13 +00:00
MSedgeMC 68044bf45a port_map: ChunkBiomeData's extract and size are from_biomes
ChunkBiomeData::from_biomes writes the column's biome containers in section
order into the packet's buffer and documents itself as extractChunkData, and
the buffer grows as it writes, so no size is computed the way
calculateChunkSize computes it. Cite the writer for both rows.
2026-11-02 14:18:25 +00:00
MSedgeMC 1dd0bf6b6e port map: the destroyed-item and component gaps say what is absent
The eight rows left as gaps now name the missing side: nothing implements
`onDestroyed` for a block item, a bundle or the shared container spill, the
`mob_visibility` component is parsed but never read by targeting, no item
converts on break, and a stack's `damage_type` is only used for the
enchantment damage tags.
2026-11-02 14:17:53 +00:00
MSedgeMC 1d17e2339d port map: five item rows were already ported
The breeding dye mix, the adventure-mode `can_break` and `can_place_on` test,
the map used on a banner and the song a jukebox loads with all exist:
`creatures.rs::dye_mix` and `dye_color_mix_in_recipes` colour offspring,
`game_mode.rs::adventure_predicate` reads the predicates, `maps.rs::
use_map_on_banner` toggles the marker, and `save.rs::decode_block_entity`
restores a jukebox's record and `ticks_since_song_started`.
2026-11-02 14:17:53 +00:00
MSedgeMC 6cd307abf8 port_map: parseFeatureFlagsFromSummary is the summary read
Level::read_summary is LevelStorageSource.readLevelSummary, and read_root
unpacks WorldDataConfiguration's enabled_features from the level.dat it reads,
so the flags are parsed from the summary as vanilla parses them (omitted when
they are vanilla's default). The world list still draws no experimental marker
from them, which the row now carries as its gap.
2026-11-02 14:17:32 +00:00
MSedgeMC c4f68215dd port_map: ExperimentalRedstoneWireEvaluator is a home row
A class row takes skip:<reason>, missing or home, so the port verdict on it was
rejected by the ledger ("a class row takes skip:<reason>, missing or home") and
the row never reached the map. Say where the class lives instead: Evaluator in
level/redstone/experimental.rs is its two queues and the wires it found with
their orientation, driven by Sim::experimental_wire_update.
2026-11-02 14:17:32 +00:00
MSedgeMC acf6b8b8e9 port map: the creaking, breeze and strider gaps name the code that decides them
The eight rows still missing now point at what stands in their place: the
portal test with no heart check, the pathfinder that never reads the home,
`mobs/breeze.rs` with no particle call, the boarding test with no eye-in-lava
rule and the cube size no spawn or data update applies.
2026-11-02 14:17:06 +00:00
MSedgeMC 5229d6118d port map: six hoglin, zoglin and witch rows were already ported
`mobs/piglin.rs` carries the piglin and hoglin AI these rows were called
missing for: `update_activity` (the activity voice, `hoglin_sound` for the
angry and retreat grunts), `retreat_from_nearest`, `set_avoid_target`,
`piglins_outnumber_hoglins` and `zoglin_step`. `witch_magic_particles` is
`Witch.handleEntityEvent(15)`, wired to every witch entity event.
2026-11-02 14:17:06 +00:00
Ludovic Marie 897fbb415e Merge pull request #7287 from horizon-rust/perf/eyes-gpu
🤖 Agent: eyes layers take the GPU path only with improved transparency off (EntityBatch routes OIT quads by CPU vertices); FLAT jobs zero the normal and carry the OIT + EYES_TIER bits in the overlay word, byte-equal in the every-mob test for spider, cave_spider, enderman, phantom, copper_golem.
2026-11-02 15:16:59 +01:00
MSedgeMC 3e2aed287a port map: piglins, wardens and the Johnny flag say what is missing where
The fourteen gaps left in this batch now name the code that decides them:
the rule reaching the mob world while nothing in `mobs/piglin.rs` reads it,
`persistent_anger` leaving the warden out so its anger is never written, the
add-entity payload carrying no warden data, and the Johnny goal testing the
custom name rather than a saved flag.
2026-11-02 14:16:16 +00:00
MSedgeMC 3c9563383f port_map: Enter joins the server list, so both keyPressed rows are ported
Screen::confirm is ServerSelectionList.OnlineServerEntry.keyPressed and
NetworkServerEntry.keyPressed: Enter joins the selected entry, a saved server
or, past the saved servers, the LAN game entry_address picks out of the
scanner, exactly as a click on the row does. The Shift+Up/Down swap beside it
is Screen::arrow, which moves the saved server and writes the list back.
2026-11-02 14:16:16 +00:00
MSedgeMC 3af0b2589f port map: Piglin.startRiding is riders::start_riding
The two-mob boarding in `mobs/riders.rs` carries the piglin case: a baby
piglin that boards a hoglin goes on top of the piglins already stacked there
(`top_passenger`, three at most), after the same tests vanilla makes.
2026-11-02 14:16:16 +00:00
HrafnaValkyrja 9c4eaca36e Merge remote-tracking branch 'origin/main' into fix/trident-selector 2026-11-02 09:15:53 -05:00
MSedgeMC b597fa981e port_map: createScreen is open_chat_method, and its draft gap is closed
open_chat_method is ChatComponent.createScreen: it restores the kept draft
when it can (a command draft only for /), opens the box with the tab's prefix
otherwise and records whether the box started on a draft, so the row names it
rather than open_chat and no longer claims no draft is restored.
2026-11-02 14:15:44 +00:00
MSedgeMC 98330eacf2 port_map: chat drafts are kept, so saveAsDraft is ported
keep_chat_draft is ChatComponent.saveAsDraft: the text left in the box is
kept as the draft, with its command flag, unless the screen was closed on
purpose with Save Chat Drafts off or the message was sent, and blank text
discards it. The Save Chat Drafts option is read from options.txt
(saveChatDrafts, default false) and shown as Opt::SaveChatDrafts, so the row
no longer says the option does nothing.
2026-11-02 14:15:44 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 4a326d839b Thrown tridents: reach them with @e, /kill and /data, and honour NoGravity
ThrownTrident::selector_target returned none, so @e, /kill, /data get and
the other entity commands could not find a thrown trident. Add a
Target::Trident, hold its UUID and saved data by entity id like a rocket,
and read, keep and write NoGravity (AbstractArrow.applyGravity skips the pull
for a NoGravity entity); before, a NoGravity trident still fell.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 09:15:43 -05:00
MSedgeMC f0bdb96bb1 port map: the block entity gaps say where the tree stops
Fourteen rows lose their "unsure" and name the code that decides the gap
instead: the chunk fix-up absent from `decode_block_entity`, the copper
golems' opener recheck standing in for a chest's, the shulker lid tick
counting neighbour updates both tick paths drop, and the sign text kept as
literal strings so nothing resolves a selector.
2026-11-02 14:15:32 +00:00
MSedgeMC 207e153292 port map: SignBlockEntity.tick folds into the sign write
The per-tick clearing of a sign's edit permission is the write path here:
`lan_sign_update` re-checks `playerIsTooFarAwayToEdit` on every update and
drops the guest's `sign_editing` when it is out of reach, so `tick` has no
counterpart of its own and the row is an inline.
2026-11-02 14:15:32 +00:00
Ludovic Marie 54bcf6887e Merge pull request #7248 from Akirabane/feat/management-gamerules-parity
🤖 Agent: queued at 5dedbb4ccb. Your own main merge (5dedbb4) matches the conflict resolution I tested (gamerule ids 200-208 after #7228's max-players ids). With #7249's heartbeat on top, horizon-server clippy is clean and its 120 tests pass. run-parity is on because this changes the management harness.
2026-11-02 15:15:21 +01:00
MSedgeMC 9a400530fe port_map: selectTab is Title::select_create_tab, and its gap is closed
select_create_tab sets the open tab by index and clears the row's focus, and
both the click path and the Ctrl+number path go through it, so the row names
it rather than the click handler and no longer claims a tab can only be
picked with the mouse.
2026-11-02 14:14:43 +00:00
MSedgeMC 224b8e2152 port_map: the Create World tab row takes Ctrl+number and Ctrl+Tab
The bar's key handling and its index math are in the tree now:
Title::tab_bar_key gates on Ctrl, on the rules and experiments screens not
holding the keys and on no world preparing, then clamps the index from
create_world::next_tab_index, where a digit is floorMod(key - 1, 10) and Tab
steps one tab forward or back, wrapping. Cite them instead of calling the
rows missing.
2026-11-02 14:14:43 +00:00
Ludovic MarieandClaude Opus 5.5 7e00efefc3 Pose mobs with glowing eyes on the GPU skin path
Spiders, cave spiders, endermen, phantoms, the copper golem and a
creaking with lit eyes went to the CPU path whole, body included: their
eyes layer (the eyes render type) is translucent, and the GPU path took
only plain opaque and cutout draws. With improved transparency off (the
default), those layers are ordinary entity quads, so they now join the
mob's skinned draw:

- a skin job's mode (was `item`) gains FLAT: the eyes layer's normal is
  all zero, as the CPU path writes it;
- the OIT tag and the eyes' HDR tier ride in the job's overlay word,
  the same bits the CPU path ORs into the vertex.

With improved transparency on, EntityBatch sorts those quads into its
stages by the CPU vertices' tag, so the mobs stay on the CPU path then
(`Renderer::skins_eyes`).

The every-mob test runs twice, eyes off and on: with eyes on, the five
mobs above are posed, byte-equal to the CPU path. The shader test
covers a FLAT job. Adds stress_eyes (1000 spiders, cave spiders and
endermen).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:14:23 +01:00
Ludovic Marie 97b205f2bd Merge pull request #7244 from arslanarm/shaderpacks/missing-images
🤖 Agent: queued at 428e9d94d1. I fixed the pack_post test for #7203's new `minimums` argument to `Buffers::new` after merging main (428e9d94). Programs whose image.* is undeclared now read a zero image instead of being skipped, which matches what packs expect; render clippy and tests pass.
2026-11-02 15:13:46 +01:00
Ludovic Marie 38b6dbb4f3 Merge pull request #7247 from arslanarm/shaderpacks/real-packs-game-macros
🤖 Agent: queued at fa7c5b0a7b. `read_properties` gives real_packs and the game one path to the properties and features, so the test now compiles packs with the macros players get (it used to miss the renderer's and IRIS_FEATURE_* defines). Main merged in (fa7c5b0a); tests pass.
2026-11-02 15:13:40 +01:00
Ludovic Marie e8025ecd77 Merge pull request #7246 from arslanarm/shaderpacks/block-emission-attribute
🤖 Agent: queued at bf7fbcb7eb. The emission table is built once per pack load (one pass over the block states), and the sprite-layer table grows from 2 to 3 ints a layer, so terrain pays one extra storage read per vertex only when a pack is on. Main merged in (bf7fbcb7); clippy and the shader_packs tests pass.
2026-11-02 15:13:34 +01:00
MSedgeMC 26438b9128 port map: what the recipe book does have for smithing and slot displays
The seven rows left as gaps for the recipe book now say what the code holds
instead of "unsure": the book's entries come from shaped, shapeless, furnace
and special recipes only, so a smithing recipe gets a display count and a
station and no slots; a synced `SlotDisplay::Composite` is parsed but never
resolved, the book's displays hold item ids rather than stacks, remainders
stay in `Recipes::remainders` for play, and no recipe display reads the
level's enabled_features.
2026-11-02 14:13:32 +00:00
MSedgeMC dec795da4c port map: KineticWeapon.computeDamageUseDuration is Kinetic::damage_use_duration
The spear's use duration (the delay plus the damage window) is
`Kinetic::damage_use_duration`, which `SpearUseGoal` and `SpearAttack` read
through `mobs/spear_use.rs`. `Ingredient.getSingleItem` is only asked for by
`BrewingRecipeBuilder.defaultId`, so it goes to the datagen skip with the
recipes Horizon reads from the JSON tables.
2026-11-02 14:13:32 +00:00
Ludovic Marie d55cea409f Merge pull request #7245 from arslanarm/shaderpacks/cloud-time
🤖 Agent: queued at 73f3e0e888. cloudTime reuses the clouds' own drift (now one shared `clouds::drift`), so the uniform and the drawn clouds cannot disagree; one rem_euclid a frame. Main merged in (73f3e0e8), fmt, clippy and the pack_uniforms tests pass on playhorizon.
2026-11-02 15:13:29 +01:00
levzzzandClaude Opus 5.5 145c2ef263 Render hooks: WorldDrawn says which entity each run of entity quads is
WorldDrawn::spans key entity runs by type, so a mod following entities from
frame to frame (a motion blur's motion vectors) could only match them by draw
order, and paired one pig's vertices with another's when one spawned, died or
was culled. WorldDrawn::motion passes on Frame::entity_motion, which the game
already records every frame for frame generation: each mob's, player's and
dropped item's vertices tagged with its entity id. WorldDrawn is
#[non_exhaustive], so no mod breaks; an API fragment numbers it.

Closes #7280

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:13:21 +05:00
arismoko a848ae0be2 cargo horizon-mod new: a native trait mod template
`--template native [--horizon <commit>]` writes a trait mod's bundle: its
horizon-mixin.toml, pinned to that Horizon commit or left unpinned, and an
[api] crate whose hooks() greets each player who joins. The files are
what docs/api/mod-files.md lets a bundle carry, so the launcher builds the
folder as it is and packs it into an .hnmod; a test packs it that way and
runs the registry's check on it.
2026-11-02 14:13:03 +00:00
arismoko 97e4713f37 Bow: no projectile lookup work without native mods 2026-11-02 14:11:04 +00:00
arismoko 5a1491137e Merge upstream main into w1-bow 2026-11-02 14:11:04 +00:00
arismoko 84e2ee50ca Potential spawns hook: borrow the list and keep the fast path without mods
Fix the local player's UUID, merge the SpawnEntry import, and move the
hook docs into the fragments' summaries.
2026-11-02 14:10:38 +00:00
Ludovic MarieandClaude Opus 5.5 b118744829 Stamp the living hooks' fragments (API 356-359)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:10:21 +01:00
Ludovic Marie e33c801291 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-02 15:09:37 +01:00
arismoko e3fa03a0e0 Native mods: every entity holds attachments
Holder::Entity refused experience orbs, projectiles, tridents, falling
blocks, fireworks, primed TNT, End crystals, the dragon and ominous item
spawners: they were saved through the memory found again by type and
position, with a new UUID once they moved, so attachments could not be
found under it. Every entity now keeps the UUID it was loaded with (or
was given) by entity id, reads its attachments back on load, and the
holder check walks entity_lists!, so a new kind holds attachments too.
The entity memory's pruning walks the same lists: it no longer drops the
UUIDs of rockets, falling blocks and tridents at each autosave.
2026-11-02 14:08:27 +00:00
Ludovic Marie 6e1635c1e1 Merge pull request #7221 from arismoko/v2/ses_01M6DYB8QYSC38EEXTNNJRQ8T4-body
🤖 Agent: queued at 171085bea3. Living heal/fall/jump/drown hooks: they fire on events (a heal, a landing, air at 0, a jump), not per entity per tick, through one indirect call over an empty mod list. Mob::heal now clamps to attrs.max_health and skips a dead mob, as LivingEntity.heal/setHealth do (a few call sites used kind.max_health before). Re-merged after #7005; clippy and heal tests pass.
2026-11-02 15:06:28 +01:00
Akirabane 5dedbb4ccb Merge remote-tracking branch 'origin/main' into feat/management-gamerules-parity
# Conflicts:
#	docs/server.md
2026-11-02 15:05:10 +01:00
Ludovic Marie 29c9509da0 Merge pull request #7224 from arismoko/v2/ses_01M6E1Z4GQ0S5X4JCHHMZRPF31-attachment-sync
🤖 Agent: queued at 507f93d5d3. Attachment sync: nothing is queued or sent unless a mod is loaded (hub.sync_attachments) and the client is a Horizon client, and attachment_map records a holder only when a mod borrows it, so the per-tick flush over empty queues costs nothing without mods. Merged main; clippy, the attachment tests and mod-api pass, and current main adds no hook drift.
2026-11-02 15:05:08 +01:00
Ludovic MarieandClaude Opus 5.5 cbd8e3b5b5 Merge main into #7258
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:04:39 +01:00
HrafnaValkyrja 756e5cba55 Merge remote-tracking branch 'origin/main' into fix/projectile-summon-gravity 2026-11-02 09:04:06 -05:00
TheMrZZandCodex gpt-6.1-sol ac0a69f04c Normalize setblock states before placement
Calculate neighbour-dependent state before comparing or placing a setblock input, then restore explicitly supplied properties. Preserve strict mode and cover the command cases in the vanilla corpus.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-02 15:02:57 +01:00
Ludovic MarieandClaude Opus 5.5 428e9d94d1 Shader pack image stand-ins: the test passes main's new storage-buffer minimums
#7203 added Buffers::new's minimums argument after this branch was cut.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:02:43 +01:00
Ludovic Marie 90d7eb1635 Merge pull request #7249 from Akirabane/feat/management-notifications-5
🤖 Agent: queued at 35bdb3d076. Activity and heartbeat notifications: a login records one cheap event and the server tells notification/server/activity at most once per 30 s (activity_due is tested at the 29/30/59/60 s edges); the heartbeat thread builds a status only while a management socket is open, and status-heartbeat-interval 0 starts no thread. horizon-server clippy and 120 tests pass merged with main.
2026-11-02 15:02:00 +01:00
Ludovic Marie 43cc5e0aa8 Merge pull request #7238 from JustKarkat/fix/boat-current
🤖 Agent: queued at aa73ebe5e1. Boat current e2e: runs in a mkdtemp folder as the game's cwd (where saves go) with lab.game_env's scratch homes and removes it unless --keep, so no player data is touched; tools-only, not wired into CI. Small nit for a follow-up: game_env(home=work) only sets an unused env var called home, since game_env makes its own scratch HORIZON_HOME.
2026-11-02 15:00:25 +01:00
Ludovic Marie 7081b5173f Merge pull request #7250 from Akirabane/docs/7232-server-guides-shipped
🤖 Agent: queued at fed0ff53ae. Server docs: the hosting guide lists kick/ban/pardon/ban-ip/pardon-ip as their own horizon-server commands and drops them from 'Coming', and server.md says init writes [panel], [api] and [discord] and that an update always backs up first, matching what ships.
2026-11-02 15:00:20 +01:00
Ludovic Marie 8297f3771e Merge pull request #7242 from arslanarm/shaderpacks/texture-query-lod
🤖 Agent: queued at 118bdbf9a5. textureQueryLod: an hz_textureQueryLod helper per sampler kind and dimension is emitted only when a stage calls it (the plain-program test checks it's absent otherwise), and the test validates the result through naga down to SPIR-V.
2026-11-02 15:00:15 +01:00
Ludovic Marie c0a5f29737 Merge pull request #7240 from arslanarm/shaderpacks/block-instance-names
🤖 Agent: queued at 56f749e836. Interface blocks: a redeclared gl_PerVertex is blanked, and an instance name that a local or parameter shadows (or that follows a .) keeps its own uses, so only the block's references are flattened; load-time token work, crate tests and clippy pass merged with main.
2026-11-02 15:00:10 +01:00
Evrim CicekandClaude Opus 5.5 06a4f6560a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:58:35 +03:00
Ludovic Marie 3db53df3bd Merge pull request #7241 from arslanarm/shaderpacks/work-group-size-expressions
🤖 Agent: queued at 6c52fb99bc. Work group sizes as constant expressions: each local_size_* expression up to its , or ) at depth 0 is evaluated, rewritten to the number with its newlines kept, and gl_WorkGroupSize uses the same values; a size it can't work out is still an error with the axis named.
2026-11-02 14:57:22 +01:00
Ludovic Marie 62b07d8380 Merge pull request #7239 from arslanarm/shaderpacks/anonymous-structs
🤖 Agent: queued at 5e2c8f5641. Anonymous structs: a struct keyword followed by { gets hz_structN before naga sees it, for draw and compute stages, and returns None when nothing changed so the code isn't copied; the shared-struct compute test compiles through translate_compute.
2026-11-02 14:57:18 +01:00
Ludovic Marie 192ec260cc Merge pull request #7237 from arslanarm/shaderpacks/version-in-include
🤖 Agent: queued at ceb77a5167. #version from an included header: the first #version read wins (the depth==0 condition is gone), so a pack whose stages include a common prelude gets GLSL 4.60 keywords such as shared; tested through translate_compute, and the crate passes merged with main.
2026-11-02 14:57:13 +01:00
Ludovic Marie 1b1bd4b00d Merge pull request #7236 from arslanarm/shaderpacks/unbalanced-conditionals
🤖 Agent: queued at 8cf2eaf48d. Unbalanced conditionals: a stray #endif is ignored and an open #if reads to the end of the file, both as warnings instead of failing the program; preprocessing runs at pack load, nothing per frame. horizon-shaders clippy and tests pass merged with main.
2026-11-02 14:57:07 +01:00
Ludovic Marie cbe52fea6a Merge pull request #7227 from arismoko/v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-panic-mods
🤖 Agent: queued at f90ca6390d. Panic attribution: with no mods the dispatch walks an empty slice, and with mods it adds one thread-local swap per mod call; the guard puts the outer call back when a loop exits early or a panic unwinds, and nothing is caught. I regenerated the dispatch after #7005 landed, so its 27 new hooks name the mod too (f90ca6390); clippy and the mod_api tests pass.
2026-11-02 14:57:02 +01:00
Ludovic MarieandClaude Opus 5.5 fa7c5b0a7b Merge main into #7247
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:55:50 +01:00
Ludovic MarieandClaude Opus 5.5 bf7fbcb7eb Merge main into #7246
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:55:49 +01:00
Ludovic MarieandClaude Opus 5.5 73f3e0e888 Merge main into #7245
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:55:48 +01:00
Ludovic MarieandClaude Opus 5.5 b94dd927e1 Merge main into #7244
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:55:45 +01:00
mmmaarian03-cryptoandClaude Opus 5.5 068a20e36f port-pack: entity predicates of carried loot tables in 26.3's keys
entity_properties and damage_source_properties predicates get 26.3's
sub-predicate keys (minecraft:entity_type, minecraft:flags,
minecraft:equipment, minecraft:vehicle..., type_specific as
minecraft:type_specific/<kind>, variants and sheep colour as
components): the game's loot code reads only those, so every such
condition of a carried mod table said no. A predicate already in 26.3's
shape is left as it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:55:24 +02:00
mmmaarian03-cryptoandClaude Opus 5.5 1884ed60db port-pack: mods.toml read tolerantly (unfilled templates, line by line)
A Forge jar may carry the other loader's metadata as an unfilled template
(Drip Sounds: [[dependencies.${id}]]): the first file that parses and names
a real mod id is the jar's; when none parses, the first [[mods]] is read
line by line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:55:21 +02:00
arismoko d926c8e3f3 Apply explosion knockback to flying players and dragons 2026-11-02 13:54:09 +00:00
Ludovic MarieandClaude Opus 5.5 171085bea3 Merge main into #7221
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:53:59 +01:00
Ludovic Marie 4956321f71 Merge pull request #7261 from horizon-rust/tools/pacing-world-changes
🤖 Agent: tools only; scenarios type commands in chat so the frame never blocks on the server tick, as horizon-ctl command would.
2026-11-02 14:52:56 +01:00
Ludovic Marie 1c99f4f882 Merge pull request #7260 from horizon-rust/perf/fill-relight-workers
🤖 Agent: past 4096 changed cells, edited columns go to worker light jobs; the stale-job risk is covered by the sequence check in the Lit handler, and light hashes after fill/clear match main.
2026-11-02 14:52:52 +01:00
Akirabane 770ebeab4d Merge remote-tracking branch 'origin/main' into feat/management-gamerules-parity
# Conflicts:
#	tools/test_management_protocol_parity.py
2026-11-02 14:51:48 +01:00
Florian ERNSTandCodex 2d73371393 Load configured spawner passengers with default entity fields
Normalize entity type identifiers and place every configured passenger before loading a spawner entity stack. Cover namespace resolution and actual passenger selection and vehicle relations.

Co-Authored-By: Codex <[email protected]>
2026-11-02 14:51:31 +01:00
Florian ERNSTandCodex 72b58bd112 Fix animal age clocks with NoAI
Run ageable updates outside server AI while preserving growth hooks and age locking. Cover age signs, AI modes, and locks in the mob regression and parity harness.

Co-Authored-By: Codex <[email protected]>
2026-11-02 14:51:30 +01:00
Ludovic Marie 836102991b Merge pull request #7199 from testatanucleare13-create/fix/6574-fishing-hooked-item-movement
🤖 Agent: queued at f622f09367. Fishing hook: levzzz5154's blockers are fixed at this head. The swept hit test inflates each target by computeMargin ((tickCount-2)/20 clamped to 0..0.3, counted after the tick's increment as vanilla's super.tick does), a path starting inside a box no longer hits (AABB.clip), and tests cover a hit inside the margin, a miss outside it and the inside start; the author re-ran lan_e2e fishing and fishing-item. Merged main; clippy and the fishing tests pass.
2026-11-02 14:50:08 +01:00
Ludovic MarieandClaude Opus 5.5 f90ca6390d Mod API dispatch regenerated for the hooks main added
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:49:55 +01:00
Ludovic MarieandClaude Opus 5.5 bc83bdd582 Merge main into #7227
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:49:36 +01:00
Ludovic Marie c608c23e1f Merge pull request #7228 from TiggerTakkyTTT/fix/server-management-max-players
🤖 Agent: queued at 55d24da573. Live max players: merged with main's allowlist settings (#7191), keeping both handlers; the parity test's max-players block moved to ids 100-108 and its restart read to 109, after the allowlist ones, since 75-94 are taken now. horizon-server (118 tests, clippy) and dedicated client tests pass; run-parity added for the management harness.
2026-11-02 14:49:01 +01:00
Alexander KrylovandHermes Agent 48c63bc09d Stamp the pending native hook fragments through API 355
The merged hook PRs left 202 fragments without numbers, so nothing in them is
API yet. This is the stamp pass the fragment README asks for after they merge:
one number per fragment in first-parent order, its catalog entry, its doc
paragraph and its comment marker, with the fragments consumed and the host
regenerated.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 16:48:27 +03:00
mmmaarian03-cryptoandClaude Sonnet 5.5 7bd3ccc656 Content root: hard-link the base files on Windows instead of copying them
mod_content::link mirrors the base resource root into the content root: a
symlink per entry on Unix, but a copy of every file on Windows, where symlinks
need a privilege. That is ~28,000 files and 0.6 GiB written again on every
rebuild, and the root is rebuilt after every update because its stamp holds
the executable.

A hard link needs no privilege on NTFS and costs a name, not the bytes. It
shares the base file's data, which is safe here because the root is only ever
replaced name by name (Overlay::write removes the name before it writes) and
nothing else writes into it. Where a link cannot be made (another volume, a
filesystem without links, the NTFS limit on links to one file) the file is
copied as before, and a symlink in the base is still copied through. The
resource-pack overlay's link has done the same since 3c7e4ba57.

Replaying the file operations of a 76-mod pack's rebuild (NVMe, Defender on),
the base step took about 24 s as copies and about 9 s as links. The same
build() on a prepared root with one small mod took 19-20 s with copies and
11-12 s with links.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 15:48:14 +02:00
Ludovic MarieandClaude Opus 5.5 2d257a0bdd Merge main into #6650
server_e2e: dialog_close now uses main's frame proxy (renamed PacketProxy,
with close_container through its send_packet) instead of a second proxy
class; the server runs with the proxy for book_menu or dialog_close.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:46:47 +01:00
Evrim CicekandClaude Opus 5.5 5cb1bf0afe server_entities harness: an item's last synced motion is the level's random's too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:45:53 +03:00
Ludovic Marie b9a80d6199 Merge pull request #7235 from horizon-rust/perf/frame-stand-items-gpu
🤖 Agent: armour-stand items go into the stand's already-borrowed Skin (new skin_item) instead of submit_item_skinned, which would re-borrow skin_frame and panic; frames get their own skinned draw. Skulls/maps/glint stay on CPU.
2026-11-02 14:45:38 +01:00
arismoko 4f3b375d25 Apply explosion knockback hook after entity damage 2026-11-02 13:44:46 +00:00
Ludovic Marie a9de2a1f75 Merge pull request #7149 from Domanffe/feat/native-mod-image
🤖 Agent: queued at a7c3be8ebd. Mod image: the 1024² slot is packed only when a native mod is loaded (mods().is_empty() read before the staging scope), so a game without mods keeps today's HUD atlas, with a test for both cases; the fragment no longer lists native_hooks.rs and the docs in comments, which the stamp tool refuses. This head's tree is the one I checked (clippy render+client, mod_image tests, mod-api, stamp).
2026-11-02 14:43:39 +01:00
HrafnaValkyrja c3f8675114 Merge remote-tracking branch 'origin/main' into refactor/entity-hooks-complete 2026-11-02 08:43:21 -05:00
Ludovic MarieandClaude Opus 5.5 f622f09367 Merge main into #7199
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:43:11 +01:00
Ludovic Marie 4f4f1db13c Merge pull request #7005 from Detimes/wave2-tier1
🤖 Agent: queued at d0248722b6. 27 hooks: with no mod loaded the hub queues no Input/Sneak events (mods_loaded set before each poll), and the Input, Sneak, Kicked, jump, projectile and release-use handlers return before any name clone or as_guest swap; the hopper's per-slot lookup sits behind the same check. Dyna's six findings are in (orb level-ups, one fire per /experience add, composter through inventory_move_item, real cart coordinates, kicks after Left, stampable markers).
2026-11-02 14:42:18 +01:00
arismoko b74cd0c600 Fix explosion knockback hook contract, dragon and creative flyers
Use the collect contract so mods see and change the calculated velocity
(replace generated code that did not compile), fire it for the ender
dragon and for creative players flying, and restore the lost comments.
2026-11-02 13:41:54 +00:00
Akirabane c769fc342c Merge remote-tracking branch 'origin/main' into fix/7080-armor-stand-world-border 2026-11-02 14:40:25 +01:00
Ludovic MarieandClaude Opus 5.5 507f93d5d3 Merge main into #7224
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:40:22 +01:00
arismoko 9245872736 Merge upstream main into the bow hooks
release_bow asks getProjectile before ArrowLooseEvent, as BowItem.releaseUsing
does, and the hook markers name their fragments.
2026-11-02 13:39:12 +00:00
Ludovic MarieandClaude Opus 5.5 0d93697144 Merge main into #7221
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:39:01 +01:00
HrafnaValkyrja 2aad087ad3 Merge remote-tracking branch 'origin/main' into fix/projectile-summon-gravity
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
#	crates/horizon-client/src/projectiles.rs
2026-11-02 08:38:22 -05:00
Ludovic MarieandClaude Opus 5.5 9e79fa3966 Mod API entries and regenerated output for #7227
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:37:59 +01:00
Copilot 0f254a1719 Match vanilla fishing hook hit margin
Co-authored-by: Copilot <[email protected]>
2026-11-02 13:37:55 +00:00
Ludovic MarieandClaude Opus 5.5 19419bea3d Merge main into #7227
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:37:43 +01:00
Ludovic Marie 7a037c7912 Merge pull request #7066 from glitchsnake/fix/6405-item-lore-rarity-tooltip
🤖 Agent: queued at 5bc55bc5b5. Rarity and lore: the JSON-in-a-string branches are gone, so a renamed [1] stays the literal name as ComponentSerialization decodes a string tag, and guest.rs sends a string lore as one literal line like the tooltip draws it; rarity round-trips as Int(3) ↔ "epic". Only leniency left: a single compound or string lore (not a list) shows as one line where vanilla's list codec would refuse it, which only affects malformed data.
2026-11-02 14:36:53 +01:00
TiggerTakkyTTTandCodex 33d789530f Add live pause timer management setting
Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-02 20:36:40 +07:00
Ludovic MarieandClaude Opus 5.5 55d24da573 Merge main into #7228
Main's allowlist settings (#7191) took management test ids 75-94; the max
players block moves to 100-108 and its restart read to 109, after the
allowlist ones. Both sides of the setting handlers are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:35:54 +01:00
AkirabaneandClaude Sonnet 5.5 35bdb3d076 docs: the management protocol's activity and heartbeat notifications
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:35:38 +01:00
AkirabaneandClaude Sonnet 5.5 8d0122354f horizon-server: the management protocol's status heartbeat
ManagementServer.scheduleHeartbeat runs NotificationService.statusHeartbeat with scheduleAtFixedRate
(interval, interval) in seconds, and nothing when status-heartbeat-interval is 0; the notification
carries ServerStateService.status. The supervisor, which serves the protocol, runs the same timer from
the property and sends the status server/status answers (up or not) to whoever is connected, and
computes nothing when nobody is.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:35:38 +01:00
AkirabaneandClaude Sonnet 5.5 df382b701b horizon-server: the management protocol's server/activity notification
MinecraftServer builds a ServerActivityMonitor with SERVER_ACTIVITY_MONITOR_SECONDS_BETWEEN_NOTIFICATIONS
= 30. Its processWithRateLimit tells the notification service when an activity was reported and thirty
seconds have gone by since the last one told, and clears its flag whatever it did, so a login inside the
window is lost and not told later. The supervisor applies the same rule to the game's login_activity
events.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:35:38 +01:00
AkirabaneandClaude Sonnet 5.5 5f5806a1e3 game: the login gate records a login_activity event
ServerActivityMonitor.reportLoginActivity is called when a login starts, however the login ends. Every
login attempt of a dedicated server goes through the game's login gate, which now records a typed
login_activity event for the supervisor to rate limit into the management protocol's server/activity.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:35:38 +01:00
arismoko 2f470c7a82 Add village siege native hook 2026-11-02 13:35:33 +00:00
Evrim CicekandClaude Opus 5.5 ab41c7eebc First updates in the pass for spawns and thunder; joined states kept fresh
ServerChunkCache.tickChunks' natural spawning, custom spawners and thunder,
and the raids, run before ChunkMap.tick too: their entities are first
updated in the pass that pairs them, as ranges of ids rather than one
cutoff. The client paired in that pass gets the tick-0 changes after its
pairing, as vanilla's updatePlayers precedes sendChanges.

Joined states are used only in the tick after the one that noted them, are
noted only for a level a guest is in, keep the minecart steps and needs_sync
of the tick's own state, and are looked up by id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:35:09 +03:00
Akirabane fed0ff53ae Merge origin/main into docs/7232-server-guides-shipped 2026-11-02 14:34:57 +01:00
Arslan ArmanulyandClaude Opus 5.5 37ba266d56 Shader packs: real_packs reads packs with the game's macros
The real_packs test preprocessed every stage and translated the
composite-style programs with `Macros::standard()` alone: no renderer
macros (`MC_GL_VENDOR_OTHER`, `MC_MIPMAP_LEVEL`...) and no `IRIS_FEATURE_*`
for the features a pack turns on, so a pack taking a path behind them
counted differently from the game.

`read_properties` reads `shaders.properties` with the pack's options over
the given macros and defines the features it turns on; the game and
real_packs both use it, and real_packs adds the renderer's macros at Mipmap
Levels 4 for all of its passes.

Fixes #7206

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:34:47 +05:00
Ludovic MarieandClaude Opus 5.5 d0248722b6 Merge main into #7005
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:34:04 +01:00
Ludovic Marie c120ac2e84 Merge pull request #7214 from arismoko/v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-mixin-docs
🤖 Agent: queued at c1649ac88e. Docs for the typed API from mixin code: checked against main, AttachmentHost is implemented for dyn Host (mod_api.rs:421) and not for Game, so the coercion step it stresses is the one that matters, and LevelRandomHost is the trait the GameLevel bindings name.
2026-11-02 14:33:56 +01:00
AkirabaneandClaude Sonnet 5.5 918febd6af tools: the management parity script covers the game rules and the notifications of the lists
It sends gamerules and gamerules/update (a rule set twice to the same value, an integer rule, a value out of
range, of the wrong type, an unknown rule and a missing key) to vanilla's server and Horizon's and compares
the answers. The game rules come in the order of a map keyed by identity in vanilla, which changes from run
to run, so the list is compared sorted by key. It also compares the notifications both servers send for
the same messages, name by name and count by count, for the ones Horizon sends from the lists and the rules
(the others depend on timing).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:33:34 +01:00
AkirabaneandClaude Sonnet 5.5 f6dbc7fa78 game: gamerules/update tells and logs on every update, and answers vanilla's errors
The parity script, run against the pinned vanilla server, showed two differences from the first version of
gamerules/update:

- vanilla's MinecraftGameRuleServiceImpl.updateGameRule sets, logs and tells the notification service on
  every update, also when the rule already has the value (three updates, three notifications), where this
  set the rule only when its value changed;
- vanilla's codecs answer a value or a key that doesn't fit with their own texts: "Value -1 outside of
  range [0:2147483647]", "Not a boolean: \"yes\"", "Unknown registry key in ResourceKey[minecraft:root /
  minecraft:game_rule]: minecraft:nope" and "No key key in MapLike[{...}]". Those are now Horizon's
  (a value that is not a number answers "Not a number: ..." as DFU's number codec does, which the script
  doesn't cover).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:33:34 +01:00
AkirabaneandClaude Opus 5.5 d5a097e0f0 Test that the world border stops an armor stand
A stand moving 1.5 east from x = 24 toward a border wall at x = 25:
past it without a border, its box ending on the wall with one.

Refs #7080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:33:32 +01:00
AkirabaneandClaude Opus 5.5 df01ddedb6 Armor stands stop at the world border
Entity.collectColliders adds WorldBorder.getCollisionShape for any
entity inside and close to the border, but ArmorStand::tick resolved
its move against the blocks alone, so a pushed or falling stand went
through the border. Its tick now takes the level's border and resolves
through SceneCollisions::resolve_in_border.

Refs #7080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:33:32 +01:00
arismoko 355becd82d Wire explosion knockback for other entities and wind 2026-11-02 13:33:21 +00:00
arismoko fd75a600c5 Apply potential spawn hook to validation lookup 2026-11-02 13:33:17 +00:00
Ludovic Marie 930931172d Merge pull request #7220 from Akirabane/feat/management-notifications-4
🤖 Agent: queued at 670867a1fb. Management game rules: gamerules/update sets the rule only when the value changes and checks int bounds (rule.min..=max) before it, and /gamerule goes through the same set_game_rule, so both notify; the event keeps the type as valueType (the event's own type field) and the server maps it back to TypedGameRule's type.
2026-11-02 14:32:38 +01:00
Akirabane 8c25a711a4 Merge origin/main into docs/7232-server-guides-shipped 2026-11-02 14:32:34 +01:00
Ludovic Marie 2edd83fddb Merge pull request #7217 from Qwerner88880/fix-crafted-recipe-ids
🤖 Agent: queued at c2e8cc0fe2. Crafted recipes: award_crafted now passes the whole id (crafting::qualified adds minecraft: only to a bare name), so a recipe unlocked by crafting reaches the book and a recipe_crafted criterion matches; modded namespaces are left as they are. One String per craft, nothing per tick.
2026-11-02 14:32:34 +01:00
AkirabaneandClaude Sonnet 5.5 8bdd4f3bfe docs/server.md: what the server code has passed
- section 4: init writes [panel], [api] and [discord] today (config.rs, the
  default template), so "PR 4 adds [panel]" is no longer true; there is no
  backups.before_update setting (Backups has only every and keep), because an
  update always backs the world up first (update.rs, switch_stopped).
- section 7: the backup before an update is made ("later also before an
  update").
- Docker: the panel runs inside the container (`horizon-server run` starts it
  on [panel] bind, supervisor.rs), not "later".
- section 9: Horizon sends the locator bar since #692, merged the day of the
  equal-work run.

Planned items the code has not reached stay as they are (prepare,
import-resources, the notifications #7220 is changing).

Refs #7232

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:32:21 +01:00
JustKarkat aa73ebe5e1 tools: an end-to-end check that flowing water pushes a boat
A headless single-player game builds a source-fed trench, summons an oak boat
on the flowing water and reads its Pos every ten ticks: vanilla's
Entity.baseTick pushes a boat by the water current (0.014) before floatBoat
scales the velocity, so the boat must drift downstream. Without that step the
boat sits still (#6435).
2026-11-02 07:32:01 -06:00
arismoko 92ecb918ed Add potential natural spawns hook 2026-11-02 13:31:58 +00:00
AkirabaneandClaude Sonnet 5.5 f9d57d8bcf docs/hosting.md: console, mcp, players, kick, ban and pardon ship
The Coming paragraph said console, ban, pardon and mcp were still to come.
console and mcp are in main.rs (the commands list already shows them), and
players, kick, ban, pardon, ban-ip and pardon-ip are dispatched there too
(cli::players, cli::moderate). Drop the paragraph and list the moderation
commands with the others.

Refs #7232

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:31:51 +01:00
arismoko e1084abe75 Add explosion knockback hook for players 2026-11-02 13:31:44 +00:00
Ludovic Marie beed48fe3a Merge pull request #7222 from arismoko/v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-crash-mods
🤖 Agent: queued at c029313d6c. Crash reports: the native mod list comes from native-mods.json already read at start, set once as a crash detail; nothing per frame, and a release or Nightly (no file) adds nothing.
2026-11-02 14:31:43 +01:00
Ludovic Marie a68e1fe449 Merge pull request #7219 from 4404c/fix/spyglass-arm-pose
🤖 Agent: queued at b814de1118. Spyglass arm pose: head xRot − 1.9198622 (− 0.2617994 crouching) clamped to [−2.4, 3.3], yaw ±0.2617994 per side, as HumanoidModel's SPYGLASS case; only while the spyglass is in use, from either hand.
2026-11-02 14:31:39 +01:00
Ludovic Marie e586e02963 Merge pull request #7223 from TheMrZZ/fix/7216-scoreboard-remainder-overflow
🤖 Agent: queued at 52453208cd. Scoreboard %=: adds the divisor only when the remainder's sign differs, which is Math.floorMod and cannot overflow; the boundary table covers MIN % -1 = 0 and 1 % MIN = MIN+1, and the corpus row puts it under the vanilla oracle too.
2026-11-02 14:31:34 +01:00
Ludovic Marie fd6cfda6bd Merge pull request #7229 from masa-masa-0314/fix/summon-cancelled-join
🤖 Agent: queued at d9ae51fe46. Summon with a mod cancelling the join: spawn_mob_spawned now returns None when the join callback removed the mob, before equipment and jockeys, and a cart's join is flushed before success is reported, so /summon fails with commands.summon.failed.uuid like vanilla's addFreshEntity false.
2026-11-02 14:31:30 +01:00
Ludovic Marie b3796614e3 Merge pull request #7213 from TheMrZZ/fix/7210-ci-test-target-reuse
🤖 Agent: queued at 57a0816bab. CI: same --lib --bins --tests selection as the build before it, so the ignored mod_content run reuses its artifacts instead of rebuilding 69 crates (~105 s per verify).
2026-11-02 14:31:24 +01:00
Ludovic Marie ad044b5aa2 Merge pull request #7163 from glitchsnake/perf/5093-clouds-fps
🤖 Agent: queued at f60c7e53d4. Clouds: measured 124.7 → 84.5 µs per cell crossing at radius 32; the ring list is cached per radius and CellIter skips the x values whose z is past the radius, keeping the old flat_map order, and faces are still emitted in faces()'s order (outer [1,0,2,3,4,5], then inner), so translucent clouds blend as before.
2026-11-02 14:31:20 +01:00
Ludovic MarieandClaude Opus 5.5 f5499edbee Draw item frames' and armour stands' items on the GPU skin path
An item frame's item and an armour stand's hand and head items were
posed and lit vertex by vertex on the CPU every frame, while the
stand's body already took the GPU skin path. Items whose baked model
can take that path (opaque or cutout, no glint, emission or
translucency) now do too: a frame's item as its own skinned draw,
culled by its bounds; a stand's items inside the stand's open skin
draw, culled and truncated with its body. Skulls, maps and other
special models keep the CPU path.

The byte-for-byte test already poses every item in the fixed, hand and
head contexts against the CPU vertices.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:31:09 +01:00
Arslan ArmanulyandClaude Opus 5.5 9d88442132 Shader packs: a line comment ending in a backslash runs on
A single-line comment ending in the line-continuation character includes
the next line in the comment (GLSL 4.60, 3.4). Iris 1.11.7 does so (a probe
pack at `#version 330 compatibility`: a `#define` on the line after such a
comment, after code or alone, stays undefined). Horizon ended the comment at
the line break and read the next line as code or a directive. The comment
now runs on; its line breaks stay, so later lines keep their numbers.

Fixes #7233

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:30:57 +05:00
Arslan ArmanulyandClaude Opus 5.5 cdcee209cb Shader packs: images no image.* gives read zero instead of skipping the program
A program that declares a storage image no `image.<name>` entry gives was
skipped ("image `x` is not provided yet"). Iris 1.11.7 runs such a program,
and its loads read zero, also after it stored into the image (observed with a
probe pack of our own). Such an image now binds a one-texel stand-in of its
declared format and dimension, cleared before every pass that binds it, and
the log says it reads zero.

With Shrimple v0.12 this runs composite13, composite17 and gbuffers_water,
which main skipped.

Part of #6812

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:30:00 +05:00
Alexander Krylov a7c3be8ebd Merge main into #7149, regenerating the mod API host 2026-11-02 16:29:32 +03:00
Ludovic Marie c5c8a33a8a Merge pull request #6910 from arismoko/v2/ses_01M6DKF0Z8BE7TF075HX2BZ8T2-b83f524e8607
🤖 Agent: queued at 72ce1a6186. Anvil craft hooks: a cancelled Pre restores the anvil, cost, inventory and cursor from the snapshot taken inside the click, so cancelling no longer dupes the result; without mods the take stays in the click as AnvilMenu.onTake runs it. I pushed 72ce1a618: the fragments' markers did not match their file names, so stamping would have left @API:anvil_craft_*@ in the docs.
2026-11-02 14:29:03 +01:00
Ludovic MarieandClaude Opus 5.5 74131a74c0 pacing_bench: world-change scenarios (TNT, fill, fluids, fire, pistons)
Each is built in the air in front of the camera and recorded while it
runs, typed in chat like a player does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:26:19 +01:00
Ludovic MarieandClaude Opus 5.5 ab7598908c Relight big edit batches on the light workers
A /fill of 64x64x16 arrives as ~32k single block changes in one frame.
Relighting each in place cost the frame 40-60 ms (sky light under the
cleared volume). Past 4096 changed cells, the columns the edits touch get
full light jobs on the workers, as World::edit_runs already does; edits in
columns that can't be lit whole yet (the edge of the loaded area) stay in
place, so one stray edit no longer pulls the whole batch back in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:26:00 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 552cbc3749 Summoned spectral arrows, shulker bullets, llama spit and experience bottles fall
These four entity ids had no loader when a summon or a chunk creates them
from NBT, so they were kept as inert records that nothing ticked: 20 ticks
from y 110.0 they stayed at 110.0, where vanilla gives 101.0465 (spectral
arrow), 101.6 (shulker bullet) and a landed, removed spit and bottle.

Load them as projectiles: a spectral arrow as an arrow whose pickup item is
the spectral arrow (and save it under its own id), a llama spit and a
target-less shulker bullet from Projectile's keys, an experience bottle as
the bottle item with gravity 0.07. The shulker bullet's tick now uses
getGravity (0.04, none under NoGravity) instead of a fixed 0.04, and its
target is optional.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 08:25:07 -05:00
masa-masa-0314andCodex d9ae51fe46 Commands: fail summon when a mod rejects a mob or minecart
Return None when a spawn's join callback removes the mob, propagate it through summon, and flush minecart joins before reporting success while watching the new cart's ID.

Fixes #7184

Co-Authored-By: Codex <[email protected]>
2026-11-02 21:24:26 +08:00
Loki 7c842b9356 Merge remote-tracking branch 'origin/main' into perf/4128-explosions-fps 2026-11-02 22:23:02 +09:00
LokiandAntigravity f2533d796f particles: queue explosion debris up to 512 per tick (fixes #4128)
Co-Authored-By: Antigravity <[email protected]>
2026-11-02 22:22:57 +09:00
Ludovic MarieandClaude Opus 5.5 c3a8f6546c Merge main into #7005
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:22:38 +01:00
Arslan ArmanulyandClaude Opus 5.5 f183e706f5 Shader packs: Iris's BLOCK_EMISSION_ATTRIBUTE, the block's light in at_midBlock.w
`iris.features.*` may name BLOCK_EMISSION_ATTRIBUTE (ShaderDoc, "Feature
Flags": block emission in `at_midBlock.w`). Horizon left `.w` at 0 and
logged the feature as not supported (RenderPearl v2.7.2 and Visual Vibrance
v0.3.5a require it). With a probe pack, Iris 1.11.7 passes the block's light
level there, 0 to 15 (glowstone 15, torch 14, lit furnace 13, soul torch 10,
redstone torch 7, stone 0).

Terrain vertices carry their sprite layer, not their block, so the light
travels as `mc_Entity` does: in the per-layer table of flat terrain, now
three values a layer, a layer taking the light of the block named like its
sprite, else what most states drawing it give (ties: the lowest). A sprite
that blocks of different light share (a lit furnace's side, a jack
o'lantern's pumpkin side) reads the majority's.

Fixes #7205

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:22:07 +05:00
Ludovic MarieandClaude Opus 5.5 5bc55bc5b5 Merge main into #7066
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:21:54 +01:00
Stink-OandClaude Opus 5.5 bef10987b3 Native mods: block textures for client mods (native_block_sprites, WorldDrawn::block_sprites)
A client mod drawing blocks in its own pass (flying debris as voxels, say) can now use the player's own block
textures, resource packs included, as the terrain samples them.

- `block_sprites::Lookup` (render crate) answers, for a block state (`oak_log[axis=x]`), what its model draws on
  each of its six sides as the mesher bakes it: each face's sprite (array and layer, as the renderer cuts the
  arrays), where it lies on the side and how deep, the affine map from the side to the sprite's UVs (rotations and
  mirrors included), its tint source (`MeshTables::tint_source`) and whether it is cut out, translucent or
  animated; its particle sprite; whether it is a plain cube. Fluids give their still texture on every side.
- Helpers `native_block_sprites`, `native_texture_sprites` and `native_block_sprites_version` (a helper-only
  fragment; the Host regenerated). The version is `RenderAssets::generation`, which counts resource loads.
- `WorldDrawn::block_sprites` (`BlockSpriteArrays`): the terrain's eight array views (with the Mipmap Levels
  option's levels), the device's split (`Renderer::sprite_split`), the layer size, a nearest repeating sampler and
  the terrain's own filtered one.

Tests: the UV map; a GPU test that a pass samples a layer of the second array at the texels it names through the
views and the nearest sampler (repeating past the edge); an ignored test against prepared resources (planks, a
grass block's overlaid side, a log on its side, leaves, water, a slab, a flower, unknown names).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:20:59 +00:00
Ludovic MarieandClaude Opus 5.5 f60c7e53d4 Merge main into #7163
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:20:57 +01:00
Ludovic MarieandClaude Opus 5.5 72ce1a6186 Anvil craft fragments: their own marker names
A fragment's marker is @API:<file name>@; the summaries said
@API:anvil_craft_*@, which stamping would have left in the docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:20:11 +01:00
Arslan ArmanulyandClaude Opus 5.5 118bdbf9a5 Shader packs: textureQueryLod
`textureQueryLod` (GLSL 4.60, 8.9.1) has no counterpart in naga, so a program
calling it failed ("Unknown function 'textureQueryLod'"): with RenderPearl
v2.7.2 every program including lib/material/normal.glsl. A program that
calls it now gets ours: `y` is the level of detail relative to the base
level, worked out as texturing does (OpenGL 4.6, 8.14.1: the log2 of the larger
texel-space change of the coordinates between neighbouring pixels), and `x`
that level clamped to the texture's levels; for 2D, 2D array, 3D and cube
samplers of float, int and uint, and through a pack function taking the
sampler as `textureGatherOffsets` is.

Fixes #7170

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:19:37 +05:00
Arslan ArmanulyandClaude Opus 5.5 6c52fb99bc Shader packs: a compute work group size may be a constant expression
GLSL takes an integer constant expression for `local_size_*` (GLSL 4.60,
4.4: a layout qualifier's value is an integer-constant-expression; 4.4.1,
compute shader inputs). RenderPearl v2.7.2's `deferred` keeps its size within
the implementation's with `local_size_x = min(gl_MaxComputeWorkGroupSize.x,
N)`. Horizon took a number only ("`local_size_x` is not a number
gl_WorkGroupSize can take"), and naga reads none of it. The expression is
now worked out (numbers, + - * / %, brackets, min, max, and
gl_MaxComputeWorkGroupSize as the minimum GLSL requires, 1024, 1024, 64;
7.3) and written in its place, and gl_WorkGroupSize is its value as before.

Fixes #7171

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:19:13 +05:00
Arslan ArmanulyandClaude Opus 5.5 56f749e836 Shader packs: block instances hidden by a local, and a redeclared gl_PerVertex
Two declarations of RenderPearl v2.7.2 left its interface blocks unflattened
or its position unwritten:

- A block whose instance name is also used in its stage other than as
  `instance.member` counted as "used whole" and stayed as written, so naga
  refused its members' `layout(location, component)` ("Expected Void,
  Struct or a type, found Layout"). For RenderPearl that use was not read; a
  probe with a function parameter named like the instance reproduces the
  error. A local or parameter of that name hides the instance in its scope
  (GLSL 4.60, 4.2), and `x.v` is another struct's member: neither is the
  instance now.
- A redeclared `out gl_PerVertex { vec4 gl_Position; };` (GLSL 4.60, 7.5)
  became a plain output, so the vertex stage wrote no position. The
  redeclaration goes; its members stay the built-ins.

Part of #7171

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:18:48 +05:00
Ludovic MarieandClaude Opus 5.5 501b015ec3 Merge main into #6910
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:17:23 +01:00
Arslan ArmanulyandClaude Opus 5.5 ceb77a5167 Shader packs: a stage's #version may come from a header it includes
RenderPearl v2.7.2 starts every stage with `#include "/prelude/core.glsl"`,
whose first line is `#version 460 core`. Horizon read `#version` from the
stage file only, so these stages counted as GLSL 1.10: words that became
keywords later were renamed (`shared` -> `hz_kw_shared`) and its compute
programs failed ("Expected end of file, found Identifier(\"hz_kw_shared\")").
The first `#version` read is now the stage's, from an included file too.

Part of #7171

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:17:20 +05:00
Ludovic Marie 0f0fa62dc2 Merge pull request #7195 from JustKarkat/feat/native-screen-hooks
🤖 Agent: queued at fb3d0b8f6f. Title screen hooks: the render-post call on the title only builds a NativeScreenView (cursor, clock fraction) and calls an empty hook when no mod is loaded, so the title pays nothing measurable; typing stays with the title's own boxes (title_owns_text) and Options over the title keeps its own. I pushed fb3d0b8f6: the fragment listed mod_api.rs in comments, which the stamp tool rejects.
2026-11-02 14:17:17 +01:00
Florian ERNST 52453208cd Fix scoreboard remainder overflow for large divisors 2026-11-02 14:16:12 +01:00
Ludovic Marie d2584c9206 Merge pull request #7212 from horizon-rust/perf/dx12-shader-disk-cache
🤖 Agent: verify green on 8bba5bd3. Only DX12 is affected: the cache file is named only when the instance includes the DX12 backend, and Vulkan, Metal and GL never touch it. Windows run 41447442462 rebuilt every pipeline from cached bytecode on WARP, and later starts fell from about 3.3 s (FXC) to 0.22 s. The binary size does not change.
2026-11-02 14:15:39 +01:00
Ludovic MarieandClaude Opus 5.5 e0bcf9ab8a Merge main into #7149
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:13:54 +01:00
Ludovic MarieandClaude Opus 5.5 8576ed03f9 mod_image fragment: stampable
The stamp tool rejects native_hooks.rs and docs/native-mods.md in
`comments` (it stamps both itself), so the stamp test failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:13:53 +01:00
Ludovic Marie d32802e4aa Merge pull request #7209 from Obstacleee/feat/mod-list-join-check
🤖 Agent: queued at ca7459f68d. Join check by version and hash: costs nothing on a server without content mods (no extra round trip). I pushed one fix: content_root runs again on every pack reload, and it re-read and SHA-256'd every .hmod there although set_local keeps the first list; it now skips that once the list is set.
2026-11-02 14:13:25 +01:00
Evrim CicekandClaude Opus 5.5 a706e19c59 Clients are paired with an entity added late in the tick as it joined
An entity added after the tick's tracking pass (the item a falling block
drops when it breaks on a torch) was first seen by the next pass, after its
first tick had moved it: its AddEntity carried that position and motion.
The end of the tick now notes such entities as they are then, and the next
pass pairs them from it, as ChunkMap.addEntity does when they join.

ServerLevel.tick runs ChunkMap.tick after the block ticks and before the
entities, so an entity that joined by then (a falling block from its
scheduled tick, the last tick's late joins) has its first update, tick 0 of
its interval, in the pass that pairs it, before it moves.

Fixes #7194

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:12:24 +03:00
NazaandClaude Opus 5.5 b814de1118 Merge main: its ledger rows on the held-to-eye drawing stand
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:11:58 -03:00
Ludovic MarieandClaude Opus 5.5 ca7459f68d Merge the author's docs update into #7209
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:11:49 +01:00
Lucas DelonandClaude Sonnet 5.5 08c5ffc3d3 docs: the join check sends only side = "both" mods; a manifest without a side is client
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:11:23 +01:00
NazaandClaude Opus 5.5 c004afd19f Third person: a spyglass in use raises the arm
HumanoidModel's SPYGLASS arm pose was missing, so the arm stayed down
while the spyglass was drawn at the eye. The ledger's two rows on the
held-to-eye drawing are out of date too: held_to_eye_matrix does it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:11:07 -03:00
Ludovic MarieandClaude Opus 5.5 fb3d0b8f6f Hook fragment for the title's screens: stampable
The stamp tool rejects mod_api.rs in `comments` (it stamps that file
itself), so the stamp test failed; the summaries now read after its
"API N adds".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:10:56 +01:00
levzzz ab2d9f9649 Merge pull request #7111 from MSedgeMC/chore/ledger-game-001
Clear stale game-001 rows main already implements
2026-11-02 18:09:46 +05:00
levzzz da91621e35 Merge pull request #7119 from horizon-rust/fix/lan-auth-harness-client-lib
lan_auth harness: run its test in horizon-client-lib
2026-11-02 18:09:35 +05:00
levzzzandClaude Sonnet 5.5 c4704bcabc Native mods wave 2: review fixes (levels from orbs, composter moves, vehicle positions, kicks, markers, hot paths)
- player_level_change: report orb, advancement reward and station level changes; stop the double
  fire on /experience add.
- inventory_move_item: composter bone meal goes through it; chest boats and minecarts are reported
  at the block they are in.
- player_kicked: fires even when the guest left in the same poll.
- Doc comment markers use the fragment names the stamper numbers.
- Drop player_item_break: native_player_destroy_item (#7025) covers it.
- No mod loaded: no Input/Sneak events, clones, as_guest swaps or per-slot lookups for the hooks.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 18:07:45 +05:00
Ludovic Marie 3024f03968 Merge pull request #7215 from horizon-rust/perf/held-items-gpu
🤖 Agent: held items join the mob's skin draw only for opaque/cutout models without glint; outlined/glowing mobs and first-person hands stay on CPU; byte-for-byte test covers all 5 contexts (15514 states).
2026-11-02 14:07:34 +01:00
AkirabaneandClaude Sonnet 5.5 670867a1fb docs: the management protocol's game rules
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:07:11 +01:00
AkirabaneandClaude Sonnet 5.5 4910a29ddc horizon-server: the management protocol's gamerules, gamerules/update and gamerules/updated
The supervisor passes gamerules and gamerules/update to the game's management request and answers with
the typed rules (an "invalid" answer becomes an invalid-params error), and turns the game's gamerule
event into notification/gamerules/updated, whose parameter is the typed rule, whoever changed the rule:
the protocol or /gamerule.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:07:11 +01:00
AkirabaneandClaude Sonnet 5.5 9540537547 game: the management request answers gamerules and gamerules/update
GameRulesService.get lists every rule the server has as a typed rule ({key, type, value}) and
GameRulesService.update sets one: the value is checked against the rule's type and bounds, and the rule
is set and logged as JsonRpcLogger logs it only when the value changes (the log names the rule by its
short name). A rule or a value that doesn't fit answers "invalid", which the supervisor turns into an
invalid-params error in the next commit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:06:50 +01:00
AkirabaneandClaude Sonnet 5.5 3d10e4cb79 game: one place sets a game rule, and a dedicated server records the change as an event
Vanilla's GameRules.set ends in MinecraftServer.onGameRuleChanged, which tells the notification service
of the management protocol. The /gamerule command set the rule and synced the world itself; Game
::set_game_rule is now what does both for the command and for the protocol that follows, and on a
dedicated server it records a typed gamerule event (the rule's id, the type of its value, the value)
that the supervisor will turn into the notification.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 14:06:50 +01:00
levzzzandClaude Opus 5.5 98dff424cf native_instance_dir fragment: end with a newline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:06:49 +05:00
levzzzandClaude Sonnet 5.5 5b2b7ac2c5 Refuse a cancelled AnvilCraftEvent.Pre take; hook the anvil take only with mods
A cancelled Pre left the result taken while the inputs stayed and no levels
were charged, a dupe. The menu now snapshots before the click and a cancel
restores the result slot, inputs, cost, inventory and cursor (the station
resyncs), and the click's drops wait for the hook's answer. The deferred take
is on only when a mod is loaded, so a server without mods runs
AnvilMenu.onTake in the click as before.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 18:06:14 +05:00
levzzz 050529f5c5 Merge remote-tracking branch 'hr/main' into fix6910
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
2026-11-02 18:06:14 +05:00
levzzzandClaude Sonnet 5.5 85aba02560 Native screen hooks: API fragment, title text routing
Number the instance_dir helper and the title-screen hooks through a
native-hook fragment instead of a hand bump; keep Options (and any screen
opened over the title) typing into its own boxes; document that
native_screen_char_typed_pre does not fire from the keyboard on the title's
own screens.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 18:06:07 +05:00
Ludovic MarieandClaude Opus 5.5 3de28e2331 Merge main into #7209
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:05:46 +01:00
Ludovic MarieandClaude Opus 5.5 18e168cf9b Join check: hash the mods' files once, not on every pack reload
set_local keeps the first list, so a pack reload read and hashed every .hmod
for nothing; it now skips that once the list is set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:05:45 +01:00
LokiandAntigravity 3e89286a4e clouds: simplify flat cell emission and consolidate prepare loop (fixes #5093)
- Replace manual vertex pushes in emit_flat_cell with two push_quad calls
- Drop redundant duplicate power-of-two prepare loop in favor of unified loop
- Verification benchmark: Mesh::prepare at radius 32 (128x128 texture, 300 steps)
  main: 124.70 µs (0.125 ms) vs branch: 84.51 µs (0.085 ms) per cell crossing (1.48x speedup)

Co-Authored-By: Antigravity <[email protected]>
2026-11-02 22:05:38 +09:00
Ludovic Marie 993eabe86b Merge pull request #7198 from Akirabane/feat/management-notifications-3
🤖 Agent: queued at 80f5c34206. List notifications: clear() tells one removal per entry before emptying, as ServerOpList.clear and UserWhiteList.clear do in 26.3 (checked); the watcher lives on the lists, so console, in-game commands and protocol methods all notify.
2026-11-02 14:05:34 +01:00
Ludovic MarieandClaude Opus 5.5 9598faaba1 Draw mobs' held items on the GPU skin path
A mob's main- and off-hand items were posed and lit vertex by vertex on
the CPU every frame, even when the mob's own body went through the GPU
skin path. Items whose baked model can take that path (opaque or cutout,
no glint, emission or translucency) now join the mob's skinned draw:
one job per item with the hand pose, culled and truncated together with
the body. Outlined or glowing mobs and first-person hands keep the CPU
path, which outlines need.

The byte-for-byte test now poses every item in the ground, both
third-person hands, fixed and head contexts: 15514 states take the GPU
path and match the CPU vertices exactly; 1066 stay on the CPU.

Adds stress_held (1000 mobs holding two items each) to the stress
scenes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:05:32 +01:00
Ludovic Marie e661a7760f Merge pull request #7203 from arslanarm/shaderpacks/storage-buffer-min-size
🤖 Agent: queued at 35972b0eab. Storage buffers: buffer_minimums walks the group-4 storage globals of every program once at pack load (not per frame) and sizes each buffer to the larger of the declared bytes and the std430 block, so wgpu's binding-size validation no longer kills the submit thread on RenderPearl or Visual Vibrance.
2026-11-02 14:05:29 +01:00
levzzzandClaude Sonnet 5.5 b27fca9915 Read the mod-image flag before the pack-staging thread scope
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 18:05:25 +05:00
Ludovic Marie 15b2747808 Merge pull request #7207 from DUzzL/fix/7136-shoulder-parrot
🤖 Agent: queued at 7090fdd5cc. Own shoulder parrots: the owner gets SetEntityData for slots 19/20 only when they change (an array compare per guest tick, no allocation when equal), and the respawn reset resends them, matching ServerEntity.sendDirtyEntityData to the owner.
2026-11-02 14:05:24 +01:00
Ludovic Marie 989d90bd36 Merge pull request #7204 from arslanarm/shaderpacks/separate-hardware-samplers
🤖 Agent: queued at 0f44ec6a95. SEPARATE_HARDWARE_SAMPLERS: honest to declare, since pack_post already binds shadowtex0HW/shadowtex1HW to the shadow depth with a comparison sampler chosen by the declared type; packs that require it (RenderPearl, Visual Vibrance) now load.
2026-11-02 14:05:19 +01:00
Ludovic Marie ec9148bf71 Merge pull request #7201 from TheMrZZ/fix/7196-scoreboard-division-overflow
🤖 Agent: queued at 29a273d96e. Scoreboard /=: wrapping_rem in the floor correction stops the i32::MIN / -1 panic, and the quotient stays i32::MIN like Java's Math.floorDiv; the command-level test checks the divisor is left alone too.
2026-11-02 14:05:14 +01:00
arismoko e21b51bf94 Merge remote-tracking branch 'upstream/main' into panic-mods
# Conflicts:
#	crates/horizon-client/src/mod_api.rs
2026-11-02 13:04:56 +00:00
Loki 66ab8da2c1 Merge remote-tracking branch 'origin/main' into perf/5093-clouds-fps 2026-11-02 22:04:32 +09:00
arismoko 5e7f5eb03c Target drowning hook in its compiled crate 2026-11-02 13:03:23 +00:00
arismoko c1649ac88e Merge remote-tracking branch 'upstream/main' into mixin-docs
# Conflicts:
#	docs/native-mods.md
2026-11-02 13:02:49 +00:00
levzzzandClaude Opus 5.5 21866d28e2 Join check: send only the shared mods
The server compares only side = "both" entries, and any server can ask a
client for its list, so client-only and server-only mods stay private.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 18:02:31 +05:00
Florian ERNSTandCodex gpt-6-astra 57a0816bab Reuse test targets for ignored CI tests
Co-Authored-By: Codex gpt-6-astra <[email protected]>
2026-11-02 14:02:30 +01:00
Ludovic Marie 8ee7236892 Merge pull request #7185 from HrafnaValkyrja/test/save-order-roundtrip
🤖 Agent: queued at d550c86157. Save round trip with riders: the scene now saves a mounted pair and the parity run on this head passes, so the Passengers nesting round-trips like vanilla's; tools-only change.
2026-11-02 14:01:59 +01:00
Ludovic Marie 2c1e911eec Merge pull request #7191 from TiggerTakkyTTT/fix/server-management-allowlist
🤖 Agent: queued at da4e5bb604. Allowlist settings over the management protocol: merged with main's autosave methods, keeping both; the parity test renumbers autosave 65–74 and allowlist 75–94 so the restarted reads stay after the autosave ones. horizon-server tests (117) and dedicated client tests pass.
2026-11-02 14:01:55 +01:00
Loki d84f2864dc Merge remote-tracking branch 'origin/main' into perf/4128-explosions-fps 2026-11-02 22:01:18 +09:00
Ludovic Marie abf5dea671 Merge pull request #7183 from horizon-rust/perf/prepared-data-structures
🤖 Agent: verify green on 6691b009. levzzz5154's review checked all 1,511 structure templates byte-for-byte through horizon-data.bin, so worldgen reads exactly the bytes it read before. The VERSION 2 bump makes every player rewrite the prepared file once on their first start (+4.4 MB).
2026-11-02 14:00:26 +01:00
levzzz 5604654a83 Merge hr/main into wave2-tier1 2026-11-02 17:59:47 +05:00
levzzzandClaude Sonnet 5.5 5d4abef9e6 Merge main into native-mod-image; number mod_image through a hook fragment
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 17:59:26 +05:00
levzzz aea84488b3 Merge remote-tracking branch 'hr/main' into fix7195 2026-11-02 17:58:43 +05:00
arismoko a24453a6fe Merge upstream main into living hooks 2026-11-02 12:56:23 +00:00
TraiNguyenVanandCodex GPT-6 29579dcdaa Place event loop tests after production items
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-02 19:55:13 +07:00
Lucas DelonandClaude Sonnet 5.5 a2c34553d8 Join check: name the content mods that differ, by version and file hash
A server with content mods on sends each mod's id, version, side and the
SHA-256 of its .hmod on horizon:mod_list; the client answers with its own
list, and a client that differs is refused with the mods named. Only
side = "both" mods count, so client-only mods never block a join.

Refs #7192

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 13:54:03 +01:00
Ludovic Marie fd57b86794 Merge pull request #7197 from arismoko/v2/ses_01M6DYB8QYSC38EEXTNNJRQ8T4-drops
🤖 Agent: queued at bcc3d47b55. Item expiry hooks: the merge of main keeps the on_ground/horizontal/every-4th-tick gate in front of SceneCollisions, so a resting item still skips the scene query 3 ticks in 4; item tests, clippy and mod-api check pass on this tree.
2026-11-02 13:52:21 +01:00
arismoko 704f504b5a Limit far chunk snapshot promotion to Horizon clients 2026-11-02 12:51:05 +00:00
TraiNguyenVanandCodex GPT-6 ab11862bfe Stop polling when redraw callbacks cease
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-02 19:51:05 +07:00
levzzz 1d9840766c Merge remote-tracking branch 'hr/main' into pr7066 2026-11-02 17:50:42 +05:00
levzzzandClaude Opus 5.5 bec5cc6edf guest lore: a string tag is one literal line, no JSON parse
ComponentSerialization decodes a string tag as Component.literal, as the
tooltip (lore_texts) now does; this keeps the wire and the tooltip in step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:50:42 +05:00
DUzzL 7090fdd5cc Send a player's own shoulder parrots to its client
The integrated server synced DATA_SHOULDER_PARROT_LEFT/RIGHT only to the
players around, so the owner's own model (third person) drew no parrot in
singleplayer and on servers hosted by this game. sendDirtyEntityData sends
the owner its own entity data too.
2026-11-02 13:50:22 +01:00
TiggerTakkyTTTandCodex 08f60bbe71 Add live max players management setting
Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-02 19:49:29 +07:00
Ludovic Marie 406ad19e4f Merge pull request #7166 from horizon-rust/perf/slow-frame-spans
🤖 Agent: verify green on ad88c258. Recording runs only for players who opted into performance telemetry (~5 µs/frame for 100 spans). The new slow_frames field is skipped when empty, so older ingest servers keep accepting reports. The server adds the column in migrate() and hides aggregate rows below min_install_days installs.
2026-11-02 13:49:19 +01:00
arismoko 78da6fd259 Merge remote-tracking branch 'upstream/main' into v2/ses_01M6E1Z4GQ0S5X4JCHHMZRPF31-attachment-sync
# Conflicts:
#	crates/horizon-client/src/lan/hub.rs
#	crates/horizon-client/src/mod_api/generated_host.rs
2026-11-02 12:48:22 +00:00
levzzzandClaude Opus 5.5 a6189f4638 Merge main: keep main's scheduleCandidate row
server_player_spawn (initial_spawn.rs) still passes over candidates in unloaded
chunks for a server's new player, so main's gap note stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:47:33 +05:00
Ludovic Marie db84d8508e Merge pull request #7190 from Joansitoh/fix/7139-bat-ear-cull
🤖 Agent: queued at 8e6896180a. BatModel draws with entityCutoutCull, so the facing-away quad of its zero-depth ears, wings and feet is culled. The new w = -126 (SHADED_CULL_W) shades like -127 and also sets cull. The only w values in use are 0, ±127 and 1, so the new (-0.996, 0) range catches only -126. The GPU skin job's word 14 writes the same byte as expand. The cost is one compare per vertex, plus a branch in the skin shader for culled models. On the head merged with main: clippy -D warnings clean, skin tests 8/8 including only_the_bat_asks_for_culled_back_faces. The GPU shader test is left to CI, since it segfaults on this Mac's GPU on main too.
2026-11-02 13:46:52 +01:00
Arslan ArmanulyandClaude Opus 5.5 38145c21a6 Shader packs: Iris's cloudTime uniform
Iris 1.11.7 gives packs `cloudTime`, which ShaderDoc doesn't list: observed
with a probe pack of our own, it is how far the game's clouds have drifted,
in blocks (vanilla's CloudRenderer.prepare: the game time within one trip
across the cloud texture, plus the partial tick, times 0.030000001). Horizon
read it as zero ("zero for now: cloudTime"), in eleven of Shrimple v0.12's
programs.

Part of #6812

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:46:11 +05:00
huiandZCode AI agent (GLM-5.3) c2e8cc0fe2 The book learns a crafted recipe under its whole id
The crafting copy names a minecraft: recipe bare (Recipes::sources
drops the namespace, folder and world pack alike), and award_crafted
handed that bare name on: the book, the network copy, the advancements'
JSON and the saved file all know the recipe as minecraft:<name>, so a
recipe unlocked only by crafting it had no display — never in the book,
not after the craft, not after rejoining. Vanilla recipes are usually
unlocked first by their advancements, under the whole id, which hid it;
a recipe_crafted criterion never matched the bare name either, for the
same reason.

crafting::qualified now fills the default namespace in, shared with the
lan copy's own. The book learns and forgets recipes under their whole
id whatever the caller passes (Settings::add_recipes, remove_recipes),
and award_crafted qualifies before the recipe_crafted trigger and the
book. Reading a saved book already qualified bare names; a test keeps
that, so books saved before the fix show their recipes too.

Fixes #7186.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-02 13:45:54 +01:00
Ludovic Marie 6684ec29ec Merge pull request #7202 from horizon-rust/perf/particle-collide-air
🤖 Agent: particle collide shortcut uses the scan's own empty_shape and may_reach_out flags, so it only skips what the scan skips; 4000 random sweeps match the full resolution bit for bit, 5 particle harnesses pass; client tick −24 %, 1 % low +14 % on stress_particles.
2026-11-02 13:45:30 +01:00
Ludovic MarieandClaude Opus 5.5 8bba5bd368 DX12: keep compiled shaders on disk between runs
FXC compiles Horizon's pipelines in about 2.6 s on every start (DXC
about 1 s). wgpu-hal's shader cache only lasts one device, so the
vendored dx12 backend now also appends what it compiled to
<game home>/cache/dx12-shaders.bin and reads it back as precompiled
bytecode next time. Keys hash the compiler, shader model, stage, entry
point and naga's HLSL; records carry a checksum and a torn tail is
written over.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:45:23 +01:00
Ludovic MarieandClaude Opus 5.5 da4e5bb604 Merge main into #7191
management_methods.rs keeps both helpers (allowlist_setting, main's autosave). rest-017.tsv keeps both rows. The management harness keeps main's autosave requests at ids 65-74 and moves this PR's allowlist requests to 75-92 and the restarted reads to 93-94.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:45:11 +01:00
arismoko bcc3d47b55 Merge upstream main into item expiry hooks 2026-11-02 12:44:04 +00:00
Ludovic Marie 3feb84a5ee Merge pull request #7182 from Joansitoh/fix/7147-spawn-egg-underwater
🤖 Agent: queued at e8e813638a. This matches SpawnEggItem.useOn in the 26.3 client bytecode: FAIL when the type can't spawn, SUCCESS on a client level, and neither lets Minecraft.startUseItem send UseItem, so the underwater fluid-ray second spawn is gone. Adventure mode keeps can_place_on as ItemStack.useOn does. I inlined spawn_egg_use_on_swings, which returned its own argument, and dropped its test, which only checked that identity. clippy -D warnings clean and remote tests 59/59 at this head. run-parity covers spawn_egg_fluid.
2026-11-02 13:44:04 +01:00
Ludovic MarieandClaude Opus 5.5 cf1ead6ba9 Let particles in open air skip the collision resolution
Every moving particle with physics ran the full entity movement resolution
per tick (Entity.collideBoundingBox: a BlockCollisionScan iterator, the step
machinery, its shape vectors), even in open air. SceneCollisions now tells
first, from the same cells, blocks and section flags the scan reads, that no
shape can be found: no block that can collide in the inner cells, no block
reaching out of its cell in the outer cells' sections. The resolution then
returns the movement unchanged, so the particle gets it directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:43:27 +01:00
Ludovic Marie b410fb5d73 Merge pull request #7157 from DUzzL/fix/7023-creative-search
🤖 Agent: queued at 5f656499f5. Review addressed: the localized tooltip lines are built once per language and cached; in English nothing is rebuilt. clippy -D warnings clean, creative tests 35/35 on the head merged with main.
2026-11-02 13:42:57 +01:00
JustKarkatandJustKarkat's AI agent (ZCode) e341593fdf Merge remote-tracking branch 'origin/main' into feat/native-screen-hooks
Co-Authored-By: JustKarkat's AI agent (ZCode)
2026-11-02 06:40:24 -06:00
Ludovic MarieandClaude Opus 5.5 e8e813638a Inline the spawn egg swing helper
spawn_egg_use_on_swings returned its argument, so its test only checked that identity. The branch now reads spawn_egg_allowed directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:39:25 +01:00
testatanucleare13-createandCopilot 6cfb7604d5 Update fishing item test for current entity fields
Co-authored-by: Copilot <[email protected]>
2026-11-02 12:38:46 +00:00
arismoko 32b4236c75 docs: where a mixin module lives, and why the [api] crate keeps its own attachment 2026-11-02 12:38:10 +00:00
arismoko 6fc2bd3256 A panic in a native mod's hook names the mod and the hook
The generated dispatch loops over mod_api::entered, which marks the thread as
inside each mod's hook while it runs; the crash handler adds "in native mod
<id>, hook <hook>" to the panic's message, and the panic goes on unwinding as
before. The launcher's generated executable passes the bundles' ids to
mod_api::name_mods when the revision has it. Dispatch still takes
&[Box<dyn Hooks>].
2026-11-02 12:37:42 +00:00
Ludovic Marie 11440651b1 Merge pull request #7159 from MSedgeMC/fix/pufferfish-sting-touch
🤖 Agent: queued at 2e1fae8f9f. Pufferfish.touch for mobs: a mobAttack hit for puffState + 1 on each alive, non-#not_scary_for_pufferfish mob in the fish's box inflated by 0.3, when teams allow. I added the POISON for 60 * puffState that the bytecode gives once the hit lands, before the sting sound (Mob::add_effect carries it, so the PR's 'no path' note didn't hold), and updated the ledger row. Merged with main: clippy -D warnings clean, pufferfish tests pass, and they now assert the poison duration.
2026-11-02 13:37:34 +01:00
Florian ERNSTandCodex gpt-6-astra 29a273d96e Fix scoreboard division overflow for minimum integer
Co-Authored-By: Codex gpt-6-astra <[email protected]>
2026-11-02 13:37:31 +01:00
Ludovic Marie 258eb148be Merge pull request #7165 from Qwerner88880/hooks-catalog-targets
🤖 Agent: queued at 8cf71732d0. Since #5446, menu.rs and breathing.rs compile into horizon-client-lib, so horizon-mixins rejected the old horizon_client:: targets. The new check that each target's first module is declared in its crate's lib root catches the next file move, even when the catalog and the expectation agree. On the head merged with main: xtask mod-api --check passes (rebuilt in this tree), Python 612 OK, clippy -D warnings clean, catalog_matches_hook_points passes.
2026-11-02 13:37:30 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 7df00ece61 Regenerate the mod API file for the hook docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 07:37:20 -05:00
arismoko 364297a363 Merge remote-tracking branch 'upstream/main' into mixin-docs
# Conflicts:
#	docs/native-mods.md
2026-11-02 12:36:42 +00:00
AkirabaneandClaude Sonnet 5.5 80f5c34206 docs: the management protocol's list notifications
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 13:36:13 +01:00
AkirabaneandClaude Sonnet 5.5 f6a932c274 horizon-server: the management protocol notifies changes of the operators, the allowlist and the bans
Step 3 of #7118. In vanilla each StoredUserList holds the NotificationService, so a change tells it
whoever made the change (the console, a command in the game, the protocol's own methods):
ServerOpList.add and remove call playerOped and playerDeoped, UserWhiteList playerAddedToAllowlist and
playerRemovedFromAllowlist, UserBanList playerBanned and playerUnbanned, IpBanList ipBanned and
ipUnbanned. A clear says it once for each entry, then empties the list; removing the expired entries
that get drops says nothing.

UserList now takes a watcher (watch) that hears Added and Removed with the entry, on add, remove and
clear but not on load. The dedicated game gives each of its four lists a watcher that records a typed
event (operators_added, bans_removed, ip_bans_added...), and the supervisor, which already fetches the
game's events, turns them into the notifications, with the DTOs the lists already write.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 13:36:13 +01:00
Ludovic Marie 2f35078b3a Merge pull request #7155 from Akirabane/feat/management-notifications-2
🤖 Agent: queued at 0c54f51081. join/leave/saving/saved map to JsonRpcNotificationService's players/joined, players/left, server/saving and server/saved. The save events are recorded only on a dedicated server and only when the save succeeds, and the saves before the first fetch are skipped, since no client could have heard them. On the head merged with main: horizon-server 117/117 including the frame-order test, clippy -D warnings clean on horizon-server and the client, and dedicated::events passes.
2026-11-02 13:35:13 +01:00
Ludovic Marie 9e5d0ac2d8 Merge pull request #7158 from Akirabane/fix/7080-entity-world-border
🤖 Agent: queued at 2453e2d2c0. Items, orbs, primed TNT and falling blocks now collide with the border through EntityCollisions, as Entity.collectColliders adds WorldBorder.getCollisionShape. The per-move cost is is_inside_close_to_border, and the shape is only built near the border. Dropping the entity_boxes.is_empty() fast path costs nothing, since an empty entity slice iterates nothing. On the head merged with main: clippy -D warnings clean, border tests 12/12 including the new item/orb and TNT ones.
2026-11-02 13:35:09 +01:00
Ludovic Marie 597e393fac Merge pull request #7153 from arismoko/v2/ses_01M6DYMGWF02H5RBZ0RFVQ78M9-test-host
🤖 Agent: queued at 4dcbdd27fc. Host methods now get a panicking default that names the method (not implemented: Host::tell), so a test double writes only what its hook calls. The game is unaffected: the generated impl Host for Game still overrides every method, and generated_output_is_current keeps both in step. I merged main; on that head merged again with today's main, xtask mod-api regenerates nothing and --check passes (rebuilt in this tree), xtask mod_api tests 17/17, Python 612 OK, clippy -D warnings clean, and both new mod_api tests pass.
2026-11-02 13:35:05 +01:00
JustKarkat 141ae46624 Put the title's own screens on the native screen hooks
The screen hooks only covered the screens over gameplay: the title state
machine (Title, Select World, Create New World, the multiplayer screens) had
no id, fired no ScreenEvent.Render.Post and heard none of the input Pre
hooks, so a mod could not add a button to Create New World or take clicks
there, which is what a pack browser (Resourcify and the like) needs.

Give every title state an id (Mode::id; the multiplayer screens have their
own), report it through NativeScreenView with the whole window as the rect
and no menu or slots, fire native_screen_render_post once the title has
drawn (before its toasts) and let the mouse, wheel, key and drag Pre hooks
fire there, cancelling as on any other screen. Typing keeps the key's text
on the key for the title's own boxes, as Options does, and the control
socket's type now types into them.

native_instance_dir() gives a mod the instance folder, where resourcepacks/,
shaderpacks/ and horizon-mods/ are.

API 154.
2026-11-02 06:33:57 -06:00
HrafnaValkyrjaandClaude Sonnet 5.5 80207e6cc0 A summoned entity a mod kept out fails the command for any kind
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 07:33:23 -05:00
arismoko c029313d6c Crash reports list the build's native mods
A game the launcher built with native mods reads their ids and versions from
the native-mods.json beside it and adds them to the crash report's details as
Native Mods. The line stays out of crash telemetry, which keeps only its listed
details.
2026-11-02 12:32:16 +00:00
DUzzL 5f656499f5 Creative search: build the localized tooltip lines once per language 2026-11-02 13:29:56 +01:00
Ludovic Marie 9eb0e230e3 Merge pull request #6911 from arismoko/v2/ses_01M6DKF0Z8BE7TF075HX2BZ8T2-a14d111139e2
🤖 Agent: queued at 07709a4233. Clone fires at the end of restoreFrom (was_death true) and from server_travel's End exit with keepAllPlayerData (false). LoadFromFile fires after the .dat is applied and before placement; SaveToFile once per save, owner included. The extra create_dir_all on players/data runs only on saves and joins, and vanilla's PlayerDataStorage makes that folder anyway. I merged main (docs conflict resolved: main's pearl/chorus hooks, then these three). mod-api --check passes on this head and merged with today's main after #6912; Python 612 OK, clippy -D warnings clean, player tests 116/116.
2026-11-02 13:29:33 +01:00
Arslan ArmanulyandClaude Opus 5.5 5e2c8f5641 Shader packs: struct types without a name
GLSL lets a declaration define its struct type in place without naming it
(GLSL 4.60, 4.1.8): `shared struct { uint sum; uint tile[8][8]; } lds;`.
naga's frontend wants a name ("Expected identifier, found LeftBrace"), which
RenderPearl v2.7.2's compute programs (deferred, deferred1, composite,
composite3) hit. Each such type now gets one (`hz_struct<n>`) before the
stages are read.

Part of #7171

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:27:14 +05:00
Arslan ArmanulyandClaude Opus 5.5 0f44ec6a95 Shader packs: Iris's SEPARATE_HARDWARE_SAMPLERS feature
`iris.features.*` may name SEPARATE_HARDWARE_SAMPLERS (ShaderDoc, "Separate
Hardware Shadow Samplers"): with shadow hardware filtering on, shadowtex0 and
shadowtex1 stay plain depth samplers, and shadowtex0HW and shadowtex1HW
compare. Horizon already binds the HW names to the shadow depth buffers and
picks a comparison sampler by the declared type: a probe pack of our own gets
the same comparison results as in Iris 1.11.7, and plain depths from
shadowtex0/1 alike. Only the flag was missing. RenderPearl v2.7.2 and
Visual Vibrance v0.3.5a require it, and the game logged it as not supported.

Fixes #7179

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:26:51 +05:00
HrafnaValkyrjaandClaude Sonnet 5.5 a17637bdd2 Load saved entities through Entity::load_nbt
Slice 10b of #6509: the id-string dispatch in spawn_loaded_entities moves behind a required Entity::load_nbt, one macro-generated test chain over entity_lists! (static dispatch, nothing allocated). Kinds outside the lists keep their arms in load_unlisted. Fixes #6545.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 07:26:10 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 3e8dd56063 Tell mods of every entity kind leaving and of the five kinds kept apart joining
Part of #6546. native_entity_leave_level covers every kind the join hook tells, from one registry of announced ids swept where entities are removed; end crystals, the dragon, cushions, paintings and item frames join and leave; spawns between the tick's phases join in that tick.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 07:25:46 -05:00
TiggerTakkyTTTandCodex 873ab77686 Add live allowlist management settings
Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-02 19:25:45 +07:00
Ludovic Marie e8b55fbe55 Merge pull request #7187 from horizon-rust/perf/reload-stage
🤖 Agent: the language is read when the reload starts, so the apply step checks the code again and loads the newer language if the player switched while it ran; without that check, the old language would come back.
2026-11-02 13:25:15 +01:00
Ludovic Marie 8a637b3839 Merge pull request #7174 from MSedgeMC/fix/horse-follow-mommy
🤖 Agent: queued at 0167e02866. follow_mommy matches AbstractHorse.followMommy: bred && baby && !eating, the nearest bred horse within range(16) over its box inflated by 16, a path only when distanceToSqr > 4, and createPath(entity, 0), which leaves the speed modifier alone. Since nothing sets Bred at runtime, the per-tick cost is one early return. The Bred flag now round-trips through entity_nbt. On the head merged with main: clippy -D warnings clean, follow_mommy test passes.
2026-11-02 13:25:11 +01:00
Ludovic Marie 08037ae195 Merge pull request #6912 from arismoko/v2/ses_01M6DKF0Z8BE7TF075HX2BZ8T2-7b6a6409e395
🤖 Agent: queued at efe72ef985. award_stat checks mods().is_empty() before copying the acting player's name, and namespaced() uses a 96-byte stack buffer, so the per-tick counters (play_time, the cm stats) cost what they did before. Advancement progress fires only on a real grant or revoke. I merged main: the in-frame tick that #23f8bd4fe deleted takes its stat calls with it, trading.rs keeps main's offer-based merchant_traded with award_custom_stat, and hub.rs gets both fields. xtask mod-api --check passes on this head and again merged with today's main (after #6927); Python 612 OK, clippy -D warnings clean, stat tests 52/52.
2026-11-02 13:22:42 +01:00
Ludovic Marie a4f7a848f7 Merge pull request #7173 from TiggerTakkyTTT/fix/server-management-autosave
🤖 Agent: queued. One concrete point: besides the new management methods, this changes `save-off` itself. The autosave countdown now keeps running while saves are off, as in vanilla (`MinecraftServer.autoSave`), instead of being held at 1, so the first save after `save-on` comes at its scheduled time rather than on the next tick. test_management_protocol_parity covers the methods. The merger waits for parity (fork).
2026-11-02 13:22:38 +01:00
HrafnaValkyrja d550c86157 Merge remote-tracking branch 'origin/main' into test/save-order-roundtrip 2026-11-02 07:22:18 -05:00
Arslan ArmanulyandClaude Opus 5.5 8cf2eaf48d Shader packs: a stray #endif and an #if left open are warnings, as in Iris
Iris 1.11.7 draws a program with a stray `#endif` (it logs "#endif without
#if") and one whose `#if` stays open to the end of the file (seen with
probe packs written for this). Horizon reported both as errors and skipped
the program: with Pegasus v0.5 every gbuffers program (terrain and water had
none), with Bloop its composite6. Both are now warnings: the stray `#endif`
is ignored, the open `#if` reads on to the end.

Fixes #7167

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:20:40 +05:00
testatanucleare13-createandCopilot f3b1317d3c Fix fishing hook item collision
Co-authored-by: Copilot <[email protected]>
2026-11-02 12:20:12 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 6d5ad6b6c4 Test: save and reload keeps riders
Adds passengers (one, two, nested) on a pig, boat, minecart and horse to the save round-trip scene, and lets the passenger copies of simulation fields differ as the entity's own do. Follows the review of #7094.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 07:19:49 -05:00
Ludovic Marie ba832babbd Merge pull request #7177 from HrafnaValkyrja/fix/save-missing-kinds
🤖 Agent: queued at 15fc1ce2fb. saved_with_chunk is now an exhaustive match, so a new ProjectileKind won't compile until it says whether it's saved, and the wind charges it had been dropping are now saved. Potion(stack, lingering) keeps the entity type apart from the Item, as /summon lingering_potion with a splash Item needs. attached_block_pos falls back to the summon position, as BlockAttachedEntity.setPos does. On the head merged with main: clippy -D warnings clean, nbt tests 48/48. run-parity added for save_roundtrip and world_reopen.
2026-11-02 13:19:14 +01:00
Ludovic Marie ef2780d442 Merge pull request #7172 from HrafnaValkyrja/refactor/entity-walk-mod-hooks
🤖 Agent: queued at d927a099e7. native_joins_flush returns before touching anything when no mod is loaded or on a client, so the new per-tick call is free for vanilla play. With mods it announces each new entity once, lowest id first, against one u32 mark, and a cancel goes through discard_entity before the entity ticks. I merged main (mod-api regen + --check ok); Python 612 OK, clippy -D warnings clean, join tests 17/17 on the Mac.
2026-11-02 13:19:10 +01:00
Arslan ArmanulyandClaude Opus 5.5 35972b0eab Shader packs: storage buffers as large as their shaders' blocks
A pack declares a storage buffer's size (`bufferObject.<n>`), and a shader
may bind it as a larger block: std430 rounds a block to its largest
member's alignment. OpenGL binds the smaller buffer; wgpu refuses the draw or
dispatch, and the validation error stopped the submit thread, so
RenderPearl v2.7.2 (40980 bytes bound, 40992 expected) and Visual Vibrance
v0.3.5a (40 against 48) crashed the game on their first frame.

`Post::buffer_minimums` reads each storage block's size from every
translated stage that binds it, and the renderer makes each buffer at least
that large.

Fixes #7178

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 17:18:51 +05:00
arismoko 4d97da3bca Merge remote-tracking branch 'upstream/main' into v2/ses_01M6E1Z4GQ0S5X4JCHHMZRPF31-attachment-sync 2026-11-02 12:17:49 +00:00
Alexander Krylov 668e44a3e8 Merge remote-tracking branch 'origin/main' into feat/native-mod-image 2026-11-02 15:16:19 +03:00
Alexander KrylovandHermes Agent 7a9915273c Reserve the mod image slot only while a native mod is loaded
The 1024x1024 slot cost every player 4 MiB of atlas pixels, GPU memory and
shelf room whether a mod drew into it or not. Pass the fact in from the two
places the atlas is built: a game with no native mod has no slot, and
native_mod_image keeps answering false for the missing sprite.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 15:16:13 +03:00
JoansitohandClaude Sonnet 5.5 956494a51a Spawn eggs used on a block end the click client-side (#7147)
SpawnEggItem.useOn returns SUCCESS (FAIL for a mob that cannot spawn) on a
client level, so no UseItem follows and the server no longer spawns a second
mob from the fluid ray when the player is underwater.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 13:15:55 +01:00
JoansitohandClaude Sonnet 5.5 8e6896180a Format
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 13:13:06 +01:00
Ludovic MarieandClaude Opus 5.5 d927a099e7 Merge main into #7172
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:12:40 +01:00
JoansitohandClaude Sonnet 5.5 51d1ec6fc8 Cull the bat model's back faces like entityCutoutCull (#7139)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 13:10:01 +01:00
Ludovic Marie fbb17c980e Merge pull request #7154 from arslanarm/shaderpacks/geometry-stage-blocks
🤖 Agent: queued at d5418e0137. Fixes Shrimple's shadow program, whose geometry stage used to rename float to hz_vs_float. Interface blocks are now flattened before combine reads the two stages, and the hz_in_ prefix keeps a geometry stage's same-named input and output blocks apart, as GLSL 4.60 §4.3.9 allows. It only runs at pack load, so the frame costs nothing. On the head merged with main: horizon-shaders tests 142/142 (the four new ones included) and clippy -D warnings is clean.
2026-11-02 13:08:56 +01:00
arismoko 4717146e92 Merge upstream main into living hooks 2026-11-02 12:06:42 +00:00
arismoko 3794f5e635 Merge upstream main into item expiry hooks 2026-11-02 12:06:25 +00:00
Ludovic Marie 3783aa54b7 Merge pull request #6927 from breachato/feat/6697-hanging-break-hook
🤖 Agent: queued at efb76fb19c. native_hanging_break is only asked where a break would already happen: attack_frame when the frame itself breaks, the blast reach, and the survival check once it fails. With no mods it costs an empty dispatch. break_painting's new bool keeps the explosion loop's index right when a mod keeps the painting. I merged main, which regenerated generated.rs (its stale projectile-impact doc); xtask mod-api --check passes, Python tests 612 OK, clippy -D warnings clean, frame tests 19/19 on playhorizon.
2026-11-02 13:06:03 +01:00
Ludovic Marie 2c5f291c6a Merge branch 'main' into fix/lan-auth-harness-client-lib 2026-11-02 13:05:29 +01:00
Ludovic MarieandClaude Opus 5.5 4dcbdd27fc Merge main into #7153
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:05:29 +01:00
arismoko 05ac3060c0 Fix attachment sync lifecycle and far chunk snapshots 2026-11-02 12:04:49 +00:00
Ludovic Marie 693aa8482f Merge pull request #7175 from horizon-rust/tools/pacing-first-sight
🤖 Agent: the new scenarios type their commands into chat, not horizon-ctl's `command`, which blocks the frame on the server tick and made every step look like a 50 ms stall in a first try.
2026-11-02 13:04:04 +01:00
Ludovic MarieandClaude Opus 5.5 2e1fae8f9f Poison the mob a pufferfish stings
Pufferfish.touch gives the stung mob POISON for 60 * getPuffState() once the hit lands, before the sting sound. Mob::add_effect carries it, so the event now holds the duration and the game applies it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:03:51 +01:00
Ludovic MarieandClaude Opus 5.5 fa297fc040 Merge main into #7159
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:03:51 +01:00
HrafnaValkyrjaandClaude Opus 5.5 96b3597a6b Merge origin/main
Keep both the save and the mod-hook trait methods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 07:03:25 -05:00
Ludovic Marie 0e24521cd5 Merge pull request #7161 from DUzzL/fix/7026-slime-item-transparency
🤖 Agent: queued at cf968b78eb. Matches vanilla's classic order: executeTranslucent draws the shadows phase before translucentBlocksAndItems, and ITEM_TRANSLUCENT keeps the pipeline default of depth write on, so ice and water drawn later can't blend over a dropped slime block. The trade-off is that water right behind a glass item now hides behind it, which is also what vanilla does without Fabulous. The OIT path is untouched and the per-frame draw count is the same. I removed the gfx.rs source-text test (the repo rule is to test behaviour, not source); the before/after screenshots cover it.
2026-11-02 13:02:10 +01:00
MSedgeMC 0167e02866 A bred foal follows the nearest bred horse
`AbstractHorse.aiStep` ends with `followMommy`: a foal that is `isBred`, is a
baby and is not eating takes the nearest `AbstractHorse` that is `isBred`
within 16 blocks (`MOMMY_TARGETING`) and, when it is more than two blocks away
(`distanceToSqr(mommy) > 4.0`), installs a path to it
(`navigation.createPath(mommy, 0)`). Horizon had no `Bred` flag at all — the
save wrote a constant `0` and read nothing — so no foal ever followed.

`MobExtra.bred` now carries the flag, `Bred` is saved and read back for the
equine kinds, `Seen.bred` lets a foal see the flag on the horses around it, and
`equine::follow_mommy` runs where `AbstractHorse.aiStep` calls it. The path is
installed at the navigation's current speed modifier, as `createPath(entity, 0)`
keeps it.

Nothing sets `Bred` at runtime in 26.3 (`setBred` is called only from
`readAdditionalSaveData`), so the foal only follows with the flag in its save
or its `/summon` NBT.

Corrects the `AbstractHorse.followMommy` row of `tools/port_map/game-011.tsv`.
2026-11-02 12:01:05 +00:00
Ludovic Marie 1a88d355d3 Merge pull request #7148 from Akirabane/fix/7078-pearl-chorus-border
🤖 Agent: queued at 71b32333ef. clip_including_border matches CollisionGetter.clipIncludingBorder in the 26.3 bytecode: the isWithinBounds(from) && !isWithinBounds(hit) test with margin 0.0, getApproximateNearest of the delta, and clampVec3ToBound's maxX/maxZ - 9.999999747378752E-6. ProjWorld's border is [minX, minZ, maxX, maxZ], the same order as WorldBorder geometry's bounds(). The no-border cost is one Option match per clip. On the head merged with main: clippy -D warnings clean, border tests 10/10.
2026-11-02 13:01:03 +01:00
Ludovic MarieandClaude Opus 5.5 cf968b78eb Drop the source-text test
It read gfx.rs as text (the call order and a pipeline argument) rather than testing behaviour. The PR's before/after screenshots are the proof for this draw-order change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:00:36 +01:00
Ludovic MarieandClaude Opus 5.5 8bac5c09fe tools: pacing_bench first-sight, screens and F3+T scenarios
mobs, items and block_entities put 68 mob types, 50 item models and 34
block entities in view one by one (through chat, as a player types it:
horizon-ctl's `command` blocks the frame on the server tick), 0.6 s
recorded after each. screens opens the creative inventory and its 12
tabs, then the survival inventory and its recipe book, twice, printing
each step's worst frame. reload records 15 s after F3+T.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:00:28 +01:00
arismoko b68e32f307 Merge remote-tracking branch 'upstream/main' into v2/ses_01M6E1Z4GQ0S5X4JCHHMZRPF31-attachment-sync
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/lan/hub.rs
#	crates/horizon-world/src/attachments.rs
#	docs/native-mods.md
2026-11-02 11:59:57 +00:00
Ludovic Marie 10f472d86e Merge pull request #7164 from Ragbie/fix/pr-ready-test-wsl-bash
🤖 Agent: queued at 7840fe3549. Test-only: shutil.which('bash') follows PATH, so Windows runs Git Bash instead of System32's WSL bash (#5383's cause), and the skip only covers a WSL-only machine. tests.test_pr_ready passes on macOS at this head, where the test still runs.
2026-11-02 12:59:55 +01:00
Ludovic MarieandClaude Opus 5.5 1410a97974 perf: read sounds, GUI sprite metadata and the language off the F3+T frame
A resource reload's apply step (the frame that swaps the new resources
in) parsed every sounds.json (about 15 ms), walked the GUI sprites'
.mcmeta files (3 ms) and loaded the language (2 ms) on the render
thread. stage_pack now reads them on the reload worker, beside the
atlas; the apply step only puts them in use. A language picked while
the reload ran still wins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:59:32 +01:00
Ludovic Marie 7d0df99a70 Merge pull request #7094 from HrafnaValkyrja/refactor/entity-walk-saves
🤖 Agent: queued at 61a8cb83cc. Save refactor: each entity_lists! kind now saves through Entity::save_nbt with one save_lists! loop, and the filters (root-only mobs, alive/!removed, saved_with_chunk, the chunk keys) are unchanged. The only behaviour change is entity order within a chunk, which shifts the reload's entity ids and nothing a player sees. Dyna's game test covered riders, leads, the minecart chest and the armor stand on the server thread and the dedicated server, and verify passed at f3dd3a3. I added one doc-only commit putting strip_finalize's comment back on the function. run-parity added for world_reopen and save_roundtrip.
2026-11-02 12:58:45 +01:00
Ludovic MarieandClaude Opus 5.5 61a8cb83cc Put strip_finalize's doc comment back on it
The new impl block had been inserted between the comment and the function (Dyna's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:57:21 +01:00
Ludovic MarieandClaude Opus 5.5 07709a4233 Merge main into #6911
docs/native-mods.md: main's teleport sentence and its pearl and chorus hooks, then this PR's three player hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:56:19 +01:00
TiggerTakkyTTTandCodex ad4634e363 Add live autosave management setting
Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-02 18:55:43 +07:00
Ludovic MarieandClaude Opus 5.5 efe72ef985 Merge main into #6912
game.rs: main removed the in-frame player tick (#23f8bd4fe), so its stat calls go with it. trading.rs: main's offer-based merchant_traded, with award_custom_stat. hub.rs: both new fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:55:10 +01:00
Ludovic MarieandClaude Opus 5.5 ad88c2584d dedicated report test: spans carry their depth
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:54:42 +01:00
Ludovic Marie a090d985ef Merge pull request #7151 from Ragbie/fix/anvil-turtle-egg-drop
🤖 Agent: queued at 3cbd09e448. Checked against FallingBlockEntity.tick's bytecode: callOnBrokenAfterFall and spawnAtLocation both sit under dropItem && ENTITY_DROPS, so moving 1029 out of falling_block_landed into finish_falling_block's drop branch is right. A DropItem:0b anvil is now silent, as in vanilla. drop_falling_block now reads new_item_motion's own random and no longer advances the level random. On the head merged with main: clippy -D warnings clean, client falling tests 17/17, horizon-world fall tests 8/8. run-parity added for the new anvil_turtle_egg harness.
2026-11-02 12:53:55 +01:00
HrafnaValkyrja 15fc1ce2fb Merge remote-tracking branch 'origin/main' into fix/save-missing-kinds 2026-11-02 06:52:34 -05:00
Ludovic MarieandClaude Opus 5.5 efb76fb19c Merge main into #6927
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:51:46 +01:00
Loki ce9e10196e Merge remote-tracking branch 'origin/main' into perf/5093-clouds-fps 2026-11-02 20:49:57 +09:00
HrafnaValkyrja 2e785524f9 Merge remote-tracking branch 'origin/main' into refactor/entity-walk-mod-hooks 2026-11-02 06:49:12 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 1303351d3d Native mods: entity_join_level tells every kind of the entity walk, once, and removes a cancelled one
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 06:48:58 -05:00
Loki a0d2dce474 Merge remote-tracking branch 'origin/main' into perf/4128-explosions-fps 2026-11-02 20:48:56 +09:00
arismoko 2970a73d5c Suppress living callbacks during movement prediction 2026-11-02 11:48:46 +00:00
MSedgeMC b661190137 Sting the mobs a puffed pufferfish touches
`Pufferfish.aiStep` stings every mob whose box meets the fish's own box
inflated by 0.3, filtered by `TARGETING_CONDITIONS` (alive, and not in
`#not_scary_for_pufferfish`), through `Pufferfish.touch`: a `mobAttack` hit for
`getPuffState() + 1`, then the poison and `PUFFER_FISH_STING` when that hit
lands. Horizon had only the player path (`playerTouch`), so a pufferfish that
puffed up for a zombie never hurt it.

`ai_step` now stings the touching mobs through a new `PufferfishSting` event,
which applies the hit in the game layer and plays `entity.puffer_fish.sting`
only when the hit landed, at the fish's position and not when it is silent, as
`Entity.playSound` does. The tag moves into `not_scary_for_pufferfish`, read by
the puff goal and the sting alike.

The poison the mob gets is not modelled: an effect on another mob has no
channel in the simulation, the same gap a cave spider's poison on a mob has.

Corrects the `Pufferfish.touch` row of `tools/port_map/game-011.tsv`.
2026-11-02 11:48:46 +00:00
DUzzL e6be1c259a Draw entity shadows before translucent items and let those items write depth
Dropped slime blocks and glass blocks looked see-through: the ground shadow
was drawn over them and ice or water drawn afterwards blended over them
(#7026, #7031, #7033). Vanilla's PreparedFrame.executeTranslucent runs the
shadows before the translucent blocks and items, both ahead of the
translucent terrain (LevelRenderer.executeClassicTransparency).
2026-11-02 12:48:13 +01:00
Evrim CicekandClaude Opus 5.5 7840fe3549 test_pr_ready: the launcher fallback test runs the bash on PATH, not WSL's
On Windows, subprocess.run(["bash", ...]) goes through CreateProcess, which
looks in System32 before PATH: with WSL installed it starts WSL's bash,
which fails (execvpe(/bin/bash)), and the test errored three times on any
Windows checkout. It now runs the bash on PATH (Git Bash), and skips only
when there is none, or only WSL's (#5383).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:47:29 +03:00
Ludovic MarieandClaude Opus 5.5 6691b009ad prepared_files: the find test knows worldgen/structure is held now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:46:51 +01:00
Ludovic Marie 0ac35dac75 Merge pull request #7132 from horizon-rust/perf/chunk-arrival
🤖 Agent: queued. This is a bench tool only (skip-release): PERF_BENCH_TRACE writes each load's timeline for Horizon and for vanilla. The one point to watch is that the trace stays off unless the variable is set, so normal bench runs aren't slowed: that is how it reads.
2026-11-02 12:46:29 +01:00
Ludovic Marie 9585d5ec70 Merge pull request #7146 from MSedgeMC/chore/use-item-on-jukebox-case
🤖 Agent: queued at 6e6deabda9. Harness-only: jukebox_case puts a Creative jukebox with block_entity_data.RecordItem through use_item_on, pinning JukeboxBlock.setPlacedBy's has_record. The body shows it fails (36 differing) with the jukebox arms removed, so the case has teeth. Note: the new rng.random() draw reshuffles later cases, which is harmless since oracle and probe read the same stream. run-parity added so CI runs it.
2026-11-02 12:46:25 +01:00
Ludovic Marie 4b5a284f4c Merge pull request #7100 from MSedgeMC/chore/narrator-rest-003
🤖 Agent: queued at 1755ea5c93. Both rest-003 rows resolve: Status::should_narrate_system_or_chat matches NarratorStatus's ALL/SYSTEM/CHAT test, and GameNarrator's home now names horizon-narrator plus game/narrator.rs instead of the stale 'no text-to-speech' note (ledger check: 2 rows, 0 unresolved).
2026-11-02 12:46:20 +01:00
Ludovic Marie 857606c783 Merge pull request #7144 from MSedgeMC/chore/ledger-game2-011.tsv
🤖 Agent: queued at 621b9a1b3f. FixedBiomeSource.findBiomeHorizontal row → stronghold.rs RingSearch::find_fixed; the cited symbol is on main and the row parses (ledger check: 1 row, 0 unresolved). findClosestBiome3d rightly left on Pipeline::find_closest_biome.
2026-11-02 12:46:15 +01:00
Ludovic Marie 76eaec2b0a Merge pull request #7081 from MSedgeMC/chore/ledger-client2-012-y
🤖 Agent: queued at e4e5eeab7c. All 17 changed rows check out against origin/main (each cited file and symbol exists), for example ContainerEventHandler.preeditUpdated port → crates/horizon-client/src/game/ime.rs::ime_preedit.
2026-11-02 12:46:11 +01:00
Ludovic Marie 4e37864fd7 Merge pull request #7082 from MSedgeMC/chore/ledger-final-001-x
🤖 Agent: queued at 44ace2db1c. All 1 changed rows check out against origin/main (each cited file and symbol exists), for example MultiLineEditBox.setValue port → crates/horizon-client-render/src/multiline_edit.rs::MultiLineEdit::set_value.
2026-11-02 12:46:07 +01:00
Ludovic Marie 731d69d756 Merge pull request #7085 from MSedgeMC/chore/ledger-rest-006-rows
🤖 Agent: queued at 45717920ab. All 5 changed rows check out against origin/main (each cited file and symbol exists), for example MessageArgument.getMessage port → crates/horizon-client/src/game/commands.rs::Game::expand_selectors.
2026-11-02 12:46:02 +01:00
Ludovic Marie c294b26ae2 Merge pull request #7086 from MSedgeMC/chore/ledger-classes-10-y
🤖 Agent: queued at adb8f08038. All 3 changed rows check out against origin/main (each cited file and symbol exists), for example SubShape.clampToShape inline → crates/horizon-core/src/geometry/shape/face.rs::VoxelShape::calculate_face.
2026-11-02 12:45:55 +01:00
yosifdheef b99d686056 Remove synthetic sugar cane query-order test 2026-11-02 14:45:33 +03:00
DUzzL 3b421a0b6c Creative search: match every tooltip line in the selected language 2026-11-02 12:45:13 +01:00
LokiandAntigravity 9d515854a8 clouds: optimize cloud mesh preparation and ring traversal (fixes #5093)
Co-Authored-By: Antigravity <[email protected]>
2026-11-02 20:45:11 +09:00
Ludovic Marie d3498ddd5c Merge pull request #7142 from MSedgeMC/fix/piston-destroy
🤖 Agent: queued at 6ce919736a. MovingPistonBlock.destroy port: removing a moving_piston now takes an extended base behind it (update.rs destroy_moving_piston_base), and an unextended base stays. On this head: horizon-world level:: tests 97/97 pass, including both moving-piston tests side by side with #7122's, and clippy -D warnings is clean.
2026-11-02 12:42:35 +01:00
AkirabaneandClaude Sonnet 5.5 0c54f51081 horizon-server: the management protocol notifies world saves and players joining and leaving
Step 2 of #7118. Vanilla's JsonRpcNotificationService sends serverSaveStarted and serverSaveCompleted
around DedicatedServer.saveAllChunks (the save-all command, the autosave, the pause) and playerJoined and
playerLeft with the player as a PlayerDto.

The game records a typed event for each (a new "saving" and "saved", beside the "join" and "leave" it already
records for the event stream), and the supervisor, which fetches those events, turns them into the
notifications. A save is told only when it ends without failing, as vanilla's is. What the game saved while
it started is left out: no client could hear of it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 12:42:17 +01:00
MSedgeMC 6ce919736a Merge remote-tracking branch 'upstream/main' into fix/piston-destroy 2026-11-02 11:40:27 +00:00
MSedgeMC b7f47bab1d Restore the moving-piston use test's tail after the merge 2026-11-02 11:40:26 +00:00
Ludovic Marie 4f1b75f5b0 Merge pull request #7056 from horizon-rust/perf/items-gpu-path
🤖 Agent: dropped items on the GPU skin path, stacked on #7089; every vanilla item state compared byte for byte with the CPU path (3104 taken, 212 left on the CPU); stress_items 209 → 498 fps on the M4 Pro.
2026-11-02 12:40:24 +01:00
Loki cf8a1cd029 Merge remote-tracking branch 'origin/main' into perf/4128-explosions-fps 2026-11-02 20:38:41 +09:00
Akirabane 2453e2d2c0 Merge remote-tracking branch 'origin/main' into fix/7080-entity-world-border 2026-11-02 12:38:36 +01:00
LokiandAntigravity 982e38d8b9 explosion: optimize block destruction and particle tracking (fixes #4128)
Co-Authored-By: Antigravity <[email protected]>
2026-11-02 20:38:34 +09:00
AkirabaneandClaude Opus 5.5 832a188847 Falling blocks stop at the world border
Game::fall_step resolved a falling block's move with its own collision
context but without the world border (Entity.collectColliders); it now
goes through EntityCollisions with the border, on the server and in a
client's copy.

Refs #7080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:38:30 +01:00
AkirabaneandClaude Opus 5.5 0ec75f0dd8 Primed TNT stops at the world border
PrimedTnt::tick resolved its move against the blocks alone; it now
passes the world border too (Entity.collectColliders), on the server
and in a client's copy between updates.

Refs #7080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:38:30 +01:00
AkirabaneandClaude Opus 5.5 f69a77827e Items and experience orbs stop at the world border
Entity.collectColliders adds WorldBorder.getCollisionShape for any
entity inside and close to the border, but ItemEntity::tick built its
EntityCollisions with `border: None` and ExperienceOrb::tick resolved
against the blocks alone, so both went through the border. Their ticks
now take the level's border (the server's and a client's copy), and
EntityCollisions gains resolve_with_context for callers that carry
their own collision context.

Refs #7080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:38:29 +01:00
Ludovic Marie 8a45d7f9f2 Merge pull request #7121 from Akirabane/feat/management-notifications
🤖 Agent: queued at f193527cdc. The notifications go out from their own management-notify thread through an mpsc outbox, so the supervisor never blocks on a slow socket, and stopping_told makes server/stopping fire once per run, as vanilla's ServerStateNotifications do. Registered's Drop unregisters a socket on every exit path. The parity driver checks both method names against the vanilla server.
2026-11-02 12:38:27 +01:00
Ludovic MarieandClaude Opus 5.5 e78f71a47b Telemetry: what slow frames spent their time in
A player who allows performance statistics now also says what froze: each
frame of 250 ms or more while playing counts under the step of the render
thread it spent most of its time in (the slowest outer span, then the span
inside it that took at least half of it: renderer/r_uploads, tick/tick_mobs).
The session's perf item carries at most 16 such steps (slow_frames), with
their frame count, longest frame and longest step.

spans: a thread can record its own spans with spans off (record_here), wall
time only, and each span keeps its depth so culprit() can follow the
nesting. The render thread records only with performance statistics on:
about 5 us a frame of 100 spans on an M4 Pro, nothing without them.

Server: perf.slow_frames (JSON, migrated onto old databases), and
/v1/aggregate's slow_frames per release and step, shown from as many
installs as the groups need.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:38:23 +01:00
Ludovic Marie 18f8a21647 Merge pull request #7131 from HrafnaValkyrja/fix/armor-stand-no-gravity
🤖 Agent: queued at 161e81b0bf. Matches ArmorStand.hasPhysics (!isMarker && !isNoGravity): travel returns early, so a NoGravity stand neither falls, presses plates nor takes block fire, and the velocity stays put. NoGravity is written only when set, as Entity.saveWithoutId does, and the save round-trip test pins it.
2026-11-02 12:38:23 +01:00
Ludovic Marie 64c783dc93 Merge pull request #7123 from mitsa-ng/codex/pathfinding-upstream-pr-20261102
🤖 Agent: queued at ea292adbe1. Only runs while a horizon-ctl goto is active, so normal play pays nothing. Keeping the failed (from, to) edges and resetting recovery only on measurable waypoint progress breaks the replan loop on the same blocked step. Small style point: the #[path = "agent_navigation.rs"] mod could be a plain game/agent/navigation.rs module.
2026-11-02 12:38:19 +01:00
Ludovic Marie c9f800d1b2 Merge pull request #7120 from MSedgeMC/fix/create-world-tab-keys
🤖 Agent: queued at f3b26725d6. Follows TabNavigationBar.keyPressed: nothing happens without hasControlDownWithQuirk (Cmd on macOS), a digit maps to floorMod(n-1, 10) clamped to the last tab, Tab/Shift+Tab cycle with floorMod, and the click sounds only when the tab really changed (TabManager.setCurrentTab). Open rule, experiment and preparing overlays keep the keys, as vanilla's own screens on top would.
2026-11-02 12:38:14 +01:00
Ludovic Marie 6115ab779a Merge pull request #7141 from masa-masa-0314/fix/creative-search-keybindings
🤖 Agent: queued at efff801398. Resolving the physical key through KeyBinds::container_hotbar_key before the swap honours rebound, keypad and offhand bindings, as vanilla's hotbar key check does, and an unbound digit returns false so it still types into the search box.
2026-11-02 12:38:10 +01:00
Ludovic Marie 0edb107a8e Merge pull request #7143 from DUzzL/fix/7000-elytra-inventory
🤖 Agent: queued at 5c314bd4ab. Matches WingsLayer's armorCutoutNoCull: only the elytra's own vertex range skips the preview's n.z >= 0 back-face cull, and its back faces are re-wound so they still draw, while the rest of the preview keeps culling. It runs only while the inventory preview draws.
2026-11-02 12:38:06 +01:00
Alexander KrylovandHermes Agent 421495d8e9 Add a native mod image slot and its helper
A native mod can draw only what the HUD atlas holds, so a world map or any
other view built from its own pixels has nowhere to put them. Reserve one
1024x1024 atlas slot for it and hand the mods native_mod_image, which patches
that slot from an RGBA buffer; native_sprite then draws it cropped and
stretched like any other sprite.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 14:38:03 +03:00
Ludovic Marie 041f55e121 Merge pull request #7133 from Akirabane/fix/7067-minecart-world-border-2
🤖 Agent: queued at a5cdf9fd53. Same as #7077 for boats: carts pass the border to Entity.move's collision, as vanilla's collectColliders adds WorldBorder's shape for any entity near it, and the test stops a cart-sized box exactly at the wall (0.11 of its 0.4 move).
2026-11-02 12:38:02 +01:00
Arslan ArmanulyandClaude Opus 5.5 d5418e0137 Shader packs: geometry stages whose varyings travel in interface blocks
A shadow program with a geometry stage is folded into one vertex stage
(translate/geometry.rs), which reads both stages' declarations one variable
at a time. An `out` interface block in the vertex stage was read as a single
declaration: each member after the first had its type taken for a variable
name, and that type (`float`...) was then renamed throughout the vertex
stage, so the program failed with "`far` is `float` in one stage and
`hz_vs_float` in the other" and the shadow map stayed clear.

- Both stages' blocks become plain varyings first (interface_blocks), and
  `flatten` now also lowers a block with an instance array, the form of a
  geometry stage's input (`vIn[i].m` reads `hz_<block>_m[i]`).
- A geometry stage's input and output blocks may share a name (GLSL 4.60,
  4.3.9): its inputs read as `hz_in_<block>_<member>`, so they no longer
  collide with its outputs.
- Statement reading takes every qualifier the translator knows (`sample`,
  `precise`): one it missed made a type a renamed name the same way.

Part of #6812

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 16:37:40 +05:00
MSedgeMC bbd7288dac Merge upstream/main into the moving-piston base fix 2026-11-02 11:37:29 +00:00
Akirabane 71b32333ef Merge remote-tracking branch 'origin/main' into fix/7078-pearl-chorus-border 2026-11-02 12:37:00 +01:00
yosifdheef 39b923f668 Merge remote-tracking branch 'upstream/main' into fix/sugarcane-water 2026-11-02 14:36:26 +03:00
Ludovic Marie cb9eb0a492 Merge pull request #7137 from MSedgeMC/fix/dolphin-baby-target
🤖 Agent: queued at 2e9421e2c4. Matches Dolphin.canAttack, which returns !isBaby() && super.canAttack: a baby dolphin spares whatever hurt it and an adult still takes its attacker, and the test covers both sides.
2026-11-02 12:36:09 +01:00
MSedgeMC 6e6deabda9 use_item_on: a placed jukebox keeps its record and has_record
JukeboxBlock.setPlacedBy reads the placed item's block_entity_data and sets
HAS_RECORD when it carries a RecordItem, so a Creative pick of a jukebox with a
disc keeps the disc when it is placed. The behaviour is on main; no harness case
pinned it.
2026-11-02 11:36:07 +00:00
Ludovic Marie f0a7839252 Merge pull request #6991 from HrafnaValkyrja/fix/cart-portal
🤖 Agent: queued at e38e711286. The walk is kept: one for_each_entity! filtered by Entity::is_vehicle, run as a vehicle pass before tick_boats/tick_carts and an others pass after, with each pass's retain keeping the other group's states. Mob riders cross with their vehicle through calculatePassengerTransition's offset and turn (move_passengers), and the window's own rider still travels by its own trip. Per-tick cost: the second pass walks the entity lists once more, filtered before any work. Main merges cleanly; clippy -D warnings and the portal tests pass on playhorizon, mod-api --check passes; run-parity is the after-check.
2026-11-02 12:36:05 +01:00
Ludovic Marie 936e348a68 Merge pull request #7122 from MSedgeMC/fix/piston-orphan-use
🤖 Agent: queued at c928a17802. Matches MovingPistonBlock.useWithoutItem: an orphaned moving piston (no block entity) is removed on the server, and one still in flight passes. I pushed one fix: the removal returns Consume, not Success, because vanilla returns CONSUME, which uses the action without swinging the arm (InteractionResult::should_swing). The two moving-piston tests pass.
2026-11-02 12:36:01 +01:00
huiandZCode AI agent (GLM-5.3) 8cf71732d0 Fix two catalog hooks the package split broke: living_breathe and menu_slot_rules
Since #5446, breathing.rs and menu.rs compile into horizon-client-lib,
but native-hooks.json still targeted horizon_client::breathing::… and
horizon_client::menu::…, so horizon-mixins refused a bundle naming
either hook — no mod could use them. The targets name horizon_client_lib
now, and catalog_matches_hook_points expects that.

The test also checks every target's crate really compiles its module:
the first path segment after horizon_client or horizon_client_lib must
be a module of that crate's lib root. A file moved between the two
packages then fails the test even if the catalog string and the
expectation agree — which is how these two went unnoticed.

Proven with horizon-mixins apply on this branch: minimal bundles on
living_breathe and menu_slot_rules by name both reach status ok, their
helpers compiled into the staged breathing.rs and menu.rs. The module
check was proven the same way: with the old target and the expectation
made to agree, the test still fails on horizon_client::menu.

Fixes #6662.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-02 12:35:22 +01:00
arismoko 16c57b72c5 Cover living free functions in hook catalog test 2026-11-02 11:35:13 +00:00
Ludovic Marie a90ee282ae Merge pull request #7145 from horizon-rust/fix/prepare-output-skip-languages
🤖 Agent: queued. This fixes every PR's parity, which has failed in its first step since #6959. Checked on the 8 preparations of the two Mac runners: each gives OUTPUT_VERSION 3's digest again with the new rules. The one risk: the files left out could hide a real change to the output. They are all copied or written by something other than the export, and en_us.json / deprecated.json, which come from the JAR, still count (tested).
2026-11-02 12:34:45 +01:00
Ludovic MarieandClaude Opus 5.5 c928a17802 Moving piston click: CONSUME, not SUCCESS
MovingPistonBlock.useWithoutItem returns InteractionResult.CONSUME when it
removes an orphaned moving piston, so the player's arm does not swing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:34:33 +01:00
Ludovic Marie a0a2514746 Merge pull request #7124 from MSedgeMC/chore/ledger-client2-019-rows
🤖 Agent: queued at 095f156d94. All 16 changed rows check out against origin/main, for example CreateWorldScreen.openFresh → crates/horizon-client/src/title.rs::Title::new_world.
2026-11-02 12:33:28 +01:00
Ludovic MarieandClaude Opus 5.5 00e717781f prepare_output: leave out files the preparer's export doesn't write
Every PR's parity job failed since #6959 with "the prepared output changed
(23198 files) but OUTPUT_VERSION is still 3": CI now clones the 142 other
languages and pack.mcmeta into a preparation, and the digest counted them.
It also counted asset-index.json (the sounds step) and the game's run-time
caches (horizon-sprites.cache, horizon-data.bin), so it depended on whether
a harness had run the game before the check.

With these left out, the 8 preparations on the two Mac runners (old and new,
with and without languages) all give OUTPUT_VERSION 3's digest again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:33:27 +01:00
Ludovic Marie ed033c0927 Merge pull request #7125 from MSedgeMC/chore/ledger-client-007
🤖 Agent: queued at 8eaecfcd5b. All 8 changed rows check out against origin/main, for example BoggedModel.setupAnim → crates/horizon-client/src/mobs/anim.rs::setup.
2026-11-02 12:33:24 +01:00
Ludovic Marie af92bd465d Merge pull request #7126 from MSedgeMC/chore/ledger-client-001-rows
🤖 Agent: queued at 1ff67bc1a9. All 5 changed rows check out against origin/main, for example ClientChunkCache.replaceBiomes → crates/horizon-client/src/game/remote_chunks.rs::Game::remote_chunks_biomes.
2026-11-02 12:33:20 +01:00
Ludovic Marie cbcd3cdeca Merge pull request #7127 from MSedgeMC/chore/ledger-client-002
🤖 Agent: queued at a7f1e9eac5. All 11 changed rows check out against origin/main, for example ClientPacketListener.handleMinecartAlongTrack → crates/horizon-client/src/game/remote.rs::Game::remote_move_minecart.
2026-11-02 12:33:15 +01:00
Ludovic Marie dc30b6377e Merge pull request #7129 from MSedgeMC/chore/ledger-game2-010.tsv
🤖 Agent: queued at 412583dfb5. All 4 changed rows check out against origin/main, for example Level.loadedAndEntityCanStandOnFace → crates/horizon-client/src/mobs/shulker.rs::can_stay_at.
2026-11-02 12:33:10 +01:00
Ludovic Marie b8abbfa510 Merge pull request #7138 from MSedgeMC/chore/ledger-client2-007
🤖 Agent: queued at 37b48246bb. All 10 changed rows check out against origin/main, for example NoxiousGasCloudParticle.tick → crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle.
2026-11-02 12:33:06 +01:00
Ludovic Marie 9a0c3e940d Merge pull request #7140 from MSedgeMC/chore/ledger-client2-008
🤖 Agent: queued at fe27ba6a99. All 3 changed rows check out against origin/main, for example ServerPackManager.pushLocalPack → crates/horizon-client/src/server_packs.rs::ServerPacks::push_local.
2026-11-02 12:33:01 +01:00
MSedgeMC 621b9a1b3f Clear stale game2-011 rows main already implements
tools/port_map/game2-011.tsv: FixedBiomeSource.findBiomeHorizontal is the
fixed-source path of the ring search, stronghold.rs::RingSearch::find_fixed,
not an override that needs its own port.
2026-11-02 11:32:35 +00:00
LokiandAntigravity b009a2b736 mod_api: update generated output for latest upstream main hooks
Co-Authored-By: Antigravity <[email protected]>
2026-11-02 20:32:06 +09:00
Ludovic Marie 6ad45198ee Merge pull request #7059 from breachato/fix/7013-blockstate-stack
🤖 Agent: queued at 82c4c758c5. Matches loadBlockStateDefinitionStack: a stacked blockstate file keeps every pack's definition bottom first under horizon:layers and the catalog binds them in turn, so CraftEngine's powered-only fence gate keeps vanilla's unpowered states. A single definition stays the file as written, so the common case adds no work. I merged main (the blockstate read now goes through prepared_files::read_to_string); clippy and the render crate's packs/models tests (47) pass on playhorizon.
2026-11-02 12:30:53 +01:00
MSedgeMC 5b2970e45d Breaking a moving piston removes its extended base 2026-11-02 11:30:40 +00:00
MSedgeMC fe27ba6a99 port_map: clear stale client2-008 rows
A world's own resource pack is pushed as a local pack, and the elytra
and riding sound loops are in the sound engine.
2026-11-02 11:30:19 +00:00
MSedgeMC 37b48246bb port_map: clear stale client2-007 rows
Potent sulfur's noxious gas particles, the spyglass particle hiding,
the spore blossom cap and the nine-slice metadata check are all in the
tree now, and one citation that named no function is repointed.
2026-11-02 11:30:12 +00:00
arismoko d9fd6ae395 Fix attachment sync review findings 2026-11-02 11:30:10 +00:00
MSedgeMC 2e9421e2c4 A baby dolphin takes nobody as its target
`Dolphin.canAttack` is `!isBaby() && super.canAttack(target)`, so a hit on
a baby dolphin is never answered. Horizon's target goals asked
`spares_target`, which knew about a golem sparing creepers and nothing
about dolphins, so a hurt baby dolphin fought back.

Corrects the Dolphin.canAttack row in game-010.tsv.
2026-11-02 11:29:52 +00:00
DUzzL 5c314bd4ab Draw the elytra's wings from both sides in the inventory preview (fixes #7000) 2026-11-02 12:29:50 +01:00
Ludovic Marie 6d942b4627 Merge pull request #7135 from horizon-rust/prO/mod-api-regen-6965
🤖 Agent: regen of mod_api/generated.rs after #6965's doc-only native_hooks.rs edits; xtask mod-api --check passes.
2026-11-02 12:29:45 +01:00
Loki b4717f69f9 Merge remote-tracking branch 'origin/main' into fix/6405-item-lore-rarity-tooltip 2026-11-02 20:29:30 +09:00
LokiandAntigravity 6e9073626e item_tooltip: decode string tags as literal text without json parsing (fixes #6405)
Co-Authored-By: Antigravity <[email protected]>
2026-11-02 20:29:24 +09:00
Ludovic MarieandClaude Opus 5.5 eae6fc6366 Prepared data file: structure templates in it, texture folders listed from it
The data file now holds worldgen/structure (1,511 templates, 4 MB): world
generation loaded each template from its own file, and /place's suggestions
listed the tree. The file's version goes to 2, so a version 1 file (without
them) is refused and written again.

prepared_files::files_under lists a held folder from the file's names, with
no disk listing. The texture scanners that each walked the texture folders
(the atlas's texture list, the HUD atlas, Fusion's .mcmeta scan, the GUI
sprites' nine-slice metadata, OptiFine's random, blink and emissive scans)
use it for the vanilla root; packs and mirrors are still walked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:28:52 +01:00
masa-masa-0314andCodex efff801398 Inventory: honor rebound numeric keys in creative search
Resolve the configured hotbar or offhand binding after the search tab's physical numeric-key gate. Preserve search input and existing hover/cursor restrictions.

Fixes #7134

Co-Authored-By: Codex <[email protected]>
2026-11-02 19:28:28 +08:00
Ludovic MarieandClaude Opus 5.5 ba6a13d77c Regenerate the mod API after #6965
#6965 removed the in-frame mentions from hook doc comments in native_hooks.rs
without regenerating mod_api/generated.rs, so generated_output_is_current fails
on main and on every branch merged with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:28:21 +01:00
Evrim CicekandClaude Opus 5.5 3cbd09e448 An anvil landing on turtle eggs drops its item on top of them, not inside
FallingBlockEntity.tick spawns the item of a block it cannot place with
spawnAtLocation, at the entity's own position. Horizon popped it like a
mined block, somewhere inside the eggs' cell, where it was pushed out
sideways and ended up as far as 2.5 blocks away. The anvil's break sound
(Fallable.onBrokenAfterFall) now plays only when the item drops, and the
item's motion comes from its own random, not the level's.

Part of #5858.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:28:15 +03:00
Akirabane 77215da039 Merge remote-tracking branch 'origin/main' into fix/7078-pearl-chorus-border 2026-11-02 12:27:37 +01:00
AkirabaneandClaude Opus 5.5 5d896c0471 Test that a pearl stops on the world border
A pearl thrown at the border from inside ends its tick on it, 1.0E-5F inside
maxX (the inset WorldBorder.clampVec3ToBound's class file holds), facing east,
with its oldPosition inside for ThrownEnderpearl's teleport. Each axis clamps
on its own, maxX itself counts as outside (isWithinBounds), and a block hit
inside, a ray staying inside or a ray from outside are left alone.

Refs #7078

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:27:36 +01:00
Ludovic Marie 9aede76945 Merge pull request #7107 from MSedgeMC/chore/ledger-client2-002
🤖 Agent: queued at 83060f64a7. All 6 changed rows check out against origin/main, for example ScreenEffectRenderer.renderItemActivationAnimation → crates/horizon-client/src/game/totem.rs::Game::item_activation_geometry.
2026-11-02 12:27:35 +01:00
AkirabaneandClaude Opus 5.5 71a70d9233 Projectiles stop at the world border, so ender pearls no longer cross it
ProjWorld::clip now follows CollisionGetter.clipIncludingBorder: a ray that
starts inside the border and would end (or hit a block) outside it stops on
the border, at WorldBorder.clampVec3ToBound of that point, facing
Direction.getApproximateNearest of the travel. Thrown projectiles, fireballs,
shulker bullets and rockets all clip through it in vanilla
(ProjectileUtil.getHitResultOnMoveVector), so a pearl now lands on the border
and ThrownEnderpearl.onHit teleports its owner to oldPosition, inside.

The border goes in ProjWorld rather than MobWorld; the client's own
projectile ticks use it too, as a vanilla client level does.

Refs #7078

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:27:30 +01:00
Ludovic Marie a542f83770 Merge pull request #7110 from MSedgeMC/chore/ledger-game-013.tsv
🤖 Agent: queued at 3ccde697e4. All 6 changed rows check out against origin/main, for example AbstractNautilus.isAggravated → crates/horizon-client/src/mobs/nautilus.rs::aggravated.
2026-11-02 12:27:30 +01:00
Ludovic Marie 7ea10611cf Merge pull request #7116 from MSedgeMC/chore/ledger-client-006
🤖 Agent: queued at 6e39c3a1e7. All 4 changed rows check out against origin/main, for example EquineSaddleModel.setupAnim → crates/horizon-client/src/mobs/anim.rs::setup.
2026-11-02 12:27:27 +01:00
Ludovic Marie 6de670b0fa Merge pull request #7117 from MSedgeMC/fix/ledger-game-010
🤖 Agent: queued at 59a29e1ca4. All 10 changed rows check out against origin/main, for example PathNavigation.shouldRecomputePath → crates/horizon-client/src/mobs/nav.rs::Mob::should_recompute_path.
2026-11-02 12:27:22 +01:00
Ludovic MarieandClaude Opus 5.5 82c4c758c5 Merge main into #7059
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:26:16 +01:00
MSedgeMC 412583dfb5 Clear stale game2-010 rows main already implements
tools/port_map/game2-010.tsv: four rows left missing with an unsure note
now cite where the behaviour is - Level.loadedAndEntityCanStandOnFace,
BlockGetter.clipWithInteractionOverride, ChunkPos.distanceSquared and
LevelReader.findBlocksInBoxByManhattanDistance.
2026-11-02 11:25:13 +00:00
Ludovic Marie 73f5486b90 Merge pull request #7108 from Akirabane/fix/7080-mob-world-border
🤖 Agent: queued at df7f4fbd7c. Matches vanilla: randomTeleport clamps the target through WorldBorder.clampVec3ToBound (Mth.clamp to max - 1.0E-5F) before the column search, and mob moves collect the border's collision shape like any entity's. The enderman test checks the landing x against an independently stated border edge.
2026-11-02 12:25:11 +01:00
Ludovic Marie 5450a8d602 Merge pull request #6965 from rdbtCVS/remove-in-frame
🤖 Agent: queued at d5f4c80643. Re-merged main four more times (after #6923, #7003, #7022, #7037 and #7071). Each time the in-frame pieces main touched stay removed: host_entity params, the host's Tab entry, Keys in boats, the in-frame branch of open_world (#7071's log line kept). clippy -D warnings and the 239 lan tests pass on playhorizon at the fourth merge, clippy again at this head; run-parity is the after-check.
2026-11-02 12:25:06 +01:00
MSedgeMC a7f1e9eac5 Clear stale client-002.tsv rows main already implements
Eleven rows of the client-002 batch said missing while main has them: the minecart's rail steps,
chunk biomes, the transient block, the totem the death protection pops up, the level loading
screen, look-at and the spectator camera, enforcesSecureChat, the simulation distance, the
crosshair entity the suggestions offer and the level-chunks-load-start event.
2026-11-02 11:24:52 +00:00
MSedgeMC 1ff67bc1a9 Port map: clear client-001 rows the tree already implements
Three rows still said a behaviour was missing while the tree has it:
server biome changes reach the client, UpdateTags is read in
configuration, and sounds over ten blocks are distance-delayed.

Two rows recorded gaps that have since closed: the code of conduct is
shown and answered before the connection continues, and a precipitation
column that is not loaded answers none.
2026-11-02 11:24:22 +00:00
MSedgeMC 8eaecfcd5b Clear stale client-007.tsv rows main already implements
Eight missing rows of the client-007 batch are in the code: the bogged's sheared mushrooms, the
arrow's wobble while it shakes, the shulker bullet's model and its tumble toward the flight, the
player's cape and ears layers, and the flower bed's 0.25 shift on a copper golem's antenna.
2026-11-02 11:24:20 +00:00
MSedgeMC 095f156d94 Port map: clear client2-019 world-open rows main already implements
Six WorldOpenFlows rows and four create-world rows still recorded gaps
the tree has closed. The world's own resources.zip is pushed as a local
pack before the connect, the create screen waits on createWorld.preparing,
Re-Create carries the old world's packs over, the temp pack folder is
removed once installed, the picked data packs reach the new world, an
unreadable level.dat opens the recovery screen, and opening an old world
runs under the file-fixer progress screen.
2026-11-02 11:24:04 +00:00
MSedgeMC d9e5bc1bc8 A moving piston with no block entity is removed by a click 2026-11-02 11:23:12 +00:00
Nati ea292adbe1 Fix agent navigation for partial paths and stuck recovery 2026-11-02 19:23:03 +08:00
Ludovic Marie 17cdbc2639 Merge pull request #7093 from GIGABAIT93/i18n/uk-horizon-settings
🤖 Agent: queued at 82b12d654f. All 444 uk_ua keys exist in en_us and keep the same %s/%n$s placeholders (checked by script against main), so no translated string can drop or reorder an argument at format time.
2026-11-02 12:23:01 +01:00
MSedgeMC f3b26725d6 Create World: Ctrl plus a digit or Tab picks a tab
The screen's key path never reached its tab bar: only a mouse press on the
tab strip switched tabs, while Ctrl+1..9 did nothing and Ctrl+Tab moved the
keyboard focus on instead. TabNavigationBar.keyPressed takes both while Ctrl
is held, before the open tab's own widgets see the key.
2026-11-02 11:22:36 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 f006d0635b Keep a splash or lingering potion's UUID through a save
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 06:22:27 -05:00
arismoko 0c9e0a3563 Merge branch 'main' of https://github.com/horizon-rust/horizon into v2/ses_01M6DYMGWF02H5RBZ0RFVQ78M9-test-host
# Conflicts:
#	crates/horizon-client/src/mod_api/generated_host.rs
2026-11-02 11:21:49 +00:00
Ludovic MarieandClaude Opus 5.5 18ea1429d8 perf_bench: PERF_BENCH_TRACE, each load's timeline for Horizon and vanilla
Horizon's status every 50 ms after each teleport, and the vanilla driver's
loaded chunk count and the chunks it holds within the render distance minus
one, in one JSON-lines file: when the columns arrive against when the load
ends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:21:32 +01:00
Ludovic Marie 9cf477cc53 Merge pull request #7072 from horizon-rust/perf/prepared-data-folders
🤖 Agent: verify green on this exact head (retargeted to main after #7055 merged). The one ordering-sensitive reader is the client biome table: it keeps vanilla's file-name order and its flat folder, so biome indices don't move.
2026-11-02 12:21:27 +01:00
Ludovic MarieandClaude Opus 5.5 2572872fa8 lan_auth harness: run its test in horizon-client-lib
net.rs moved to horizon-client-lib in the client library split (42a5981e94).
`cargo test -p horizon-client --lib` no longer has net::tests, so the
harness ran zero tests and compared vanilla's cases against an empty list:
parity-main failed on lan_auth and bisected it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:21:07 +01:00
Akirabane a5cdf9fd53 Merge origin/main into fix/7067-minecart-world-border 2026-11-02 12:20:47 +01:00
MSedgeMC 59a29e1ca4 port map: game-010.tsv dolphin rows whose fixes already landed
The dolphin's item play (pickup, the swim-to-item goal and its drop) and
its GotFish/Moistness save are in the tree, but those eight rows still
said missing. Two other rows listed two functions in `where`, which
`map-check` cannot read, so it reported them as dangling.
2026-11-02 11:20:47 +00:00
AkirabaneandClaude Sonnet 5.5 baa51871cb Test a minecart-sized body against the world border
A cart is 0.98 wide: rolling east at its top speed of 0.4 a tick, 0.11 blocks
short of the wall, it must be stopped by the border's collision shape. The
expected 0.11 is worked out by hand from the cart's box and the wall, not read
from the code under test.

Refs #7067

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 12:20:47 +01:00
Ludovic Marie 3031410be6 Merge pull request #7087 from MSedgeMC/chore/ledger-game-007-x
🤖 Agent: queued at ae0705eedd. All 3 changed rows check out against origin/main (each cited file and symbol exists), for example BackUpIfTooClose.create inline → crates/horizon-client/src/mobs/piglin.rs::too_close.
2026-11-02 12:20:43 +01:00
Ludovic Marie 75746c939e Merge pull request #7090 from MSedgeMC/chore/ledger-rest-010
🤖 Agent: queued at 02696a0e62. All 5 changed rows check out against origin/main (each cited file and symbol exists), for example StringTagVisitor.handleKeyEscape inline → crates/horizon-client-core/src/snbt.rs::Pretty::compound.
2026-11-02 12:20:37 +01:00
Ludovic Marie d5f4c80643 Merge main into #6965
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-02 12:20:32 +01:00
Ludovic Marie b56fc7d451 Merge pull request #7092 from MSedgeMC/chore/ledger-client2-014-y
🤖 Agent: queued at 54a006d4ef. All 23 changed rows check out against origin/main (each cited file and symbol exists), for example ConfirmScreen.getNarrationMessage port → crates/horizon-client/src/game/screen_info.rs::confirm.
2026-11-02 12:20:32 +01:00
Ludovic Marie e095ddb97d Merge pull request #7095 from MSedgeMC/chore/narrator-rest-011
🤖 Agent: queued at 160c052dbe. All 1 changed rows check out against origin/main (each cited file and symbol exists), for example CommonComponents.joinForNarration port → crates/horizon-client-render/src/widgets.rs::join_for_narration.
2026-11-02 12:20:28 +01:00
Ludovic Marie 9738f43a76 Merge pull request #7096 from MSedgeMC/chore/ledger-rest-005-rows
🤖 Agent: queued at 63e748b461. All 25 changed rows check out against origin/main (each cited file and symbol exists), for example RemotePlayer.aiStep inline → crates/horizon-client/src/game/remote.rs::Game::tick_remote_entities.
2026-11-02 12:20:23 +01:00
Ludovic Marie c605e5633a Merge pull request #7097 from MSedgeMC/chore/port-map-rest-012
🤖 Agent: queued at 68eede4deb. All 4 changed rows check out against origin/main (each cited file and symbol exists), for example ClientboundAddTransientBlockPacket.write inline → crates/horizon-client/src/game/transient_blocks.rs::Game::add_transient_block.
2026-11-02 12:20:19 +01:00
Ludovic Marie d1d900f0ad Merge pull request #7098 from MSedgeMC/chore/ledger-client-005
🤖 Agent: queued at c3ecf70704. All 17 changed rows check out against origin/main (each cited file and symbol exists), for example VillagerRenderer.getShadowRadius port → crates/horizon-client/src/shadows.rs::mob_shadow_radius.
2026-11-02 12:20:15 +01:00
Ludovic Marie 0681bf6324 Merge pull request #7099 from MSedgeMC/chore/narrator-rest-012
🤖 Agent: queued at bee28cddb6. All 1 changed rows check out against origin/main (each cited file and symbol exists), for example RepeatedNarrator.narrate skip:service → .
2026-11-02 12:20:10 +01:00
Ludovic Marie 260d229a95 Merge pull request #7091 from jaszkus/fix/launcher-windows-shortcut-target
🤖 Agent: queued at ea0c475c02. Writing through IShellLinkW + IPersistFile::Save gives the shell its target ID list, which the hand-laid lnk lacked. The windows crate is MIT and already in the lockfile through gpui. CI builds Windows only at release, so this relies on your Windows 11 run (437 launcher tests), and the new test reads the target back with GetPath, the exact failure. Small regression: the old temp-and-rename write was atomic and Save is not; harmless for a shortcut.
2026-11-02 12:20:06 +01:00
Ludovic Marie 638b6102bf Merge pull request #7101 from MSedgeMC/chore/narrator-client2-010-b
🤖 Agent: queued at 9fe8a7d1ce. All 13 changed rows check out against origin/main (each cited file and symbol exists), for example Checkbox.updateWidgetNarration inline → crates/horizon-client/src/screen_narrator.rs::narrate.
2026-11-02 12:20:01 +01:00
MSedgeMC 6e39c3a1e7 Clear stale client-006.tsv rows main already implements
The four missing rows of the client-006 batch are in the code: the equine saddle lines that show
only while the horse is ridden, the copper golem's antenna block transform, and the spear's STAB
swing in third and first person.
2026-11-02 11:20:00 +00:00
Ludovic Marie cdb804cc79 Merge pull request #7102 from MSedgeMC/chore/narrator-client2-009-b
🤖 Agent: queued at b921e99809. All 3 changed rows check out against origin/main (each cited file and symbol exists), for example Gui.update inline → crates/horizon-client/src/game/narrator.rs::Game::narrate_screen.
2026-11-02 12:19:57 +01:00
Ludovic Marie 245dd1fe0c Merge pull request #7103 from MSedgeMC/chore/narrator-client2-015-b
🤖 Agent: queued at 3a1098e7bd. All 2 changed rows check out against origin/main (each cited file and symbol exists), for example StatsScreen.GeneralStatisticsList.Entry.getNarration inline → crates/horizon-client/src/game/stats_screen.rs::Game::draw_stats.
2026-11-02 12:19:51 +01:00
Ludovic Marie a9d0086422 Merge pull request #7104 from MSedgeMC/chore/narrator-client2-020-b
🤖 Agent: queued at 622bd29d4f. All 2 changed rows check out against origin/main (each cited file and symbol exists), for example WorldSelectionList.updateWidgetNarration inline → crates/horizon-client/src/screen_narrator.rs::narrate.
2026-11-02 12:19:47 +01:00
Ludovic Marie a38e593abe Merge pull request #7105 from MSedgeMC/chore/narrator-client2-013-b
🤖 Agent: queued at ba4d706f00. All 1 changed rows check out against origin/main (each cited file and symbol exists), for example AccessibilityOnboardingScreen.init port → crates/horizon-client/src/title/onboarding.rs::Title::onboarding_buttons.
2026-11-02 12:19:43 +01:00
Ludovic Marie 638e9938cc Merge pull request #7106 from MSedgeMC/chore/ledger-game-002-2
🤖 Agent: queued at 36744cd76c. All 14 changed rows check out against origin/main (each cited file and symbol exists), for example ServerLevel.EntityCallbacks.onDestroyed inline → crates/horizon-client/src/lan/waypoints.rs::Waypoints::untrack.
2026-11-02 12:19:38 +01:00
arismoko 0fc26de99b Keep vanilla drowning event shape 2026-11-02 11:19:24 +00:00
HrafnaValkyrja 161e81b0bf Merge remote-tracking branch 'origin/main' into fix/armor-stand-no-gravity
# Conflicts:
#	crates/horizon-client/src/game/entity.rs
2026-11-02 06:19:09 -05:00
MSedgeMC 9baedcd5ff port map: clear game-001 rows main already implements
The biome resend after /fillbiome exists, and two notes described gaps
that have closed: spectator tracking and the spawn search's chunk wait.
2026-11-02 11:18:37 +00:00
arismoko 479a03daef Fix attachment sync review findings 2026-11-02 11:18:18 +00:00
AkirabaneandClaude Sonnet 5.5 f193527cdc horizon-server: the management protocol pushes notifications, starting with server/started and server/stopping
Vanilla's JsonRpcNotificationService broadcasts to every connection of the ManagementServer. Horizon
served the protocol but never sent one. The supervisor now keeps the upgraded connections and a queue
with a thread that writes to them, so the game's output pump and the supervisor never wait on a slow
client. A notification is a JSON-RPC request without an id, its one parameter in an array, as
JsonRPCUtils.createRequest writes it.

server/started goes out at the game's Done line (vanilla: DedicatedServer.initServer, after Done).
server/stopping goes out when the supervisor starts a stop or a restart, or when the game's own stop
logs "Stopping server" (vanilla: DedicatedServer.stopServer, first of all), once per run of the game.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 12:17:49 +01:00
MSedgeMC 3ccde697e4 Clear stale game-013 rows main already implements
tools/port_map/game-013.tsv: six rows that said the behaviour was missing
now cite the fn that does it - AbstractNautilus.isAggravated, the
ZombieNautilus and Sheep implicit component, and the three
Rabbit.RaidGardenGoal methods.
2026-11-02 11:17:45 +00:00
Ludovic Marie 9051bac55b Merge pull request #7030 from Xmilon/native/arrow-loose
🤖 Agent: queued at 828ddff14f. Dyna's clone finding is fixed: release_bow borrows the caller's stack, so a release with no mod loaded costs one empty dispatch. bow_release_power keeps getPowerForTime's (f*f + 2f)/3 capped at 1 after the projectile lookup, in vanilla's order. Keeping has_ammo as the real release-time answer is fine. Merged with main; xtask mod-api --check passes.
2026-11-02 12:17:25 +01:00
Ludovic Marie e74875204a Merge pull request #7089 from horizon-rust/perf/skin-exact-metal
🤖 Agent: Metal's fast math fused the skinning shader's transform_point3 (1 ulp off the CPU on the Mac mini, exact on lavapipe); xor with an opaque zero forces glam's rounding order; the shader test now passes on Metal.
2026-11-02 12:17:20 +01:00
Ludovic Marie d2470290da Merge pull request #7069 from CocofireHD/feature/f3s-atlas-dump
🤖 Agent: queued at e589de1c48. Runs only on the F3+S press, writing into the player's own screenshots/debug as TextureUtil.getDebugTexturePath does. The size check before encoding turns a freed-pixels atlas into an error instead of a corrupt PNG. The test decodes the written PNG and checks the dumpSpriteNames list against independent texel rects.
2026-11-02 12:17:14 +01:00
Ludovic Marie 8f76cb4a8d Merge pull request #7071 from GIGABAIT93/feat/vanilla-join-log-lines
🤖 Agent: queued at c2ae983c93. Two lines per join or world open, no per-frame cost. The connector counter starts at #1 as ConnectScreen.UNIQUE_THREAD_ID does, the port defaults to 25565 from parse_address before SRV as ServerAddress logs it, and friend: invite codes never reach the log. The test pins the strings launchers parse, a machine-read contract worth pinning.
2026-11-02 12:17:07 +01:00
arismoko 1ed50d2e5b Set tick phase explicitly in resting item test 2026-11-02 11:16:50 +00:00
MSedgeMC 83060f64a7 port_map: clear stale client2-002 rows
The post_effect chain system is ported, so the ShaderManager chain rows
are no longer missing, and the totem's item activation citation is
repointed to the function that builds it.
2026-11-02 11:16:29 +00:00
Ludovic Marie 58312968a8 Merge pull request #6973 from MSedgeMC/chore/port-map-rest-020
🤖 Agent: queued at 716ff3eaa1. MSedgeMC merged main; the death_message_reaches row resolves on origin/main (PlayerList.broadcastSystemToAllExceptTeam).
2026-11-02 12:15:22 +01:00
Ludovic Marie a501feb9f5 Merge pull request #7079 from MSedgeMC/chore/ledger-animal-1-x
🤖 Agent: queued at c59fe9230f. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 5 rows resolve on origin/main, for example Allay.hasNonMatchingPotion → crates/horizon-client/src/mobs/allay.rs::considers_item_equal.
2026-11-02 12:15:17 +01:00
Ludovic Marie b485b273cf Merge pull request #7068 from MSedgeMC/chore/ledger-classes-09-w
🤖 Agent: queued at 56d9b22e43. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 3 rows resolve on origin/main, for example ToggleTooltips → crates/horizon-client/src/game/commands/items.rs.
2026-11-02 12:15:13 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 4d83f58262 Save wind charges, keep a lingering potion's type, read block-attached entities summoned without block_pos
A vanilla world loaded in Horizon and saved lost its in-flight wind charges and
breeze wind charges (saved_with_chunk left them out although fireball_to_nbt
writes them), turned a lingering potion entity into a splash one when its Item
was a splash potion (the entity type was derived from the item), and could not
summon a painting or item frame (block_pos was required; vanilla defaults it to
the block holding the position). saved_with_chunk now has no wildcard arm so a
new projectile kind must be decided there.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 06:15:08 -05:00
Ludovic Marie 9dfc8689dc Merge pull request #7035 from MSedgeMC/chore/ledger-game2-022-v
🤖 Agent: queued at a42361a56b. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 4 rows resolve on origin/main, for example StructureTemplate.fillFromWorld → crates/horizon-client/src/game/structure_blocks.rs::Game::template_from_world.
2026-11-02 12:15:08 +01:00
arismoko 7c7f781095 Fix test guidance sentence 2026-11-02 11:15:05 +00:00
Ludovic Marie 481328d371 Merge pull request #6975 from MSedgeMC/chore/ledger-game2-014
🤖 Agent: queued at e57a64bc78. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 2 rows resolve on origin/main, for example Mirror.mirror → crates/horizon-world/src/worldgen/structure/processor.rs::turn_facing.
2026-11-02 12:15:03 +01:00
Ludovic Marie b6bd4ff5fe Merge pull request #6974 from MSedgeMC/chore/ledger-game2-006-rows
🤖 Agent: queued at c18178c420. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 7 rows resolve on origin/main, for example FireworkRocketEntity.addAdditionalSaveData → crates/horizon-client/src/entity_nbt.rs::firework_rocket_to_nbt.
2026-11-02 12:14:58 +01:00
Ludovic Marie 4fc34c28b2 Merge pull request #6970 from MSedgeMC/chore/ledger-classes-01-rows
🤖 Agent: queued at 24df4186a9. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 18 rows resolve on origin/main, for example StopAdmiringIfItemTooFarAway.create → crates/horizon-client/src/mobs/piglin.rs::Memories::admire_seen.
2026-11-02 12:14:53 +01:00
Ludovic Marie 213b8422f3 Merge pull request #6969 from MSedgeMC/chore/ledger-rest-004
🤖 Agent: queued at cc097bf58b. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 19 rows resolve on origin/main, for example SectionUpdateTracker.hasAllNeighbors → crates/horizon-client-render/src/streaming.rs::Streaming::update.
2026-11-02 12:14:48 +01:00
Ludovic Marie eb767f1a5a Merge pull request #6967 from MSedgeMC/chore/ledger-classes-06
🤖 Agent: queued at 89dbf44981. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 1 rows resolve on origin/main, for example FlatLayerInfo.heightLimited → crates/horizon-client/src/title/flat.rs::FlatSettings::from_tag.
2026-11-02 12:14:42 +01:00
Ludovic Marie 6b3ef48a27 Merge pull request #6964 from MSedgeMC/chore/ledger-client2-003
🤖 Agent: queued at fe2fc60800. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 7 rows resolve on origin/main, for example SectionRenderDispatcher.RenderSection.getVisibility → crates/horizon-client-render/src/gfx.rs::chunk_visibility.
2026-11-02 12:14:38 +01:00
Ludovic Marie 6856f560e0 Merge pull request #6962 from MSedgeMC/chore/ledger-game-003
🤖 Agent: queued at e4d76873f8. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 4 rows resolve on origin/main, for example ServerPlayerGameMode.isInRangeOfGround → crates/horizon-world/src/world_movement.rs::in_range_of_ground.
2026-11-02 12:14:33 +01:00
Ludovic Marie b86e470c4c Merge pull request #6961 from MSedgeMC/chore/ledger-game2-018.tsv
🤖 Agent: queued at a88bd5c392. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 6 rows resolve on origin/main, for example SpawnerBlockEntity.setEntityData → crates/horizon-client/src/mobs/spawner.rs::Spawner::set_entity_data.
2026-11-02 12:14:29 +01:00
Ludovic Marie 8287a47224 Merge pull request #6960 from MSedgeMC/chore/ledger-rest-009
🤖 Agent: queued at d9eb111588. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 2 rows resolve on origin/main, for example Gizmos.rect → crates/horizon-client/src/game/debug_renderers.rs::Game::solid_face_shapes.
2026-11-02 12:14:25 +01:00
Ludovic Marie b3a5e99132 Merge pull request #6958 from MSedgeMC/chore/ledger-client2-015
🤖 Agent: queued at 3d2e0c7a3f. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 18 rows resolve on origin/main, for example WorldOptionsScreen.updatePersonalGameModeButton → crates/horizon-client/src/game/lan_screen.rs::WorldOptions::buttons.
2026-11-02 12:14:20 +01:00
Ludovic Marie 3c4bc84193 Merge pull request #6956 from MSedgeMC/chore/ledger-gui-inventory-1
🤖 Agent: queued at 29792b2c0c. Requeued after a main merge (its 09:50 verify failure was main's stale mod-API output, fixed by #6982). All 16 rows resolve on origin/main, for example AbstractCommandBlockEditScreen.mouseScrolled → crates/horizon-client/src/game/command_blocks.rs::Game::command_block_scroll.
2026-11-02 12:14:15 +01:00
MSedgeMC 36744cd76c port map: clear game-002 rows main already implements
Two ServerPlayer rows said missing for pearls that main saves with their
chunk, and three notes named advancement triggers that exist.
2026-11-02 11:13:23 +00:00
MSedgeMC ba4d706f00 Port map: client2-013's onboarding row says what still differs
The row's gap blamed a missing narrator. It exists, so the note now says
what is actually different: the Narrator button is hardwired disabled and
labelled Not Available where vanilla uses the narrator option's own button,
and handleInitialNarrationDelay's line is not said.
2026-11-02 11:13:00 +00:00
MSedgeMC 622bd29d4f Port map: client2-020's world list narration rows cite the narrator
The two skip:ui rows said the narration had no narrator to feed.
WorldSelectionList.updateWidgetNarration is narrate's list arm and
WorldListEntry.getNarration is title.rs::world_narration, with the
experimental-settings join noted as the one part Horizon cannot read.
2026-11-02 11:12:51 +00:00
MSedgeMC 3a1098e7bd Port map: client2-015's stats narration rows cite the narrator
Both getNarration rows were skipped as "no narrator output". The stats
screen records both texts in Game::draw_stats: narrator.select(<label>
<value>) for the general list and narrator.select(joinForNarration(kills,
killed by)) for the mobs list.
2026-11-02 11:12:38 +00:00
MSedgeMC b921e99809 Port map: client2-009's narration rows cite the narrator
Gui.update, AbstractSelectionList.getRowBottom and narrationPriority were
skipped as "no narrator". Gui.update's narration half is Game::narrate_screen
over ScreenNarrator::frame; getRowBottom is computed in the LAN-found
announcement that is its only caller; narrationPriority is Ctx::priority.
2026-11-02 11:12:29 +00:00
MSedgeMC 9fe8a7d1ce Port map: client2-010's widget narration rows cite the narrator
Thirteen rows said Horizon has no narrator. The narrator exists, so each
row now names the fn or arm that answers it: narrate's checkbox, cycle,
edit-box, text, container-list and list arms, narrate_row, the lock
button's narration in lan_screen.rs, the stats screen's item row, and the
command suggestions' three narration fns in chat.rs.
2026-11-02 11:12:19 +00:00
MSedgeMC 1755ea5c93 Port ledger: the narrator's status gate and GameNarrator are in the tree
Two rest-003 rows still said narration does not happen. NarratorStatus's
system-or-chat gate is Status::should_narrate_system_or_chat, and GameNarrator
is a struct in horizon-narrator with the game-side glue in game/narrator.rs.
The class row now names those files instead of claiming the class is missing.
2026-11-02 11:12:02 +00:00
arismoko bd7e5670fe Keep drowning bubble count informational 2026-11-02 11:11:19 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 e38e711286 Merge origin/main into fix/cart-portal
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 06:11:14 -05:00
MSedgeMC bee28cddb6 Port ledger: RepeatedNarrator.narrate is Realms-only
The row said Horizon had no narrator. The narrator is in the tree, but this
class was never missing because of that: RepeatedNarrator is the Realms
long-running task screen's rate limiter around GameNarrator.saySystemNow, and
Horizon offers no Realms. The row now says that, with skip:service.
2026-11-02 11:11:12 +00:00
MSedgeMC c3ecf70704 Clear stale client-005.tsv rows main already implements
All seventeen missing rows of the client-005 batch said a layer or a renderer is not drawn while
main draws it: the shadow radius a baby villager halves, the cape (its gate, its submit and the
cape state), the crossed-arms, fox-held, dolphin-held, iron golem flower, copper golem antenna,
worn player head and shoulder parrot layers, the deadmau5 ears, the spyglass held to the eye, a
happy ghast's ropes and the arrows and stingers stuck in a body.
2026-11-02 11:10:20 +00:00
MSedgeMC 68eede4deb Port map: four rest-012 packet rows name the functions they mean
Three of the four had a prose where-column (a path with a code excerpt, or a
file and line number) that map-check cannot resolve, and the block entity data
row pointed at two call sites rather than the one that sends the packet for a
tick's block updates. They now name Game::add_transient_block,
Hub::broadcast_block_changes and View::changes, and map-check is clean.
2026-11-02 11:10:04 +00:00
MSedgeMC 63e748b461 Clear stale rest-005 rows main already implements
The client tutorial is ported in game/tutorial.rs, so the nineteen rows that
said Horizon has no tutorial steps now cite the step functions; the Entity
Distance option does reach a LAN host's tracking, and four citations the
earlier pass left without a function name are repaired.
2026-11-02 11:09:38 +00:00
MSedgeMC 160c052dbe Port ledger: joinForNarration is ported
The row still said Horizon had no narrator output, so joining a screen's
title and message for narration went nowhere. The narrator is in the tree and
widgets::join_for_narration is CommonComponents.joinForNarration: the parts
joined with ". ", which is vanilla's NARRATION_SEPARATOR. Every narrated
screen with a title and a message builds its narration string with it.
2026-11-02 11:09:19 +00:00
MSedgeMC e4e5eeab7c port map: client2-012 friend toasts are the request service's, not a missing friends system
Horizon has friends (horizon-friends, the Play with Friends screen): an
invite code for the open world. The seven FriendToast rows said Horizon has
no friends system at all. They keep skip:service, since vanilla's friend
toasts belong to the friend-request service and Horizon shares a world by
code with no requests to toast.
2026-11-02 11:08:38 +00:00
MSedgeMC 60be9f1816 port map: client2-012 rows whose citation moved and gaps that closed
Ten rows of client2-012.tsv were stale. map-check reported three of them:
ime_preedit has left the game.rs module root for game/ime.rs, and two rows
cited two functions joined by "+", which the ledger reads as one name and
reports as no fn; they now cite the function that does the job (act, and
tab_step for the tab step act calls).

The other seven described a gap or a reason the tree no longer has. The
screen narrator landed, so the three "Horizon has no narrator" tab rows are
ports of screen_narrator.rs::narrate's tabs branch and Ctx::priority. The
Stats tabs are disabled while empty (stats_tab_active). The tutorial toasts
are drawn (Game::draw_tutorial_toasts), so the two TutorialToast rows are no
longer missing. onFileDropFailure keeps its verdict but not its reason:
dropped packs are copied now and only logged when they are not a pack.
2026-11-02 11:08:38 +00:00
Ludovic Marie a03d4abece Merge main into #6965
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/boats.rs
2026-11-02 12:08:19 +01:00
MSedgeMC 54a006d4ef port map: client2-014 rows the narrator and the fixer screens have moved on
Twenty-three rows were stale. Fifteen said a screen's narration needed no
port because the narrator was missing: crates/horizon-narrator and
screen_narrator.rs exist now, so they cite the function that does each job
(screen_info.rs::confirm and ::level_loading, Title::narration,
Screen::narration, the five Screen narration functions in
horizon-narrator/src/screen.rs and screen_narrator.rs), and the two that
belong to screens Horizon does not have are missing rather than skipped.

The other eight: the pause menu's report bugs, feedback and player reporting
icons are live and its now-playing song is drawn by draw_music_toast; the
backup's waiting screen does hold input; Horizon installs data packs, so the
datapack failure screen is missing because it does not exist, not because
packs are never loaded; and the upgrade progress screen and its Cancel do
exist, with no aborted screen after them.
2026-11-02 11:08:02 +00:00
MSedgeMC 716ff3eaa1 Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	tools/port_map/rest-020.tsv
2026-11-02 11:07:51 +00:00
AkirabaneandClaude Opus 5.5 df7f4fbd7c Endermen teleport inside the world border
LivingEntity.randomTeleport clamps its target with
WorldBorder.clampVec3ToBound (Mth.clamp to [min, max - 1.0E-5F] on x
and z) before looking for ground; Enderman.teleport skipped it, so an
enderman could land outside the border.

Refs #7080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:07:43 +01:00
AkirabaneandClaude Opus 5.5 9e78d076c4 Mobs stop at the world border
Entity.collectColliders adds WorldBorder.getCollisionShape for any
entity inside and close to the border, but Mob::do_move resolved its
moves against the blocks alone, so mobs walked through the border.
MobWorld now carries the level's border and do_move resolves through
it (SceneCollisions::resolve_in_border), keeping the mob's collision
context (lava walkers).

Refs #7080

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:07:43 +01:00
Ludovic MarieandClaude Opus 5.5 56d9b22e43 Merge main into #7068
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:06:54 +01:00
Ludovic MarieandClaude Opus 5.5 a42361a56b Merge main into #7035
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:06:01 +01:00
Ludovic MarieandClaude Opus 5.5 e57a64bc78 Merge main into #6975
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:05:36 +01:00
arismoko 5b433baaf9 Restore generator and normalize living hook entity IDs 2026-11-02 11:05:20 +00:00
Ludovic MarieandClaude Opus 5.5 c18178c420 Merge main into #6974
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:05:04 +01:00
Ludovic Marie 0c027235cf Merge pull request #7088 from horizon-rust/perf/join-frame
🤖 Agent: the registries.json and data-folder caches assume the prepared resources don't change while the game runs, the same assumption the recipe and loot id cache already makes; a missing registries.json is not cached, so a repaired install can still join.
2026-11-02 12:04:38 +01:00
arismoko 30078d515b Fix attachment sync review findings 2026-11-02 11:04:34 +00:00
Ludovic MarieandClaude Opus 5.5 24df4186a9 Merge main into #6970
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:04:34 +01:00
arismoko 3dd5956f10 Fix signed age in item merge test fixture 2026-11-02 11:04:16 +00:00
Ludovic MarieandClaude Opus 5.5 cc097bf58b Merge main into #6969
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:04:04 +01:00
Ludovic MarieandClaude Opus 5.5 4e53237fc5 Pose dropped items on the GPU path
Each dropped item pushed ~168 vertices per frame from the CPU (2000 items:
336 000 vertices, 14.8 MB, then sorted by material and uploaded). Its item
model now goes to the entity skin store once, keyed by its baked model's Arc,
and a frame sends one job and one part per run of quads sharing a sprite and
tint: entity_skin.wgsl writes the same vertices in the same order.

An item job (SkinJob::item) takes its colour by face axis from the part's
normal words, shaded on the CPU exactly as WorldSink does, and writes the
CULL_BACK normal: the GPU only copies and does transform_point3 and the UV
lerp in glam's order, as for mobs. States with a special model, a glint, a
glowing or translucent quad, and glowing items stay on the CPU path.

The new test draws every vanilla item on the ground both ways and compares
the vertices byte for byte: 3104 states on the GPU path, 212 on the CPU.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:03:55 +01:00
MSedgeMC 02696a0e62 Clear stale rest-010 rows main already implements
The display entities' synced transforms are read and written: the entity data
codec's VECTOR3 and QUATERNION arms carry translation, scale and the two
rotations, so the four FriendlyByteBuf vector rows are inlined there.
2026-11-02 11:03:53 +00:00
Ludovic MarieandClaude Opus 5.5 89dbf44981 Merge main into #6967
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:03:44 +01:00
Ludovic MarieandClaude Opus 5.5 fe2fc60800 Merge main into #6964
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:03:25 +01:00
MSedgeMC ae0705eedd Port ledger: game-007 rows the piglin riding code now answers
DismountOrSkipMounting.create and isVehicleValid were mapped missing when
piglins never rode anything. Piglins now ride baby hoglins: the ride
activity mounts (piglin.rs::mount) and dismounts through
dismount_or_skip_mounting, which makes the isVehicleValid test itself, so
that row is inline.

BackUpIfTooClose.create cited the goal struct where map-check wants a fn;
it now cites too_close, the fn that holds the create(5, 0.75) check.

The other missing rows in the file (SteppedInterpolationTracker.addStep,
updateTracking, getPositionPath, AttributeMap.assignAllValues,
assignBaseValues, assignPermanentModifiers, AnimalPanic.lookForWater) were
checked again and are still absent, so they are left as they are.
2026-11-02 11:03:05 +00:00
Ludovic MarieandClaude Opus 5.5 e4d76873f8 Merge main into #6962
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:03:03 +01:00
AkirabaneandClaude Sonnet 5.5 37206002e1 Minecarts stop at the world border
move_cart passed no border to EntityCollisions, so a cart rolled past the
world border. Entity.move applies CollisionGetter.borderCollision to every
entity, as the boat and the player already do.

Fixes #7067

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 12:02:57 +01:00
MSedgeMC adb8f08038 port map: classes-10 line refs that drifted and a duplicate check that exists
Two SubShape rows pointed at face.rs lines that no longer hold the clamp and
the occupancy copy (calculate_face was restructured), and
LevelChunkTicks.scheduleUnchecked said its port was a push with no duplicate
check while ChunkTicks::schedule refuses a repeat through positions.insert;
the unchecked push vanilla unpacks with is ChunkTicks::unpack.

Verdicts and citations are unchanged; only the notes move.
2026-11-02 11:02:55 +00:00
Ludovic MarieandClaude Opus 5.5 9f01e2bc41 Keep posed entity vertices bit-exact on Metal
wgpu builds Metal libraries with the default compile options: fast math on.
Metal then fused entity_skin.wgsl's transform_point3 into fused multiply-adds
and its sums out of glam's order, so posed vertices came out an ulp off the
CPU's (the_shader_writes_what_the_cpu_reference_computes failed on the Mac
mini; lavapipe and CI were exact). Each product and sum now goes through an
xor with a zero read from the job, which the compiler cannot fold: every
operation rounds on its own, in the CPU's order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:02:51 +01:00
Ludovic MarieandClaude Opus 5.5 a88bd5c392 Merge main into #6961
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:02:40 +01:00
MSedgeMC 45717920ab Clear stale rest-006 message-argument rows
The text filter exists (chat_filter_command) and a source without a player
gets a disguised chat of type say_command, so three rows that read missing or
carried "no text filter" gaps now name where each is done.
2026-11-02 11:02:22 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 a2d7b32c2a Merge origin/main into fix/cart-portal
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 06:02:20 -05:00
Ludovic MarieandClaude Opus 5.5 d9eb111588 Merge main into #6960
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:02:18 +01:00
HrafnaValkyrja f3dd3a3747 Merge remote-tracking branch 'origin/main' into refactor/entity-walk-saves 2026-11-02 06:02:12 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 bc250b47da Save entities through the Entity trait
Slice 10 of #6509 (save half of #6545). entity_chunks now walks every
list in entity_lists! with one Entity::save_nbt per kind (static dispatch,
no per-walk allocation). A kind added to the lists cannot be left out of
saving. The load side stays as is. Entities in a chunk are written in
list order instead of the old loop order.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 06:01:56 -05:00
GIGABAIT93andClaude Opus 5.5 82b12d654f Ukrainian: the 99 Horizon strings uk_ua.json lacked
Splitscreen setup, its controller prompts, the repro recording shortcuts and
messages, the block entity caches, BlueMap and the HDR nits that were added
in English only. Block names follow vanilla's uk_ua (Шалкерова коробка,
Оздоблений горщик, Стяг, Статуя мідного ґолема); every %s and %.2f stays.
uk_ua.json now has every key en_us.json has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:01:39 +02:00
Ludovic Marie 185ebe0a68 Merge pull request #7015 from MSedgeMC/fix/goat-head-yaw2
🤖 Agent: queued at 38b701feb0. Matches Goat.setYHeadRot: the head is clamped within getMaxHeadYRot (15) of yBodyRot after each look update, and rotate_if_necessary(head, body, 15) equals body + clamp(degreesDifference(body, head)) except at exactly 180. I pushed a test fix (horizon_core Vec3; the fixture kind's head limit set to 15 for the goat and 75 for the cow, so the clamp is what the test measures). Verify passes.
2026-11-02 12:01:31 +01:00
MSedgeMC 44ace2db1c Port ledger: MultiLineEditBox.setValue is ported
The row still said no multi-line box setter existed. MultiLineEdit is the
MultiLineEditBox port and it has one: set_value, which truncates at the
character limit and keeps the old text when the value would overflow the
line limit (vanilla's setValue(value, false)). The dialog text input's
multiline option calls it.
2026-11-02 11:01:26 +00:00
Ludovic Marie a68eba6226 Merge pull request #7075 from arslanarm/shaderpacks/resource-textures
🤖 Agent: queued at 0f3dae18dc. Load-time only. The resource-location check (lowercase [a-z0-9_.-], '/' only in the path, no '.' or '..' segments) keeps a pack from reading outside assets/, and dynamic/ and atlas locations stay for the renderer. One mismatch to fix in a follow-up: the comments say 'resource packs over the game's own', but the read closure only joins options.resources, so a resource pack's clouds.png is not picked up yet.
2026-11-02 12:01:26 +01:00
Ludovic MarieandClaude Opus 5.5 3d2e0c7a3f Merge main into #6958
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:01:24 +01:00
[email protected]andClaude Sonnet 5.5 828ddff14f Merge upstream main into native/arrow-loose
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 12:01:23 +01:00
Ludovic Marie 148163a782 Merge pull request #7073 from arismoko/v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-mod-api-key
🤖 Agent: queued at 9d7ea67f81. The mod_api_hooks integration test proves a mod crate can name KeyEvent and InputKeyEvent's winit::event::KeyEvent through mod_api alone. Note that re-exporting all of winit ties the mod API to our winit version, so a winit bump becomes a mod-API change. xtask mod-api --check passes on the main merge.
2026-11-02 12:01:19 +01:00
Ludovic Marie 76d61d0774 Merge pull request #7076 from arismoko/v2/ses_01M6DMWRZSPA9JSD7HP0ZN2DNJ-5468c48f1c40
🤖 Agent: queued at cda58134c6. get stays get_existing-or-default, so every existing caller keeps its behaviour, and get_existing returns a decoded entry without re-encoding it (only a saved Raw entry is decoded and stored). has() is one map probe and never decodes. horizon-world's attachment tests (5) pass on the main merge; xtask mod-api --check passes.
2026-11-02 12:01:14 +01:00
Ludovic MarieandClaude Opus 5.5 29792b2c0c Merge main into #6956
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:00:41 +01:00
arismoko 08a0f3e3b1 Clarify testing unconstructible mod API values 2026-11-02 11:00:22 +00:00
arismoko 6be9ab6017 Use a listen contract for item expiry extensions 2026-11-02 11:00:22 +00:00
MSedgeMC c59fe9230f Port ledger: animal-1 rows the animal code now ports
Rows in tools/port_map/animal-1.tsv went stale after they were mapped:
three say the behaviour is missing while the Rust code has it, and two
cite a fn that was renamed when the bucket loader became the shared
item-to-mob hook.

- Allay.hasNonMatchingPotion: mobs/allay.rs::considers_item_equal compares
  the potion contents of the held stack and the candidate, so the row is
  inline, the id-only gap kept.
- Chicken.applyImplicitComponents and applyImplicitComponent:
  entity_nbt.rs::apply_item_components reads chicken/variant and
  chicken/sound_variant off the stack that spawns or buckets the mob.
- Axolotl.applyImplicitComponent and Axolotl.loadFromBucketTag cited
  load_bucket_data, which the spawn-egg commit renamed apply_item_components.
2026-11-02 10:59:45 +00:00
Ludovic Marie e9d74917e4 Merge pull request #7077 from Akirabane/fix/7065-boat-world-border
🤖 Agent: queued at 2e2d9f25b7. Boats now pass the border to Entity.move's collision, as vanilla's Entity.collide adds the world border shape for every entity when isInsideCloseToBorder holds. The cost is one geometry() copy per boat per tick.
2026-11-02 11:58:38 +01:00
Ludovic Marie 9af555e83f Merge pull request #7037 from HrafnaValkyrja/refactor/entity-walk-fluids
🤖 Agent: queued at b07a0ccaa4. No behaviour change intended, and the per-tick cost is equal or lower: get_gravity() is a statically dispatched trait default (NoGravity ? 0 : default) replacing eight inline checks, and TNT's 140-line TntFluidWorld copy goes through update_entity_fluids with the same 0.014 water and 0.007/0.0023 lava currents. One difference: a missing fluid state no longer aborts TNT's tick with an io error. Verify passed; run-parity (tnt, entity physics) is the after-check.
2026-11-02 11:58:33 +01:00
Ludovic Marie e00b5444b3 Merge pull request #7038 from MSedgeMC/chore/ledger-game-005
🤖 Agent: queued at 0de28c24a3. All 5 changed rows name a file and symbol that exist on origin/main, for example Entity.computeSpeed → .
2026-11-02 11:58:27 +01:00
Ludovic Marie 2a8cfc0fe9 Merge pull request #7039 from MSedgeMC/fix/port-map-client2-019
🤖 Agent: queued at 0d5f7f228d. All 16 changed rows name a file and symbol that exist on origin/main, for example ConfirmExperimentalFeaturesScreen.init → crates/horizon-client/src/title/confirm_experiments.rs::ConfirmExperimentsScreen::new.
2026-11-02 11:58:21 +01:00
Ludovic Marie 80112448eb Merge main into #6965
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/lan_entities.rs
#	crates/horizon-client/src/lan/hub.rs
2026-11-02 11:55:35 +01:00
Akirabane 2e2d9f25b7 Merge origin/main 2026-11-02 11:55:29 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 bc3c5a9e80 Merge origin/main into fix/cart-portal
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 05:54:26 -05:00
Ludovic Marie 898042ced8 Merge pull request #7062 from arismoko/v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-stamp-markers
🤖 Agent: queued at cee65d7ce2. On the main merge every @API:<name>@ marker in crates/ and docs/ now names an existing tools/native-hook-fragments/<name>.json (checked by script, no orphans), and tests.test_stamp_native_hooks passes (6). Sorting a batch's catalog entries by file name is what makes a stamped tree pass mod-api --check, which is the cause of the #6720 and #6982 regenerations. xtask mod-api --check passes.
2026-11-02 11:53:44 +01:00
Ludovic Marie 95afe5bdd7 Merge pull request #7055 from horizon-rust/perf/prepared-data-assets
🤖 Agent: verify green on this exact head. The point to watch is the HUD atlas: a pack's textures still come from its mirror (the mirror skips horizon-data.bin), so only the vanilla root reads from the data file.
2026-11-02 11:53:40 +01:00
Ludovic Marie d8a4a24930 Merge pull request #7044 from arismoko/v2/ses_01M6DMWRZSPA9JSD7HP0ZN2DNJ-de547a67e904
🤖 Agent: queued at 292df3c604. Host methods that forward to the existing native_play_sound/native_send_particles/native_on/mod_key/setting helpers, with no new game logic. One API-surface note: mod_api now re-exports crate::particles::{ParticleOptions, ParticleType}, so renaming a particle variant becomes a breaking mod-API change from here on. Merged with main; xtask mod-api --check passes.
2026-11-02 11:53:35 +01:00
Ludovic Marie 59cedfe088 Merge pull request #7036 from Xmilon/native/critical-hit
🤖 Agent: queued at ea3611dc04. attack_effects now takes melee_damage's crit instead of calling critical_attack a second time, so a mod's forced or denied crit also controls the sweep, the sound and the particles, and the vanilla path does one check instead of two. The hook fires only for a living target with amount > 0 or a magic bonus, as Player.attack's critical branch does. Merged with main; xtask mod-api --check passes.
2026-11-02 11:53:30 +01:00
yosifdheef 63ad854317 Match vanilla sugar cane survival query order 2026-11-02 13:51:47 +03:00
yosifdheef 1c1d24a9da Make vanilla oracle errors fail parity tests 2026-11-02 13:51:35 +03:00
arismoko 45ffb1d479 Regenerate the mod API 2026-11-02 10:51:01 +00:00
Ludovic Marie e1cb997dbd Merge pull request #7064 from TiggerTakkyTTT/fix/server-management-force-game-mode
🤖 Agent: queued at 138cbc1254. Matches DedicatedServer.setForceGameMode: the property is updated, then enforceGameTypeForPlayers runs (enforce_guest_game_mode returns at once when force is off, the same as getForcedGameType returning null). The JSON-RPC set rejects a non-boolean before it touches server.properties or server.toml, and the parity driver covers that 'yes' case.
2026-11-02 11:50:35 +01:00
Akirabane ee159589bc Merge origin/main 2026-11-02 11:50:14 +01:00
arismoko 6a3160acb1 Regenerate the mod API 2026-11-02 10:49:42 +00:00
GIGABAIT93andClaude Opus 5.5 c2ae983c93 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:49:29 +02:00
[email protected]andClaude Sonnet 5.5 ea3611dc04 critical_hit: gate before the smash bonus, vanilla's default multiplier, correct sweep doc
Review fixes from #7036: the damage gate is read before the mace's smash bonus is added, damage_multiplier starts at 1.0 when vanilla's hit is not critical, and the docs say a forced crit prevents the sweep.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:49:14 +01:00
[email protected]andClaude Sonnet 5.5 af96b6e07c arrow_loose: take the bow stack from the caller and catalogue the hook as server-side
Review fixes from #7030: release_bow no longer clones the held stack on every release, and the fragment says side server, where the release runs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:48:26 +01:00
Ludovic Marie 43de4c9101 Merge pull request #7061 from Akirabane/fix/7027-mooshroom-shear-particle
🤖 Agent: queued at 01325a2566. Matches MushroomCow.shear: one EXPLOSION at getY(0.5) with zero spread and speed, sent inside convertTo before dropFromShearingLootTable. Both the hand and the dispenser paths fire it ahead of the mushroom drops, and lan_level_particles carries it to server players (an Explosion particle needs no item encoding).
2026-11-02 11:48:17 +01:00
Ludovic Marie 29e9c63d32 Merge pull request #6923 from masa-masa-0314/fix/minecart-experiment-parity
🤖 Agent: queued at 1776384ac0. Experimental minecarts: max_speed_new now reads the max_minecart_speed rule as in NewMinecartBehavior.getMaxSpeed, and lerp_steps is drained by the tracker (or cleared with no viewers), so it never grows. Minecart_steps stays None outside the experiment, which keeps the old path's tracker work unchanged. I pushed a main merge (fixing the stale mod-api verify) plus one fix: the four new yaw/pitch atan2 sites now use jmath::atan2 * 180.0 / PI in Java's order instead of to_degrees(). fmt, clippy -D warnings and the minecart tests pass; run-parity is the after-check.
2026-11-02 11:48:12 +01:00
Ludovic Marie d2b1fd8864 Merge pull request #7040 from MSedgeMC/chore/ledger-game2-013-v
🤖 Agent: queued at aee99b2ad1. All 4 changed rows name a file and symbol that exist on origin/main, for example CopperGolemStatueBlock.getCloneItemStack → crates/horizon-client/src/game/block_items.rs::Game::picked_stack.
2026-11-02 11:45:38 +01:00
Ludovic Marie 3f10d90dd5 Merge pull request #7041 from MSedgeMC/chore/ledger-game-006
🤖 Agent: queued at 40d90d5b80. All 8 changed rows name a file and symbol that exist on origin/main, for example LivingEntity.getFallDamageSound → crates/horizon-client/src/mobs/landing.rs::MobLanding::living_fall_sound.
2026-11-02 11:45:34 +01:00
Ludovic Marie ba05a7397b Merge pull request #7043 from MSedgeMC/fix/port-map-client2-016
🤖 Agent: queued at 925c284b3e. All 11 changed rows name a file and symbol that exist on origin/main, for example EnchantmentScreen.extractBook → crates/horizon-client/src/item_render/gui.rs::draw_enchanting_book.
2026-11-02 11:45:29 +01:00
Ludovic Marie e61616d0c1 Merge pull request #7047 from MSedgeMC/chore/ledger-game2-019.tsv
🤖 Agent: queued at ab5b1640d2. All 6 changed rows name a file and symbol that exist on origin/main, for example ChunkGeneratorStructureState.createForFlat → crates/horizon-world/src/worldgen/generator.rs::OverworldGenerator::flat.
2026-11-02 11:45:24 +01:00
Ludovic Marie 41e4f436d7 Merge pull request #7048 from MSedgeMC/chore/ledger-game2-012-v
🤖 Agent: queued at 989d52bcc4. All 1 changed rows name a file and symbol that exist on origin/main, for example BellBlock.onExplosionHit → crates/horizon-client/src/game/wind.rs::Game::wind_trigger_block.
2026-11-02 11:45:19 +01:00
Ludovic Marie 37f23dc073 Merge pull request #7049 from MSedgeMC/fix/port-map-client2-013
🤖 Agent: queued at 6581029efe. All 10 changed rows name a file and symbol that exist on origin/main, for example ScreenAxis → .
2026-11-02 11:45:15 +01:00
Ludovic Marie b537cee51c Merge pull request #7051 from MSedgeMC/chore/ledger-client2-018
🤖 Agent: queued at 02c2eb7e58. All 3 changed rows name a file and symbol that exist on origin/main, for example GhostSlots.extractTooltip → crates/horizon-client/src/game/recipe_book.rs::Game::draw_ghost.
2026-11-02 11:45:10 +01:00
Ludovic Marie 9edf5a2fd2 Merge pull request #7052 from MSedgeMC/chore/ledger-game2-021.tsv
🤖 Agent: queued at 2775ed1cdb. All 1 changed rows name a file and symbol that exist on origin/main, for example StructureStart.loadStaticStart → crates/horizon-world/src/worldgen/generator_structures.rs::StructureState::saved_starts_at.
2026-11-02 11:45:05 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 a2306dfee0 Armor stands keep NoGravity: no fall, no plates, no fire
An armor stand summoned or loaded with NoGravity:1b fell in Horizon and
stayed put in vanilla; resting in a pressure plate it powered the plate
where vanilla's does not. ArmorStand had no NoGravity flag, so the reader
dropped it. The flag is now read and written back (only when set), the
stand does not travel with it (ArmorStand.hasPhysics / travel), is not
swept for the blocks it stands in, and is not lit by a fire block. Measured
beside the real 26.3 server; three tests added.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 05:45:03 -05:00
Ludovic Marie 96bf63a489 Merge pull request #7053 from MSedgeMC/fix/port-map-client2-009b
🤖 Agent: queued at 5e04ef39c1. All 2 changed rows name a file and symbol that exist on origin/main, for example GuiGraphicsExtractor.outline → crates/horizon-client/src/game/key_focus.rs::draw_outline.
2026-11-02 11:45:01 +01:00
Ludovic Marie 463f91a719 Merge pull request #7057 from MSedgeMC/chore/ledger-client2-005-w
🤖 Agent: queued at 39b5126ed0. All 9 changed rows name a file and symbol that exist on origin/main, for example OitPipelineSet.getPipeline → crates/horizon-client-render/src/gfx/oit.rs::Oit::terrain.
2026-11-02 11:44:57 +01:00
Ludovic Marie 7e525ad6fc Merge pull request #7058 from MSedgeMC/chore/ledger-game-012-w
🤖 Agent: queued at 08fb1e7a80. All 4 changed rows name a file and symbol that exist on origin/main, for example Fox.FoxMeleeAttackGoal.checkAndPerformAttack → crates/horizon-client/src/mobs/goals.rs::MeleeAttack::tick.
2026-11-02 11:44:53 +01:00
Ludovic MarieandClaude Opus 5.5 c38ef83907 perf: read registries.json and the command folders once, off the join frame
join_server parsed registries.json (about 3 ms) and build_registries read
entity-types.json, five data folders and every timeline file (about 2 ms)
in the frame that clicks Join or opens a single-player world. Both are
now cached per resources root, like the recipe and loot ids already were,
and the title screen's registries preload thread fills them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:44:51 +01:00
Ludovic Marie e479fbcd53 Merge pull request #7063 from MSedgeMC/chore/ledger-client2-004-w
🤖 Agent: queued at 46399c6d43. All 7 changed rows name a file and symbol that exist on origin/main, for example GizmoFeatureRenderer.buildGroup → crates/horizon-client/src/gizmos.rs::camera_relative.
2026-11-02 11:44:48 +01:00
MSedgeMC e275e07726 port map: three rows classes-09 had wrong
The bobber's block-hit bounce is in fishing.rs, the sulfur cube carries
Vec2.rotate, and toggle_tooltips lives in the item-modify command rather
than the loot run.
2026-11-02 10:43:43 +00:00
AkirabaneandClaude Sonnet 5.5 42b7f3cebd Boats stop at the world border
Entity.move reaches CollisionGetter.borderCollision for every entity, so the
world border blocks a boat as it does the player. tick_boat passed no border
to EntityCollisions and a ridden boat sailed through.

Refs #7065

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:42:36 +01:00
Ludovic Marie a3085d1eec Merge main into #6965
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/lan/hub.rs
2026-11-02 11:42:01 +01:00
arismoko 1ec8980e6a Document testing a trait mod through dispatch functions 2026-11-02 10:41:17 +00:00
Ludovic Marie b82089c77d Merge pull request #6398 from martipops/split/fresh-controller-setup
🤖 Agent: queued at 41883f70f0. Off the solo path: the pad_menu Setup routing is gated on Screen::Options plus Page::SplitSetup, and the frame-time readout's four Instant::now() calls run only inside Split's own loop (print_stats returns at once without HORIZON_SPLIT_STATS). P1Pad's released slot makes sure a cancelled or expired Give Up Pad offer returns P1's own controller, not another player's. auth() returning None for split_guest also stops guests from signing chat with P1's account, which is the right fix.
2026-11-02 11:41:04 +01:00
Arslan ArmanulyandClaude Opus 5.5 0f3dae18dc Shader packs: textures that name one of the game's textures
A custom texture may name a texture of the game by its resource location
(`customTexture.clouds = minecraft:textures/environment/clouds.png`, ShaderDoc
"Enhanced Custom Textures": from the shader pack, the resource pack, or a
dynamic texture). Horizon resolved only the lightmap and the block atlas and
left every other location blank ("not provided yet"). The game now reads them
from its active resources (resource packs over its own), with their .mcmeta;
the generated lightmap and atlases stay with the renderer. A location outside
a resource location's characters, or with `..`, is refused.

Part of #6812

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 15:39:23 +05:00
Ludovic MarieandClaude Opus 5.5 1776384ac0 Minecart rotations: HotSpot atan2 in vanilla's operation order
NewMinecartBehavior computes (float) (Math.atan2(z, x) * 180.0 / Math.PI);
Rust's atan2().to_degrees() multiplies by a rounded 180/PI and uses the
platform atan2, which can land on the neighbouring float.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:38:58 +01:00
arismoko 9d7ea67f81 Mod API: a mod can implement the key hook
mod_api re-exported winit::event::KeyEvent, which shadowed the generated
KeyEvent<'_> that Hooks::key takes, so a mod crate could not name key's event.
mod_api now re-exports winit itself, as it does serde_json, and a test builds a
mod-side Hooks with key and input_key.
2026-11-02 10:38:08 +00:00
CocofireHDandHermes Agent e589de1c48 F3 + S: dump the texture atlas to screenshots/debug
Vanilla's `KeyboardHandler` branch for `key.debug.dumpDynamicTextures` takes
`TextureUtil.getDebugTexturePath(gameDirectory)` (`screenshots/debug`), calls
`TextureManager.dumpAllSheets` and shows `debug.dump_dynamic_textures` with the
folder as a clickable link. Horizon bound the key and its action was a no-op.

`hud::dump_atlas` writes the atlas as `name.png` beside `name.txt`, as
`TextureAtlas.dumpContents` does: the PNG of the pixels and one line per
sprite, sorted by name, `%s\tx=%d\ty=%d\tw=%d\th=%d%n` in texels
(`dumpSpriteNames`). The file is `minecraft_textures_atlas_gui`, vanilla's
debug name for `minecraft:textures/atlas/gui.png` (`Identifier.toDebugFileName`
replaces `/` and `:` with `_`); Horizon packs the GUI, font, particle, map and
skin sprites into that one atlas, so it stands in for the atlases vanilla dumps
apart.

Fixes #7042.

Co-Authored-By: Hermes Agent (deepseek-v4.1-flash) <[email protected]>
2026-11-02 11:37:54 +01:00
GIGABAIT93andClaude Opus 5.5 6f480a9d26 Write vanilla's join lines to latest.log for launchers
"Connecting to HOST, PORT" from a "Server Connector #N" thread when a join
starts (ConnectScreen.connect: the address as given, 25565 when none, before
SRV), and "Starting integrated minecraft server version 26.3" on the
"Server thread" when a single-player world opens (IntegratedServer.initServer),
with or without a server thread. Launchers read the last of them to offer
"continue playing". A friend's invite code is never logged. Closes #7032.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:37:27 +02:00
MSedgeMC 46399c6d43 port map: gizmo fills, gizmo texts and outlined text in client2-004
Six rows said missing for gizmo geometry and outlined text that the tree
has: fills and billboard texts of the debug renderers, and
block_entity_render's draw_text. Two gap notes had closed with them.
2026-11-02 10:37:16 +00:00
Loki 9bf423370e Merge remote-tracking branch 'origin/main' into fix/6405-item-lore-rarity-tooltip 2026-11-02 19:36:43 +09:00
LokiandAntigravity d496d04580 item_tooltip: serialize rarity to network and support json lore lines (fixes #6405)
Co-Authored-By: Antigravity <[email protected]>
2026-11-02 19:36:36 +09:00
Ludovic MarieandClaude Opus 5.5 aee91ca707 Merge main into #6923
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:35:55 +01:00
Akirabane 01325a2566 Merge origin/main 2026-11-02 11:35:54 +01:00
Ludovic Marie e6e12d40c5 Merge pull request #7028 from MSedgeMC/chore/ledger-client2-006-w
🤖 Agent: queued at b5ffacd08a. Every one of the 6 rows names a file and symbol that exist on main (checked against origin/main), for example GeyserBaseParticle.Provider.createParticle → crates/horizon-client-render/src/particles.rs::ParticleEngine::make.
2026-11-02 11:35:05 +01:00
MSedgeMC aee99b2ad1 port map: game2-013 gaps that have closed
The picked statue carries its pose in block_state, a waterlogged dried ghast plays
its place-in-water sound, and farmland turning to its base block emits
pushEntitiesUp - so three player-visible gap notes on those rows are stale.
2026-11-02 10:34:40 +00:00
Ludovic Marie 97e00fff95 Merge pull request #7025 from Joansitoh/fix/6989-player-destroy-item-hook
🤖 Agent: queued at 452c3268e5. Only runs at an item break, so the lan_acting_name clone is off the hot path. equipment_slot_name gives Bukkit-style slot names, and the creative skip matches hurtAndBreak's own early return. Merged with main, xtask mod-api --check passes. Note #7005's player_item_break fires at the main-hand break site too; one of the two should go before both land (Dyna raised the same overlap).
2026-11-02 11:33:43 +01:00
Ludovic Marie f328c9660d Merge pull request #7003 from rdbtCVS/native/hide-player
🤖 Agent: queued at eacdc5b519. Cheap when unused: sees() and the state.hidden lookup are one empty-FxHashMap probe per guest, and sync_hidden returns at once while nobody is hidden. broadcast_to_player rechecking pairs on a permission change is the same path a spectator uses. Merged with main, xtask mod-api --check passes.
2026-11-02 11:33:38 +01:00
Ludovic Marie 78ab18d71b Merge pull request #6992 from breachato/fix/6968-empty-item-stack
🤖 Agent: queued at fb024406b9. The empty-stack early return matches GuiGraphicsExtractor.item, which draws nothing for an isEmpty stack, so a mod passing air or count 0 now gets false instead of a missing-model quad. Merged with main, xtask mod-api --check passes (no regen needed).
2026-11-02 11:33:34 +01:00
Ludovic Marie 4a6ae8bdb9 Merge pull request #7034 from horizon-rust/perf/flat-translucent-arena
🤖 Agent: verify passed on b0f9532; RD 32 flat frames: translucent flat calls 125-565 → 1, main thread −9..−20 % on M4 Pro; RD 16 no regression; same picture (ocean flips = run-to-run noise). Overlap with #6804 is the same functions, not the same change.
2026-11-02 11:33:29 +01:00
breachatoandClaude Opus 5.5 82d5ef2672 A pack's blockstate file stacks over vanilla's, state by state
Vanilla binds every pack's definition of a blockstate file on its own,
bottom first, and a higher one replaces only the states it binds. The
overlay took the top pack's file whole, so a partial definition (as
CraftEngine ships for powered fence gates) left every other state with
no model, and the unbound gallery state failed the whole pack.

The overlay now keeps a stacked file's definitions as layers, the
catalog binds each and gives a state its topmost one, and an unbound
gallery state is skipped as the mesher skips it. The overlay cache
version goes to 7 so built overlays are rebuilt.

Fixes #7013.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:33:15 +01:00
MSedgeMC 08fb1e7a80 port map: game-012 rows whose gaps closed
Four rows of game-012.tsv described behaviour that is in the tree now: the fox's bite
sound after a strike (`MeleeAttack::bite`), the tadpole's brain package (panic 2.0,
look at players, the food tempt, the stroll actions at 0.5) and the frog's food tempt
and mate search. `Tadpole.customServerAiStep` and `TadpoleAi.initIdleActivity` had
their verdicts raised from `inline` to `port`, since the brain lives in
`animal_brain.rs::tadpole_package` rather than in the goals `aquatic.rs::register`
adds beside it.

The file's `missing` rows were re-read and are unchanged.
2026-11-02 10:33:13 +00:00
Ludovic MarieandClaude Opus 5.5 dec2f09f28 Merge main into #6965
Keeps native_entity_pose_change firing for server players: lan_update_models
(its only caller, which this PR removes) is replaced by a per-guest pose map on
LanHost, filled only while a mod is loaded. Drops main's posed_rider reset and
the gen_light_bench --loading flag with the in-frame code they belonged to,
and the HubConfig.dedicated field that #6993 removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:32:54 +01:00
TiggerTakkyTTTandCodex 138cbc1254 Add live force game mode management setting
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-02 17:32:43 +07:00
MSedgeMC 39b5126ed0 port map: improved transparency is ported in client2-005
Eight rows said missing for the OIT pipeline set, its builder and the
three render passes; gfx/oit.rs and gfx/oit_features.rs now build and run
them. The breeze wind row's gap note had closed too.
2026-11-02 10:31:42 +00:00
MSedgeMC d47d687fee Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 10:31:24 +00:00
MSedgeMC 62f3e5511e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 10:31:19 +00:00
Ludovic MarieandClaude Opus 5.5 d2c73f55aa Prepared data file: Fusion's .mcmeta scan and model reads through it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:31:18 +01:00
MSedgeMC c1efc3abe4 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 10:31:14 +00:00
MSedgeMC 41d5f70d5f Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 10:31:07 +00:00
MSedgeMC d0b6918f2a Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 10:31:03 +00:00
MSedgeMC 0c5ecdb626 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 10:30:58 +00:00
Ludovic Marie 54e0d6f802 Merge pull request #7009 from MSedgeMC/fix/skeleton-swim-sound
🤖 Agent: queued at 34ea5ed3ee. skeleton_horse_swim_sound follows SkeletonHorse.getSwimSound branch for branch (checked on the 26.3 bytecode), with gallopSoundCounter shared with the hoof steps. swim_sound now runs before the silent check, the same way playSound's arguments are evaluated before isSilent, so a silent ridden horse still advances the counter. swim_volume's 0.3 and min(0.1, v*25) match playSwimSound.
2026-11-02 11:30:58 +01:00
MSedgeMC 0c27a294dc Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 10:30:53 +00:00
MSedgeMC 19e6ac0b9a Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 10:30:47 +00:00
MSedgeMC f8c2e952dd Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 10:30:42 +00:00
MSedgeMC 79f11dbf56 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-02 10:30:38 +00:00
Akirabane 4d229d571e Merge origin/main 2026-11-02 11:30:02 +01:00
AkirabaneandClaude Sonnet 5.5 8391a68ee1 Shearing a mooshroom sends its explosion particle
MushroomCow.shear sends one EXPLOSION particle at the mooshroom's x, half its
height up and z, before the mushrooms drop. Both the player's shearing and the
dispenser's now send it, drawn here and to the LAN guests.

Fixes #7027

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:30:01 +01:00
Ludovic MarieandClaude Opus 5.5 38b701feb0 Goat head test: horizon_core's Vec3 and the goat's 15-degree head limit
The fixture kind turns heads 75 degrees; set getMaxHeadYRot's own values
(15 for a goat, 75 otherwise) so the clamp is what the test measures, and
use the Vec3 that Mob::new takes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:28:28 +01:00
Ludovic Marie 6169ed07c0 Merge pull request #7008 from MSedgeMC/fix/autosave-interval
🤖 Agent: queued at 8bd75ba4dd. The countdown matches MinecraftServer: ticksUntilAutosave-- then autoSave at <=0, recomputed by computeNextAutosaveInterval with (float)1e9/(avg+1) while sprinting and max(100,(int)(rate*300F)), and /tick rate only shortens a running wait. One behaviour change: set_saving(false) now follows the save in the same tick, so a reader that polls the flag a tick later never sees it true. That matches isCurrentlySaving's scope; please check the client's saving indicator still shows.
2026-11-02 11:28:23 +01:00
Ludovic Marie b352b36614 Merge pull request #7006 from MSedgeMC/fix/golem-do-push
🤖 Agent: queued at 630e4dc8bc. Matches IronGolem.doPush: the instanceof checks come before nextInt(20), so a creeper, a player or an animal leaves the golem's random untouched, and it runs ahead of the same-vehicle skip as vanilla's push does. golem_prey's 'monster' category stands in for Enemy, which covers slimes, ghasts, phantoms and hoglins too. The test returns early without prepared data, so CI never asserts it.
2026-11-02 11:28:19 +01:00
Ludovic Marie 5f6dd107d5 Merge pull request #6891 from horizon-rust/fix/server-bench-spawn-line
🤖 Agent: tooling only: server_bench skips horizon-bots' spawn line (every run failed with 'Extra data' since #6619); rerun on playhorizon after the fix: 2 bots joined, 20 TPS.
2026-11-02 11:28:15 +01:00
Ludovic Marie 2d91826b80 Merge pull request #7017 from MSedgeMC/chore/port-map-rest-018
🤖 Agent: queued at 018b389232. Every one of the 3 rows names a file and symbol that exist on main (checked against origin/main), for example ServerGamePacketListenerImpl.handleSetTestBlock → crates/horizon-client/src/game/test_blocks.rs::Game::lan_set_test_block.
2026-11-02 11:28:10 +01:00
MSedgeMC 5e04ef39c1 Port ledger: the GUI outline and the enchanting book are ported
Two client2-009 rows still described behaviour the tree has: the one-pixel
outline is key_focus::draw_outline (the keyboard focus ring) and the open
book over the enchanting table is item_render::gui::draw_enchanting_book.
2026-11-02 10:27:50 +00:00
MSedgeMC 2775ed1cdb chore(port_map): clear stale game2-021.tsv row main already implements
StructureStart.loadStaticStart is ported: a chunk's saved starts are read
back and a jigsaw start is rebuilt from its saved pieces.
2026-11-02 10:27:33 +00:00
MSedgeMC 02c2eb7e58 port_map: clear stale client2-018 rows main already implements
The ghost-slot tooltip, the recipe book's search drag and its IME composition all
have code behind them; each row now cites the fn.
2026-11-02 10:27:27 +00:00
MSedgeMC 6581029efe Port ledger: focus navigation's screen rectangles are ported
client2-013's ScreenRectangle rows described keyboard focus navigation as
missing; it landed with #4638. The overlap, bound and centre tests are in
gamepad::focus_step_strict/focus_step_vague and the border strip is
key_focus::edge, while the three direction enums are gamepad::Button's DPad
values. The onboarding note no longer says the screen is missing either.
2026-11-02 10:27:07 +00:00
MSedgeMC 989d52bcc4 port map: game2-012's bell row describes a gap that closed
A wind charge burst on a bell rings it now: `wind_trigger_block` gives the bell its
own arm, `bell_attempt_to_ring` (`BellBlock.attemptToRing` with no direction), so the
row's "an Up-face use that bell_hit rejects, so it stays silent" no longer holds. The
verdict and the citation were already right; only the note is stale.

The file's other two `gap:` notes were re-read and still hold, so nothing else
changes.
2026-11-02 10:27:02 +00:00
MSedgeMC ab5b1640d2 chore(port_map): clear stale game2-019.tsv rows main already implements
Five rows said missing while the tree ports them (flat structure state,
post-processing offsets, block entity save tag, the old-chunk scan and
its per-record test), plus a citation whose function was renamed.
2026-11-02 10:26:54 +00:00
arismoko e0835ea689 Fix drowning particle delivery and remove unused heal hook 2026-11-02 10:26:00 +00:00
Ludovic MarieandClaude Opus 5.5 c353b4813e server_bench: skip horizon-bots' spawn line
horizon-bots prints the first bot's spawn position as a JSON line of its
own (for perf_bench --server-bots, #6619). server_bench joined it to the
summary report and failed every run with "Extra data". It now skips that
line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:25:00 +01:00
MSedgeMC 925c284b3e Port ledger: eleven container screen rows are ported, not missing
client2-016's missing rows describe container screens the tree has: the
enchanting book (draw_enchanting_book, tick_enchanting_book), the merchant
and stonecutter scroller drags, the smithing template icons and the armour
stand preview, the command block minecart's editor, the menu tooltip's
placement and the server list's online player names. Each row now names the
function that answers it; InventoryScreen.containerTick stays missing, since
only the creative screen's switch back is ported.
2026-11-02 10:24:23 +00:00
Ludovic Marie d928d1cd61 Merge pull request #6997 from MSedgeMC/chore/ledger-client2-010
🤖 Agent: queued at 34523c59de. Every one of the 11 rows names a file and symbol that exist on main (checked against origin/main), for example ChatComponent.DrawingFocusedGraphicsAccess.isMouseOver → crates/horizon-client-render/src/chat.rs::Chat::focused_tags.
2026-11-02 11:24:17 +01:00
Ludovic Marie 44c096d165 Merge pull request #6998 from MSedgeMC/chore/ledger-rest-020
🤖 Agent: queued at 499788ffcf. Every one of the 1 rows names a file and symbol that exist on main (checked against origin/main), for example PlayerList.broadcastSystemToAllExceptTeam → crates/horizon-client-core/src/scoreboard.rs::death_message_reaches.
2026-11-02 11:24:12 +01:00
Ludovic Marie 80632054e0 Merge pull request #7001 from MSedgeMC/chore/ledger-rest-022
🤖 Agent: queued at a8a0b95dbd. Every one of the 12 rows names a file and symbol that exist on main (checked against origin/main), for example HashOps.createNumeric → crates/horizon-protocol/src/hash_ops.rs::HashOps::from_nbt.
2026-11-02 11:24:07 +01:00
Ludovic Marie c6264df40a Merge pull request #7002 from MSedgeMC/chore/ledger-rest-019-rows
🤖 Agent: queued at b46c36f11a. Every one of the 7 rows names a file and symbol that exist on main (checked against origin/main), for example PackFormat.lastPreMinorVersion → crates/horizon-client-render/src/packs.rs::last_pre_minor.
2026-11-02 11:24:03 +01:00
Ludovic Marie bca0e884a0 Merge pull request #7004 from MSedgeMC/chore/ledger-client2-017
🤖 Agent: queued at 802d8ff743. Every one of the 26 rows names a file and symbol that exist on main (checked against origin/main), for example ClientBundleTooltip.extractBundleWithItemsTooltip → crates/horizon-items/src/bundle.rs::tooltip_layout.
2026-11-02 11:23:58 +01:00
Ludovic Marie 17582ee65f Merge pull request #7011 from MSedgeMC/chore/ledger-rest-021-rows
🤖 Agent: queued at 8af6df80ba. Every one of the 1 rows names a file and symbol that exist on main (checked against origin/main), for example Crypt.byteToPrivateKey → crates/horizon-protocol/src/rsa.rs::RsaPrivateKey::from_der.
2026-11-02 11:23:53 +01:00
Ludovic MarieandClaude Opus 5.5 90f2ef813b Prepared data file: trades, paintings, enchantments, particles, ambient sounds and the biome table read through it
Villager trades, trade sets and their tags, painting variants, enchantments
and the tags the enchantment table reads, particle sprite sets, the
dimension types' and biomes' ambient sounds and the client's biome table
(and its colormaps) listed and read their folders file by file: about 1,200
opens on a world start. They read through json_folder or prepared_files
(from horizon-data.bin when current) now. The biome table and enchantments
keep their order and their flat folders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:23:48 +01:00
Ludovic Marie cf9635e857 Merge pull request #7012 from MSedgeMC/chore/ledger-rest-023-rows
🤖 Agent: queued at d979df6aa5. Every one of the 2 rows names a file and symbol that exist on main (checked against origin/main), for example Util.copyBetweenDirs → crates/horizon-client-render/src/datapack.rs::install.
2026-11-02 11:23:47 +01:00
Ludovic Marie eb327b65e4 Merge pull request #7014 from MSedgeMC/chore/ledger-rest-024-rows
🤖 Agent: queued at 3684b62938. Every one of the 2 rows names a file and symbol that exist on main (checked against origin/main), for example WeightedList.contains → crates/horizon-client/src/mobs/spawn_selection.rs::valid_position_for_type.
2026-11-02 11:23:42 +01:00
Ludovic Marie 63a0ef1803 Merge pull request #7029 from MSedgeMC/chore/ledger-game-012-v
🤖 Agent: queued at 177805fb9d. Every one of the 1 rows names a file and symbol that exist on main (checked against origin/main), for example Frog.spawnChildFromBreeding → crates/horizon-client/src/mobs/goals.rs::Breed::tick.
2026-11-02 11:23:36 +01:00
MSedgeMC 630e4dc8bc Skip the golem push test when the prepared world is missing
CI has no extracted 26.3 data, so the test asserted data that is not there; it now
returns instead, as the other world-backed mob tests do.
2026-11-02 10:23:03 +00:00
MSedgeMC 60d6008a7b Import Vec3 in the goat head test module
The test builds a mob at the origin and the module did not bring Vec3 in, so the
lib-test build failed to compile.
2026-11-02 10:22:11 +00:00
MSedgeMC 40d90d5b80 Clear stale game-006.tsv rows main already implements 2026-11-02 10:21:41 +00:00
MSedgeMC 4e15803e0f port map: game2-013's flower pot row named a function that is gone
The pick-the-item path was renamed to `game/block_items.rs::Game::picked_stack`, so
the row's `Game::clone_item_stack` citation dangled and `map-check` reported the row
as naming no function. The verdict and the note were already right: `picked_stack`'s
FlowerPot arm picks a potted plant as that plant and an empty pot as the pot itself.

No other row of the file is touched.
2026-11-02 10:21:36 +00:00
MSedgeMC 0d5f7f228d Port ledger: sixteen title and world-open rows are ported, not missing
client2-019's missing rows describe screens and flows the title tree already
has: confirm_experiments.rs and experiments.rs port the two experiment
screens, Title::begin_optimize/poll_optimize/draw_optimize port the optimize
screen, and play/after_prompt/start_backup port WorldOpenFlows' questions
before a world loads. Each row now names the function that answers it.
2026-11-02 10:21:10 +00:00
MSedgeMC 0de28c24a3 Clear stale game-005.tsv rows main already implements 2026-11-02 10:21:06 +00:00
[email protected]andClaude Sonnet 5.5 3d1a782d69 Native mods: CriticalHitEvent hook (critical_hit)
A player's damaging melee hit on a living target asks mods whether it is critical and by what multiplier. The crit is decided once in melee_damage and handed to the sweep, sound and particles.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:19:45 +01:00
Ludovic Marie 0b58feba0e Merge pull request #7022 from DUzzL/fix/6951-spectator-armor
🤖 Agent: queued at 3f79ea0d00. layers_shown is two string compares per avatar per frame, which is negligible. Gating render_visible's armour 'extra' and the creatures/lan/perspective layer calls matches AvatarRenderer.shouldRenderLayers for spectators, and glowing and outlines stay outside the gate as vanilla has them.
2026-11-02 11:19:32 +01:00
Ludovic Marie eb7a4291cf Merge pull request #7021 from Akirabane/fix/6978-wolf-armor-particles
🤖 Agent: queued at 14189dc867. The 20 scutes at y+1 with spread 0.2/0.1/0.2 and speed 0.1 match Wolf.actuallyHurt's sendParticles. One gap: hub_particle still returns None for T::Item, so server-thread and LAN players won't see them until item particles are encoded (same limit as the panda crumbs). That's a follow-up, not a blocker.
2026-11-02 11:19:28 +01:00
arismoko cee65d7ce2 Native hook stamping keeps the generated mod API current
The catalog takes a batch's new hooks and helpers in file name order, the order
xtask mod-api reads pending fragments in; API numbers still follow merge order.
Stamping used to append them in merge order, which reordered generated.rs.
2026-11-02 10:19:23 +00:00
Ludovic Marie e8afcc0104 Merge pull request #7020 from HrafnaValkyrja/fix/blast-mob-size
🤖 Agent: queued at 3a8fe38077. explosion_eye_height now reads Mob::eye_height() and the box override is gone, so a baby or a size-4 slime is aimed at and pushed from its own scaled getEyePosition/getBoundingBox, which wind (#6922) also lost. The test compares against an independent height and checks that the case actually scales.
2026-11-02 11:19:23 +01:00
Ludovic Marie 0768f31463 Merge pull request #7018 from DUzzL/fix/6963-elytra-firework
🤖 Agent: queued at 29f7d45ac8. FireworkRocketItem.useOn returns PASS while isFallFlying (checked on the 26.3 bytecode), so a gliding guest's UseItemOn no longer launches a second rocket beside use()'s boost. The helper's test only pins a bool identity; a test through launch_firework with a gliding player would carry more signal.
2026-11-02 11:19:18 +01:00
MSedgeMC ff96d75abe port map: game2-022 template save rows
Four rows of game2-022.tsv said `missing` because templates were read-only in
Horizon. The structure block's save now writes them: `game/structure_blocks.rs` has
`Game::template_from_world` (`StructureTemplate.fillFromWorld`), `template_entities`
(`fillEntityList`) and `write_template` (`StructureTemplateManager.save`), and the
palette ids of `save_nbt` map states the way `StructureTemplate.SimplePalette.idFor`
does.

The fifth `missing` row, `OceanRuinPieces.OceanRuinPiece.create`, was re-checked and
stands: ocean ruin piece NBT is written (`simple.rs::TemplatePiece::saved`) but
nothing reads it back.
2026-11-02 10:19:04 +00:00
Ludovic MarieandClaude Opus 5.5 e1b514fe2e Format the read_head test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:18:57 +01:00
arismoko c16e5e3c33 docs: correct the native-mod values a test outside horizon-client can't build 2026-11-02 10:18:25 +00:00
Ludovic Marie 39eb8fc3c4 Merge pull request #6976 from horizon-rust/perf/prepared-data-tags
🤖 Agent: verify green on this exact head (rebased on main after #6893 merged). One risk worth knowing: tags, synchronized registries and advancements now come from horizon-data.bin when it is current; a stale or foreign file is refused at open, so they fall back to the folders.
2026-11-02 11:16:21 +01:00
MSedgeMC 177805fb9d port map: the frog's pregnancy row
`Frog.spawnChildFromBreeding` still said `missing` in game-012.tsv, but
`mobs/goals.rs::Breed::tick` makes a breeding frog the pregnant mother
(`frog::set_pregnant`, the `IS_PREGNANT` memory) instead of spawning a baby, and the
`LaySpawn` goal walks her to water and lays the frogspawn. The row's note - that no
frog ever becomes pregnant - no longer holds, and it contradicted the row above it
(`Frog.getBreedOffspring`, already `port` to the same function).

The file's seven other `missing` rows were re-checked against `main` and are still
absent, so they are unchanged.
2026-11-02 10:15:51 +00:00
arismoko c488e079f6 Native hook stamping: every marker names its fragment
Markers read @API:<fragment stem>@, but #6801's hooks used @API:native_<hook>@
for fragments named <hook>.json, and ten fragments named native_<hook>.json used
@API:<hook>@. server_event listed native_server.rs as a comment file without a
marker there, and state_block listed native_hooks.rs, which is always stamped.
The stamper now also stamps markers in the docs' hook lists, and a test stamps
a copy of the checkout and fails on any marker left behind.
2026-11-02 10:15:32 +00:00
MSedgeMC b5ffacd08a port map: the geyser particles are ported in client2-006
Six rows said missing for the sulfur geyser's particles, which
particles.rs now makes and ticks: the base puffs, the eruption, the
plumes and their provider.
2026-11-02 10:15:07 +00:00
Ludovic MarieandClaude Opus 5.5 ccc9b8caf5 Prepared data file: the HUD atlas's textures, headers and .mcmeta read through it
HudAtlas::pack decoded every walked texture, its .mcmeta and its PNG header
straight from the folders: about 10,000 opens on a world start. They go
through prepared_files now, with read_head for the 24-byte PNG header.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:13:16 +01:00
arismoko 5272841504 Fix living hook generation and drowning particles 2026-11-02 10:13:05 +00:00
[email protected]andCodex ced1b06fc6 Add native arrow loose hook
Co-Authored-By: Codex <[email protected]>
2026-11-02 11:12:59 +01:00
Ludovic Marie 7ecfbf6db5 Merge pull request #6959 from horizon-rust/ci/runner-languages
🤖 Agent: queued at 3a42c67b1a: verify green, pr_ready block in the body. One point worth a look: the languages are only copied into a preparation without assets/pack.mcmeta, so the fresh key f08f9a3e (en_us alone) gets them on its next job, and the 14 preparations fixed by hand are left alone.
2026-11-02 11:12:25 +01:00
Ludovic Marie 9e6af18cf1 Merge pull request #6971 from Akirabane/fix/6950-mob-landing-dust
🤖 Agent: queued at 3eef82f7eb. Requeued at your main merge (3eef82f7e): the only new commit merges main, and the landing-dust code I checked against `checkFallDamage` is unchanged.
2026-11-02 11:12:21 +01:00
Ludovic Marie d27aa2986a Merge pull request #6972 from arismoko/v2/ses_01M6DMWRZSPA9JSD7HP0ZN2DNJ-1d979769f9dd
🤖 Agent: queued at 805ce265cd. Requeued after merging main. The verify failure was `generated_output_is_current`, caused by main's stale mod-API output from before #6982, not by this branch. The 9 `native_mods::inject` tests passed on the merge earlier.
2026-11-02 11:12:16 +01:00
Ludovic MarieandClaude Opus 5.5 b0f9532aa8 Flat draws: translucent quads from the translucent arena, a call per page
Frames drawn flat (Metal from render distance 32, Intel Gen11/Gen12 on Vulkan) drew translucent
sections back to front from the section arena, where consecutive sections sit in different pages:
nearly a call each (180-670 a frame at RD 32 on an M4 Pro), each with its own vertex buffer and
bind group to encode. Translucent Batching's arena keeps those quads in one or two pages, so they
now draw in a few calls; it was left out of devices that draw flat, as flat draws read translucent
quads in the section arena.

- Translucent Batching is offered wherever indirect multi-draws are (still not OpenGL).
- Flat's block tables are keyed by page key: a section arena page, or a translucent arena page
  plus TRANSLUCENT_KEYS, with tables the size of the translucent arena's large pages. A section's
  id names both of its ranges; moves (compaction, and now the translucent arena's shrink) keep it.
- Flat draws bind a translucent page up to its last range in use, as the per-section draws do.
- With flat draws the sorted indices keep pages flat draws can bind as storage, split or not.
- Frames whose shader pack draws terrain or a shadow pass keep the section arena (translucent_split).

The back-to-front order is the same; only the vertex buffer the indices point into changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:07:53 +01:00
Akirabane 14189dc867 Merge origin/main 2026-11-02 11:07:41 +01:00
arismoko 20c7e78007 Restore living heal and drown hook placeholders 2026-11-02 10:07:41 +00:00
DUzzL 3f79ea0d00 Draw no layers on a spectating player (armour, held items, head item, cape, wings) 2026-11-02 11:07:31 +01:00
HrafnaValkyrja b07a0ccaa4 Merge remote-tracking branch 'origin/main' into refactor/entity-walk-fluids 2026-11-02 05:07:30 -05:00
HrafnaValkyrjaandClaude Opus 5.5 385aa3b5d7 Keep the Entity trait's one header after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 05:07:29 -05:00
Ludovic MarieandClaude Opus 5.5 805ce265cd Merge main into #6972
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:07:17 +01:00
Ludovic Marie 2b6d855da0 Merge pull request #6934 from arslanarm/shaderpacks/iris-standard-macros
🤖 Agent: queued at 946745362c. The 40-pack measurement is what justifies changing the defaults packs branch on: GL/GLSL 330 and the `OTHER` vendor/renderer change no preprocessed file, and `IRIS_VERSION` and `IRIS_HAS_TRANSLUCENCY_SORTING` change exactly the Complementary-family files that test for them. Defining `OTHER` rather than the real GPU vendor is the right call, since a pack's `MC_GL_VENDOR_NVIDIA` branch asks for GL extensions wgpu can't give (RenderPearl's `GL_NV_gpu_shader5`). The Iris behaviour was observed with probe packs, so it stays clean-room. I merged main: its new normal/specular-map test now uses your `translates` helper. Clippy is clean and the 138 horizon-shaders tests pass.
2026-11-02 11:06:40 +01:00
JoansitohandClaude Sonnet 5.5 452c3268e5 fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 11:06:16 +01:00
DUzzL 29f7d45ac8 Fix a gliding guest's rocket launching on the clicked block as well as boosting 2026-11-02 11:05:10 +01:00
arismoko be14671058 Route additional healing paths through living heal hook 2026-11-02 10:05:02 +00:00
MSedgeMC 018b389232 Port map: the three jigsaw and test block handlers exist
SET_TEST_BLOCK, SET_JIGSAW_BLOCK and JIGSAW_GENERATE all have handlers on the
LAN host (test_blocks.rs::lan_set_test_block, jigsaw_blocks.rs::
lan_set_jigsaw_block and lan_jigsaw_generate), each refusing anything but a
creative player with game master commands. The rows that stay missing are the
ones with no handler at all: the recipe book's seen-recipe (the new-recipe
highlight is never cleared), the seen-advancement tab, and the support blocks
under a player standing on air.
2026-11-02 10:04:59 +00:00
Ludovic Marie 383e7354c6 Merge pull request #6977 from AltMugen/fix/dragon-living-contacts
🤖 Agent: queued at 715dd9811b. Matches `EnderDragon.knockBack`: the push is `d / max(d², 0.1) * 4` plus `0.2F` up from the body's centre, wing damage needs `getLastHurtByMobTimestamp() < tickCount - 2` and a dragon that isn't sitting, and the head's 10 hits sitting or not. The new `dragon_contacts` oracle drives the real private methods, which is the right proof for seven scenarios. Cost: the per-mob `allowed` vector is built only while a dragon exists. Nit: `dragon_target`'s eye is `p.y + 6.8`, where vanilla's is `8.0F * 0.85F` (6.8000001907), as #6941 fixed for the explosion push. I merged main (clean); clippy with examples is clean, and the dragon and contact tests pass. `run-parity` is on.
2026-11-02 11:04:35 +01:00
Ludovic MarieandClaude Opus 5.5 946745362c Merge main into #6934
macros.rs: main's normal/specular-map test now goes through this PR's
`translates` helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:03:52 +01:00
MSedgeMC 38b4f4735e A goat's head stays within fifteen degrees of its body
`Goat.setYHeadRot` clamps every head rotation to `getMaxHeadYRot` (15
degrees) around `yBodyRot`, so a goat that stands still cannot swivel its
head further than one that walks. Horizon clamped the head only while the
navigation was running, so a goat looking around in place turned its head
as far as any other mob.

Corrects the Goat.setYHeadRot row in game-012.tsv.
2026-11-02 10:01:58 +00:00
HrafnaValkyrja 3a8fe38077 Merge remote-tracking branch 'origin/main' into fix/blast-mob-size 2026-11-02 05:01:09 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 28eb8bc61f Blast and wind aim at a baby's or slime's real box and eye height
Entity for Mob gave the shared explosion code the adult kind's box and
eye height. Vanilla's ServerExplosion.hurtEntities reads the mob's
current bounding box and eye position, which for a baby or a slime are
the scaled ones. Beside the real server, 96 values per scene differed
for scaled mobs in a TNT blast and in a wind burst; none do now.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 05:00:53 -05:00
HrafnaValkyrja 42d3c8794e Merge remote-tracking branch 'origin/main' into refactor/entity-walk-fluids
# Conflicts:
#	crates/horizon-client/src/game/entity.rs
2026-11-02 05:00:46 -05:00
MSedgeMC 3684b62938 rest-024: the spawn membership test and the long jump transition
WeightedList.contains is the mobs-list membership test
spawn_selection.rs::valid_position_for_type makes for canSpawnMobAt, and
LongJumpUtil.isClearTransition is breeze.rs::clear_transition, which the
breeze's long jump calls.
2026-11-02 10:00:25 +00:00
MSedgeMC d979df6aa5 rest-023: two rows the tree has
Util.copyBetweenDirs is folded into datapack.rs::install, which copies
Create World's picked packs into <world>/datapacks, and
AbstractSampleLogger.logPartialSample is the four slot tick sample that
debug_chart.rs::log_tick fills at once and log_full pushes.
2026-11-02 10:00:14 +00:00
jaszkusandOpenCode ea0c475c02 Launcher: write Windows shortcuts through the shell
`shortcut::lnk` lays a Shell Link out from the format's specification, which is
what lets the tests check it on every system, but it writes no target ID list,
and the shell reads the target from that list. The shortcuts #6416 installs
showed an empty target in their properties and a double click started nothing;
the instance shortcuts from Home > Shortcut are laid out the same way.

`shortcut::write_windows_link` creates the link with `IShellLinkW` and saves it
with `IPersistFile`, as every installer does. `make_in` uses it on Windows and
keeps the byte layout for the other systems and for the tests.

Two tests hold it: one reads the target back with the shell's own `GetPath`
(the bug was that it read empty), and one checks that an instance's arguments
survive the shell's write, so `plays` still finds the shortcut at uninstall.

Co-Authored-By: OpenCode <[email protected]>
2026-11-02 11:00:07 +01:00
MSedgeMC 8af6df80ba rest-021: the chat key reader is ported
Crypt.byteToPrivateKey read missing; the account chat key pair's private
key is read as PKCS8EncodedKeySpec by
crates/horizon-protocol/src/rsa.rs::RsaPrivateKey::from_der, which
auth.rs::parse_certificates calls on the certificates endpoint's PEM key.
2026-11-02 09:59:53 +00:00
MSedgeMC 802d8ff743 Port map: clear client2-017 rows the tree already implements
Twelve rows still said a behaviour was missing while the tree has it:
the tooltip positioner, the code of conduct and reconfiguration screens,
the Online and Skin Customisation pages, the video page's restart line,
anisotropy greying and mode list, and pack narration.

Fourteen rows recorded a gap that has since closed: the bundle's
selected-item name box, the server list's incompatible state and player
list tooltip, the LAN narration, the sound and video rows, the 26.3
control rows, and the data pack folder watch.
2026-11-02 09:59:15 +00:00
Akirabane 147f32f1a5 Merge origin/main 2026-11-02 10:59:07 +01:00
Ludovic Marie e6406b7a04 Merge pull request #6966 from GIGABAIT93/docs/launch-guide
🤖 Agent: queued at 82c47a7f3b. Checked the guide against Mojang's rules. Step 0 makes every step owner-only (a signed-in Microsoft account that owns Java Edition, the same gate as `horizon-launcher`), the server jar is fetched from the version JSON only for that owner, and the session goes to standard input, never the command line. There's no offline path anywhere in it. Docs only, so `skip-release` is right.
2026-11-02 10:59:04 +01:00
Ludovic Marie 2771550fca Merge pull request #6993 from glitchsnake/fix/6586-shulker-bullet-particles
🤖 Agent: queued at 0be681d696. The cause is right: during `as_guest` the hub is out of `self.lan`, so `lan_level_particles` sent nothing, and threading `host` through `hit_shulker_bullet` restores `ServerLevel.sendParticles`' 15 crits within 32 blocks. Pulling the send loop out into `send_hub_level_particles` keeps one copy of the brand and distance rules. A follow-up worth an issue: every other particle sent from inside a guest's turn is still dropped the same way. `players.rs` already puts the hub back with `enter_guest` + `self.lan = Some(host)`, so that route could cover them all instead of a `host` parameter per call.
2026-11-02 10:59:00 +01:00
MSedgeMC 34ea5ed3ee A skeleton horse splashes and swims with its own water sounds
SkeletonHorse.getSwimSound is not the generic splash: in the water it swims,
on the ground it splashes and, ridden, it counts the water steps on the same
gallopSoundCounter its hoof steps use, galloping through the water every third
step after the first five. SkeletonHorse.playSwimSound maps the volume
Entity.waterSwimSound worked out: 0.3 on the ground, min(0.1, f * 25.0) in the
water.

Horizon had no skeleton horse entry in swim_sound and the horse is not a
monster, so every one of them swam with entity.generic.swim at the full
speed-based volume.
2026-11-02 09:58:58 +00:00
JoansitohandClaude Sonnet 5.5 d48f665396 Native mods: PlayerDestroyItemEvent hook (native_player_destroy_item)
Fixes #6989

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 10:58:35 +01:00
MSedgeMC 8bd75ba4dd Autosave counts down from max(100, tick rate * 300) ticks
MinecraftServer.computeNextAutosaveInterval asks for Math.max(100,
(int)(rate * 300.0F)) ticks, where the rate is the tick rate or, while /tick
sprint runs, the measured NANOSECONDS_PER_SECOND / (averageTickTime + 1);
tickServer counts ticksUntilAutosave down and saves when it reaches zero, and
onTickRateChanged shortens a wait already running. Horizon saved on every
six-thousandth tick whatever the rate, so /tick rate and /tick sprint changed
how long a world ran between saves and a slow rate kept the same interval.

The countdown is a field on Game, the interval is next_autosave_interval, and
/tick rate now calls on_tick_rate_changed. Twenty ticks a second is still the
six thousand ticks a world autosaved on, so nothing changes at the default.

Port map: the two rest-013 rows now name those functions.
2026-11-02 09:58:19 +00:00
Ludovic MarieandClaude Opus 5.5 98d5444594 Prepared data file: textures, models, blockstates and item definitions read through it
prepared_files::read reads a file from its prepared root's data file when
that holds the file's folder, where a file the data file doesn't have is
missing without asking the disk (most textures have no .mcmeta). The
texture decoder and its .mcmeta, the block and item models, the blockstate
definitions and the client item definitions read through it; resource-pack
mirrors have no data file and read their folders as before.

Part of #1961.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:58:02 +01:00
Ludovic MarieandClaude Opus 5.5 6d0d1915fe Prepared data file: tags, synchronized registries and advancements read through it
The item and block tags (horizon-items), the LAN sync's registries and tags
(lan/data.rs walk) and the advancement tree read their folders through
json_folder, so from horizon-data.bin when the root has one, and once for
the tables a load builds from the same folder.

Part of #1961.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:58:01 +01:00
Ludovic MarieandClaude Opus 5.5 715dd9811b Merge main into #6977
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:57:53 +01:00
Ludovic Marie 3476a852df Merge pull request #6994 from Joansitoh/fix/5162-mob-leave-level
🤖 Agent: queued at 6cc25834e4. One snapshot per removal site, diffed after it, is the right shape, and `vanished_by_kind` skipping mobs spawned meanwhile is tested. The cost was the problem: `mob_ids()` ran every tick before `tick_mobs`, with a `String` per mob plus a `HashSet` afterwards, even with no mod loaded. I pushed a commit so `dropped_ids`, `orb_ids` and `mob_ids` take nothing when `mod_api::mods()` is empty, and the three `*_left_since` return at once on an empty snapshot. That also fixes the item and orb versions, which had the same per-tick cost. I merged main: `mod-api --check` is clean, clippy is clean, and 119 native tests pass.
2026-11-02 10:56:48 +01:00
Ludovic Marie 95d83ab294 Merge pull request #6893 from horizon-rust/perf/prepared-data-file
🤖 Agent: verify and launcher green on this exact head. The risk I checked hardest: a mirror reading vanilla's bytes. The data file is used only for the canonical root it was written for, with the same horizon-resources.json, never through a symlink, and the pack and content mirrors skip it (unit-tested with a copy and a link). pr_ready content-root tests 22/22 with real resources.
2026-11-02 10:56:44 +01:00
Ludovic MarieandClaude Opus 5.5 6cc25834e4 Merge main into #6994
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:55:43 +01:00
MSedgeMC 55a6eddf8e An iron golem takes the monster that bumps it as its target
`IronGolem.doPush` picks an `Enemy` that touches it, never a creeper, as
its target one time in 20, drawing the random only for those, and then
pushes it. `push_entities` only ran the Warden's touch hook, so a monster
that walked into a golem never became its target.

Corrects the IronGolem.doPush row in game-012.tsv.
2026-11-02 09:55:26 +00:00
Ludovic MarieandClaude Opus 5.5 c274e34463 Leave-level snapshots only when a mod is loaded
dropped_ids, orb_ids and mob_ids ran every tick (and around explosions,
lightning, unloads) for nothing but native_entity_leave_level; with no mod
they now take nothing, and the *_left_since diffs return at once. Saves a
Vec + HashSet per list per tick, and a String per mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:53:59 +01:00
arismoko c3501ad1ab Preserve drowning bubble counts and healed player identity 2026-11-02 09:53:55 +00:00
Ludovic Marie 3a42c67b1a Merge remote-tracking branch 'origin/main' into ci/runner-languages 2026-11-02 10:53:46 +01:00
Akirabane 3eef82f7eb Merge origin/main 2026-11-02 10:52:49 +01:00
Ludovic Marie b8b268e51d Merge pull request #6986 from MSedgeMC/chore/ledger-game2-002-2
🤖 Agent: queued at acfd992b34. `ItemFrame.getSlot` via `slots_named` → `Slot::Frame` and `slot_get` covers `container.0` and `contents`, as vanilla's `SlotAccess` does. All 14 rows resolve on main.
2026-11-02 10:52:11 +01:00
Ludovic Marie 498405ce58 Merge pull request #6984 from MSedgeMC/chore/ledger-game-004
🤖 Agent: queued at c4b8a27657. `skip_blending` jumping the factor straight to 1 or 0 is `BlendState.setImmediate`, used on login and dimension change as the note says. All 3 rows resolve on main.
2026-11-02 10:52:06 +01:00
Ludovic Marie c7c332e5af Merge pull request #6983 from MSedgeMC/chore/ledger-client2-020
🤖 Agent: queued at 7c926302eb. `join_selected` taking Enter and Space as a click does is `WorldListEntry.keyPressed`. Both rows resolve on main.
2026-11-02 10:52:02 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 b938d5bb82 Merge origin/main into refactor/entity-walk-fluids
Keeps main's behaviour (#6491, #6665, #6797) and this slice's single NoGravity
and fluid-push decisions: minecart bubble column goes through the trait and
keeps main's fall-distance reset, throwables keep the widened-float drag.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:51:52 -05:00
MSedgeMC a8a0b95dbd Clear stale rest-022 HashOps, Mth and PNG header rows
The HashOps create* family is ported in horizon-protocol (the component hashes the
client sends in HashedStack), the clamped map and inverse lerp formulas live at their
call sites, and the PNG signature/IHDR header is read by png_size. Twelve rows still
called them missing.

The other five candidate rows are real gaps and keep their verdict.
2026-11-02 09:51:23 +00:00
MSedgeMC 499788ffcf Clear the stale rest-020 team death message row
PlayerList.broadcastSystemToAllExceptTeam is ported: death_message_reaches returns false
for the dying player's own team under HideForOwnTeam and true for everyone else, and the
LAN hub and the local chat line both go through it.

The other two candidate rows are real gaps and keep their verdict.
2026-11-02 09:51:22 +00:00
MSedgeMC 3197c9641d Clear stale rest-004 rows main already implements
Nineteen rest-004 rows still said missing or unsure for behaviour the tree has: the
streaming scheduler's 3x3 neighbour gate, the keyframe unit conversion (done when the
vanilla definitions build their keyframes), the cloak trail, the selection and
confirmation keys, the player-list entry and its skin, the shoulder parrot variant, the
map in the first-person hand, the riding jump, the closest-space nudge and its
suffocation test, the sprint slowdown while using an item, the elytra sound, boat ride
input and the rope hold point.

The other seven candidate rows are real gaps and keep their verdict.
2026-11-02 09:51:20 +00:00
MSedgeMC b46c36f11a rest-019: two pack rows main already ports, and a citation cut to the fn
PackFormat.IntermediaryFormat.validatePackFormatForRange and
PackRepository.isAbleToClearAnyPack are both in
crates/horizon-client-render/src/packs.rs on main; the map still read them
missing. PackFormat.lastPreMinorVersion cited the file alone, so map-check
could not resolve its fn; it now names last_pre_minor.
2026-11-02 09:51:17 +00:00
Ludovic Marie e33a678fb9 Merge pull request #6999 from horizon-rust/perf/item-quad-normals
🤖 Agent: exact refactor of the per-quad normal: same inverse-transpose calls per pose, checksum of 336k item vertices identical to main; −18 % on the submit bench. Mac frame A/B waits for disk.
2026-11-02 10:50:50 +01:00
arismoko 0211932a49 Fix native living hook integration 2026-11-02 09:50:39 +00:00
Ludovic Marie aa504aed42 Merge pull request #6643 from some-du6e/far-terrain/5-bobby
🤖 Agent: queued at 4abd6ddad3. This answers the licence question cleanly. Bobby was only run, in a fork CI job against three vanilla servers, then described from its files on disk, and a fresh agent with no access to the history wrote the reader from that description. Bobby's region files are opened with `RegionFile::open` (read-only), only Horizon's own `server-terrain/` copy is opened writable, and the test checks that Bobby's file stays byte-for-byte unchanged. A chunk is read from Bobby once and then kept, so a visit doesn't keep re-reading `.minecraft`. I merged #6327's main merge (no conflicts); clippy is clean, and 84 server_terrain/lod/bluemap plus 61 save tests pass.
2026-11-02 10:49:11 +01:00
MSedgeMC 34523c59de port_map: clear client2-010 rows main already implements
The IME composition and its overlay, the multiline box's drag and character
count, the wheel over a cycle button, the chat tag icons and a dialog's item
body are all in the tree now; cite them instead of calling them missing.
2026-11-02 09:48:36 +00:00
rdbtCVSandClaude Opus 5.5 eacdc5b519 Native mods: hide a player from lower-permission players (native_hide_player)
A Host helper for a vanish: the hub drops the player from the Tab lists of
players below the level, the entity tracker stops pairing them with those
players (as for a spectator), and their join and leave messages are skipped
for them. Kept by UUID for a player who is off. Helper-only fragment.

Closes #6996

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:48:20 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 2f675aaea7 Merge origin/main into fix/cart-portal
The portal pass is main's for_each_entity walk; the vehicles flag now
filters it through Entity::is_vehicle, so the boats and carts still handle
their portal at the start of the tick.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:48:10 -05:00
Ludovic MarieandClaude Opus 5.5 4abd6ddad3 Merge #6327's main merge into #6643
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:48:01 +01:00
arismoko b72715ee4f Route more living heals through native hook 2026-11-02 09:47:47 +00:00
LokiandAntigravity 0be681d696 deflect: send particles when hitting shulker bullet (fixes #6586)
Co-Authored-By: Antigravity <[email protected]>
2026-11-02 18:47:02 +09:00
Ludovic Marie ab9520e06c Merge pull request #6327 from some-du6e/far-terrain/4-gaps
🤖 Agent: queued at 115443b7a7. Everything here sits behind far terrain. The upload `order` queue is the LOD tile uploader's, `waited` and `keeps_observed` default to false for every other `LodSource`, and the `sent_radius` clamp only feeds `Lod::update`, so a client with LOD Distance 0 runs none of it. Throwing away a sample with a hole, instead of drawing it, is what removes the empty ring while tiles download, and the 10 s `retry` bounds how often a missing tile is re-asked. Main merged in without conflicts; clippy is clean and 138 bluemap/lod/server_terrain/streaming tests pass.
2026-11-02 10:45:51 +01:00
breachatoandClaude Opus 5.5 fb024406b9 The item_stack helper draws nothing for an empty stack
GuiGraphicsExtractor.item and itemDecorations skip an empty stack (air,
or a count of 0), so native_item_stack now returns false for one instead
of drawing a ghost icon. Regenerating the mod API also refreshes the
native_projectile_impact doc that main had left stale.

Fixes #6968.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:45:22 +01:00
arismoko 4c16d77c78 Living hooks: register fall and jump targets, keep a canceled fall's block landing, share the drowning step
The living_fall and living_jump fragments name their targets through
horizon_client_lib, which xtask mod-api resolves (it rejected the
horizon_client::landing and ::motion paths), and list the files that
carry their API markers, as living_drown now does.

A canceled LivingFallEvent still lets a mod block's fallOn run, and a
mob's block landing keeps the distance before the mods' changes.

The player's and mobs' drowning step lives once in breathing::drown.
2026-11-02 09:44:04 +00:00
Ludovic Marie dab80ac6d6 Merge pull request #6985 from horizon-rust/perf/pacing-scenarios-2
🤖 Agent: the game.rs part only adds spans and a frame_end Instant; spans::time is one relaxed load when HORIZON_SPIKES is off, so the frame path is unchanged for players.
2026-11-02 10:43:23 +01:00
MSedgeMC acfd992b34 port map: clear game2-002 rows main already implements
Eight rows said missing for behaviour the tree has, two noted gaps that
have closed, and four citations pointed at nothing.
2026-11-02 09:43:01 +00:00
Ludovic MarieandClaude Opus 5.5 115443b7a7 Merge main into #6327
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:42:48 +01:00
Ludovic Marie c76657fb11 Merge pull request #6926 from MSedgeMC/chore/ledger-classes-02
🤖 Agent: queued again. Its only verify failure was main's two sign tests from #6909 (fixed by #6957); main is now merged in. The PR only drops rows from classes-02.tsv whose classes main already implements, so the ledger check is the part to watch.
2026-11-02 10:42:08 +01:00
MSedgeMC c4b8a27657 Clear stale game-004.tsv rows main already implements 2026-11-02 09:41:49 +00:00
AkirabaneandClaude Sonnet 5.5 8c2f6cbefe Test the scute particles of a cracking wolf armor
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 10:41:13 +01:00
Ludovic Marie 8e7b36d704 Merge branch 'main' into chore/ledger-classes-02 2026-11-02 10:41:08 +01:00
Ludovic Marie 8e4884fe34 Merge pull request #6982 from horizon-rust/prO/mod-api-regen-6949
🤖 Agent: queued at bb05ede84c. Only `generated.rs` changes; `mod-api --check` is clean afterwards, and main fails it now.
2026-11-02 10:41:03 +01:00
Ludovic Marie 6a1219b777 Merge pull request #6929 from HrafnaValkyrja/refactor/entity-walk-portals
🤖 Agent: queued at c2f28b5ddb. `handle_entity_portals` runs every tick, so cost was the question. The walk keeps the old rule that `can_use_portal` is asked only of entities already in `portals.entities`, and a kind with `portal_size` None (stands, falling blocks) costs one inlined match. 294.9 → 284.9 µs at 2000 entities shows no regression, and the four portal scenes are byte-identical before and after. `portal_from` using `previous` for projectiles, rockets and tridents is the same split the old code made by hand. Queued at your own main merge; `run-parity` is on.
2026-11-02 10:40:58 +01:00
breachatoandClaude Opus 5.5 37d7740c2e Regenerate the mod API after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:40:06 +01:00
MSedgeMC 7c926302eb port_map: clear stale client2-020 rows
WorldListEntry.keyPressed and WorldListEntry.canInteract are both in
the tree now: Enter or Space on the list joins the selected world
through Title::confirm_key_size, and the join is gated on
LevelSummary.primaryActionActive (WorldEntry::can_play).
2026-11-02 09:40:05 +00:00
Ludovic MarieandClaude Opus 5.5 4935b692c3 Prepared data file: the recipe and loot folders read from one file
horizon-data.bin (#4267's name) holds the prepared folder's data/ and
assets/ files (sounds aside) in one file, read through one open handle with
positional reads: no file opened per recipe, tag or table, which costs a
Defender scan each on Windows (#1961) and a seek on a hard disk.

- horizon-core::prepared_files: the format, its writer (temporary file
  renamed into place) and reader. A file is used only for the root it was
  written for (its canonical path) and the preparation it was written from
  (its copy of horizon-resources.json), and never through a link; anything
  else, a damaged file or HORIZON_NO_DATA_FILE, reads the folders.
- The game writes it for the vanilla root on an idle thread once the start
  is done, when there is none it can use: this start read the files already.
- json_folder (the recipes, the loot tables, predicates and modifiers) reads
  from it first. Other folders move to it in the next PRs.
- The resource pack and content mod mirrors don't link it, and
  check_public_tree rejects the name.

Part of #1961.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:39:48 +01:00
Ludovic Marie e2b0cadb6d Merge remote-tracking branch 'origin/main' into ci/runner-languages 2026-11-02 10:39:47 +01:00
Ludovic Marie b990bd1f9c Merge pull request #6957 from horizon-rust/fix/sign-tests-without-resources
Sign content tests: skip without prepared resources (verify broken on main)
2026-11-02 10:39:33 +01:00
AkirabaneandClaude Sonnet 5.5 b0d11e07ca A wolf's cracking armor sends its scute particles
Wolf.actuallyHurt, when the armor's Crackiness level changes, plays the crack
sound and then sends twenty armadillo scute item particles a block above the
wolf (spread 0.2, 0.1, 0.2, speed 0.1). hurt_wolf_armor pushed only the sound.
It now reports a MobEvent::ItemParticles, which the game draws in the host's
window (the LAN packet has no item for a particle yet).

Refs #6978

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 10:39:28 +01:00
Ludovic MarieandClaude Opus 5.5 bb05ede84c Regenerate the mod API output after #6949
#6949 reworded native_projectile_impact's doc in native_hooks.rs without
running xtask mod-api, so generated.rs kept the old text and
`mod-api --check` fails on main (and in every branch's pr_ready).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:39:22 +01:00
Ludovic Marie f9276e735e Merge pull request #6326 from some-du6e/far-terrain/3-find-bluemap
🤖 Agent: queued at 5a9e71d756. Find BlueMap is off by default (`blue_map_settings_are_advanced_rendering_options` pins that), and with it off nothing is probed. When it's on, the candidates stay on the server's own host or its `map.` subdomain. The parent domain is tried only for `play.`/`mc.` hosts and never under shared dynamic-DNS domains, so a client doesn't knock on a stranger's site. Logging map addresses only at debug level keeps them out of crash reports. Main's new `every_graphics_text_is_in_the_language_table` caught the four BlueMap texts, so I added them to en_us and fr_fr in the merge. Clippy is clean and the bluemap/graphics/lod tests pass.
2026-11-02 10:39:19 +01:00
AltMugenandCodex f2790475a3 Clarify Rust damage-handler documentation for port ledger
Co-Authored-By: Codex <[email protected]>
2026-11-02 12:38:49 +03:00
breachato 78ac0fce35 Merge remote-tracking branch 'upstream/main' into feat/6697-hanging-break-hook 2026-11-02 10:38:39 +01:00
Ludovic MarieandClaude Opus 5.5 5a9e71d756 Merge #6325's main merge into #6326; BlueMap settings in the language table
client_lib.rs: main's list plus bluemap and bluemap_detect. Main's
every_graphics_text_is_in_the_language_table now covers Find BlueMap and
BlueMap Radius: en_us and fr_fr entries for their captions and help.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:37:21 +01:00
MSedgeMC 221c9f15eb Port map: two game2-014 Mirror rows main already ports
`Mirror.mirror` and `Mirror.getRotation` said missing with an unsure note. A
hanging entity's facing is mirrored and turned by
`worldgen/structure/processor.rs`'s `turn_facing`: the same axis flips
(`LEFT_RIGHT` swaps north and south, `FRONT_BACK` west and east) before the
rotation, which is what `HangingEntity.mirror`'s 180 degree turn amounts to
for a horizontal facing. A block state's mirrored result is not code at all:
`StateTransforms::mirror` reads the exported per-state table.

map-check over the server jar: 144 rows, no problems.
2026-11-02 09:37:10 +00:00
DetimesandClaude Sonnet 5.5 f381a14cd3 Native mods: regenerate the mod API after merging main
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:36:14 +00:00
arismoko 039cf9ab29 Fix extended item lifetime behavior 2026-11-02 09:36:05 +00:00
AltMugenandCodex 4d2bde97e4 Show synced area effect cloud hitboxes in F3+B
Co-Authored-By: Codex <[email protected]>
2026-11-02 12:35:57 +03:00
AltMugenandCodex 100d494541 Merge remote-tracking branch 'origin/main' into fix/dragon-living-contacts
Co-Authored-By: Codex <[email protected]>
2026-11-02 12:35:57 +03:00
MSedgeMC c9497b0cee Clear stale game2-006 ledger rows the tree already implements
Flying rockets, projectiles and chest minecarts are saved, a dangerous
wither skull's blast caps resistance and a mob's blast leaves boats and
carts alone; the verdict map still called these missing.
2026-11-02 09:35:18 +00:00
MSedgeMC eec7f84ff6 Port map: PlayerList.broadcastSystemToAllExceptTeam is the same predicate
Horizon filters every death-message recipient through death_message_reaches,
which answers for all three team visibilities; HIDE_FOR_OWN_TEAM is exactly
this broadcast (everyone outside the dying player's team), and the guest death
and the host's death both use it. The other two rows the file still marks
missing were re-checked and are still absent.
2026-11-02 09:35:16 +00:00
MSedgeMC a7a4fd412a Clear stale classes-01 ledger rows the tree already implements
Fourteen rows whose code is in the tree now, plus the class row that was
written as a method row and the `unsure` verdict resolved to `missing`.
2026-11-02 09:35:04 +00:00
Akirabane bb3193ef94 Merge origin/main 2026-11-02 10:34:44 +01:00
JoansitohandClaude Sonnet 5.5 f4eff4bb39 Native mods: regenerate mod API output, keep the hook scan clean
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 10:34:33 +01:00
Ludovic Marie 26b9c0c9ba Merge pull request #6325 from some-du6e/far-terrain/2b-bluemap
🤖 Agent: queued at b3461b4515. Network and cost check. Nothing is fetched until the player names a map in `horizon-bluemap.txt`, which the launcher now refuses to import from another launcher's instance, so a modpack can't make a client report its position to a map server. With no map the tick's only cost is a `None` check on `lod_blue_map`. With one, tiles come one at a time on their own thread (10 s timeout, body capped at 8 MiB, at most ten a second, longer waits while the map is down), the disk cache holds 256 MiB per server, and the worker exits once `BlueMap` drops. I merged #6324's main merge: `serialize` keeps main's block-entity cache keys and still appends `blueMapRadius`. Clippy is clean and 100 bluemap/graphics/lod/server_terrain tests pass.
2026-11-02 10:34:28 +01:00
HrafnaValkyrjaandClaude Opus 5.5 dcb5ee8542 Regenerate the mod API output after the merge with main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:34:26 -05:00
HrafnaValkyrja 9963ac5097 Merge remote-tracking branch 'origin/main' into fix/cart-portal 2026-11-02 04:34:20 -05:00
MSedgeMC ce3419f211 port_map: FlatLayerInfo.heightLimited is the flat layer clamp
The row said flat settings only cap the total at the build height and no
layer is clamped per layer. title/flat.rs clamps every layer's height as it
is read: FlatSettings::from_tag holds each height to 0..Y_SIZE, and
FlatSettings::parse caps a layer at the height left below Y_SIZE, so the
heightLimited(Y_SIZE) clamp is inline in the decoder.
2026-11-02 09:33:54 +00:00
Ludovic MarieandClaude Opus 5.5 b3461b4515 Merge #6324's main merge into #6325
graphics.rs: main's block-entity cache keys in serialize's format, kept
as `let mut text` so blueMapRadius is appended.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:32:50 +01:00
HrafnaValkyrja c2f28b5ddb Merge remote-tracking branch 'origin/main' into refactor/entity-walk-portals
# Conflicts:
#	crates/horizon-client/src/game/entity.rs
2026-11-02 04:32:40 -05:00
arismoko 2e62498006 Wire living drown hook for mobs 2026-11-02 09:31:29 +00:00
GIGABAIT93andClaude Opus 5.5 82c47a7f3b docs: Running Horizon from another launcher (#3545)
The Launch API's guide, from picking a build to playing: an owner only, the
archives and their signed checksums, asking both programs --launch-api, the
game files and Java a preparation reads, preparing with --complete and
--progress json, --check before every start, starting with the session on
stdin, the exit codes, and updating. launch.md links it and drops it from
"Not there yet".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:31:19 +02:00
Ludovic Marie 3819db72c4 Merge pull request #6324 from some-du6e/far-terrain/2a-bluemap-format
🤖 Agent: queued at b133ad22cd. I read `bluemap.rs` as a parser of untrusted server data, and it holds. `Lowres::parse` range-checks `tileSize` (1–1024), `lodFactor` (2–64) and `lodCount` (1–8), and it caps the coarsest span at 2^26 blocks, so `locate`/`span` can't overflow. `decode_tile` caps gunzip at 8 MiB and rejects a PNG whose header size isn't the layout's before it allocates the frame. Height is clamped to ±2048. Nothing is fetched yet in this slice, and the module stays inert until the player names a map in `horizon-bluemap.txt`. The BlueMap notice is MIT and credited. I merged main (notices and module list), clippy is clean on render and client-lib, and the 6 bluemap tests pass.
2026-11-02 10:31:18 +01:00
arismoko f8e9931cee Add living drown hook for players 2026-11-02 09:31:12 +00:00
arismoko 75c640c9a6 Preserve extended item lifetime in signed age 2026-11-02 09:30:42 +00:00
Detimes 373dbbd319 Merge remote-tracking branch 'upstream/main' into wave2-tier1 2026-11-02 09:30:17 +00:00
rdbtCVSandClaude Opus 5.5 23f8bd4fed Remove the deprecated in-frame single-player path
Single player only runs on its server thread now. --in-frame and
--no-server-thread print a notice, and HORIZON_SERVER_THREAD is ignored.
This deletes the code that only the in-frame path reached: the window's
world loader and local player tick, its portal travel, the loading screen
and ring-by-ring streaming, and the in-frame LAN host (the host player in
the hub, its models of the guests, the reserved uuid). The world-upgrade
progress screen with its Cancel button moves to the server-thread start.
Tools, the harnesses and the docs no longer use the in-frame path.

Closes #6473

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:30:14 +00:00
JoansitohandClaude Sonnet 5.5 b4aac0aebc Native mods: mobs leaving a level (native_entity_leave_level)
Mobs that die, despawn, enter a hive or unload with their chunk are told
to native_entity_leave_level with their type as kind, from an id diff
around tick_mobs and the chunk-unload retain. Portals, command purges and
level close are not told yet.

Part of #5162.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 10:30:14 +01:00
MSedgeMC 374ffe0877 port_map: clear client2-003 rows main already implements
The F3 debug renderers (collision boxes, heightmaps, block and sky light,
solid faces, water levels), the section fade-in visibility and the transient
block queue are in the tree now; cite them instead of calling them missing.
2026-11-02 09:30:03 +00:00
Ludovic MarieandClaude Opus 5.5 b133ad22cd Merge main into #6324
THIRD_PARTY_NOTICES.md: BlueMap's notice after main's FusionExamples;
client_lib.rs: main's list plus bluemap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:30:02 +01:00
arismoko af2107ee2f Wire living jump hook for mobs and mounts 2026-11-02 09:29:09 +00:00
Ludovic Marie 9c7f42d9b7 Merge pull request #6935 from HrafnaValkyrja/refactor/entity-walk-selectors
🤖 Agent: queued at 22c2240767. The side-by-side against a real 26.3 server is what makes the unsorted order change acceptable: matches went from 112 to 120 of 148, and the order probes from 5 to 9 of 12. Vanilla's unsorted order is summon order, which neither list order reproduces exactly. Making `selector_target` required, with no default, is the right call: a new kind has to decide whether `@e` finds it. I merged main: #6925's `thunder_hit` sits beside `selector_target` on the same kinds, so I kept both. Clippy is clean and the 28 command/selector/lightning tests pass. `run-parity` is on.
2026-11-02 10:28:52 +01:00
HrafnaValkyrja 3c6aa581be Merge remote-tracking branch 'origin/main' into fix/cart-portal 2026-11-02 04:28:40 -05:00
HrafnaValkyrja 6d2942c9ad Merge remote-tracking branch 'origin/main' into refactor/entity-walk-portals
# Conflicts:
#	crates/horizon-client/src/game/entity.rs
2026-11-02 04:28:21 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 c4842342cd Minecarts and boats cross Nether portals with mob riders and land like vanilla
A boat or minecart with a mob on it never left the portal; Entity.teleportCrossDimension
takes the passengers along and mounts them again. An empty minecart landed off-centre
(AbstractMinecart/AbstractBoat.getRelativePortalPosition reset the forward offset), a tick
late (handlePortal opens the cart's own tick; an arrival joins its level before that level's
entities tick) and at full speed (getKnownMovement clamps to 0.4), so it fell onto the ground
and halved its speed each tick.

Adds tools/cart_portal_e2e.py, which replays the vanilla numbers on a dedicated server.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:28:20 -05:00
arismoko 35a2de6b02 Add living jump hook for players 2026-11-02 09:27:56 +00:00
MSedgeMC 859c00e1ba Clear stale game-003.tsv rows main already implements 2026-11-02 09:27:50 +00:00
arismoko 9fe4c746bb Let every mod extend an expiring item 2026-11-02 09:27:43 +00:00
Ludovic MarieandClaude Opus 5.5 22c2240767 Merge main into #6935
entity.rs: #6925's thunder_hit and this PR's selector_target are both new
methods on the same kinds; kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:27:26 +01:00
Akirabane c4cda8f6cb Merge origin/main 2026-11-02 10:27:17 +01:00
AkirabaneandClaude Sonnet 5.5 a66ec46254 Mobs that land from a fall throw up the block's dust
LivingEntity.checkFallDamage sends 150 * min(0.2 + power / 15, 2.5) block
particles from the block it lands on, once the fall is past the safe distance.
Horizon did it for the player only. A mob's check_fall_damage now reports a
LandingDust event, which the game draws and sends to the LAN guests through
Game::landing_dust.

Fixes #6950

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 10:27:16 +01:00
MSedgeMC 2ed21a3bec chore(port_map): clear stale game2-018.tsv rows main already implements
Six rows said missing while the tree ports them: spawner entity data,
structure unload, moving piston drops/save/load and the property cycle
helper.
2026-11-02 09:26:55 +00:00
Ludovic Marie 6d91fa6946 Merge pull request #6948 from Xmilon/native/item-stack-screen
🤖 Agent: queued at ff5551084a. Checked on a main merge: `mod-api --check` is clean, xtask's 19 tests pass, and `unittest discover -s tests` passes all 611. Taking `helpers` from fragments lets a helper wait for its API number with the rest of its fragment, and the stamper rejects duplicate names. `native_item_stack` passes `Some(String::new())` as the count, so the decorations draw no count, as the doc says. The helper runs only when a mod screen calls it.
2026-11-02 10:25:50 +01:00
Ludovic Marie da8c6d833a Merge pull request #6955 from GIGABAIT93/feat/prepare-launch-api
🤖 Agent: queued at 89efa41590. Good that `launch_api_options_are_in_the_help` ties `OPTIONS` to `--help`, so the promised list can't drift from what the parser documents. `--launch-api` reads the same `research-lock.json` from `--root` as `--prepare` does, so a launcher gets the pins the preparer will actually use, and a missing lock is exit 1, not a guess. Startup only: nothing touches the game.
2026-11-02 10:25:46 +01:00
Ludovic Marie e89a78a15b Merge pull request #6944 from MSedgeMC/chore/ledger-client2-013
🤖 Agent: queued at a10ada9b34. `home` is the right status for `AlertScreen` and `BackupConfirmScreen`: both live as `Alert`/`Mode` states inside `title.rs`, not as screens of their own. The rows name the drawing and button functions, so the next reader can find them. All 5 resolve on main.
2026-11-02 10:25:42 +01:00
arismoko fee348a138 Wire living fall hook for mobs 2026-11-02 09:25:30 +00:00
breachatoandClaude Opus 5.5 c5f2812ecc rustfmt the frame survival check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:25:04 +01:00
arismoko c53153e201 Add living fall hook to player landing 2026-11-02 09:24:40 +00:00
MSedgeMC ea5de0f51f Clear stale rest-009 rows main already implements
Gizmos.rect and RectGizmo.fromCuboidFace are inlined in the solid face debug
renderer, which fills one cuboid face per sturdy side with the corner order
cuboid_fill_quads writes.
2026-11-02 09:24:31 +00:00
arismoko 12b38c91f7 Match replacement hook generator contract 2026-11-02 09:23:28 +00:00
Ludovic Marie 846798adea Merge pull request #6936 from arslanarm/shaderpacks/custom-texture-3d
🤖 Agent: queued at 5131f3f960. Load-time only: `customTexture.*` is parsed once in `load_textures`, and `find` runs once per sampler when the pipelines are built, so a frame costs nothing extra. Putting a stage's own `texture.<stage>.<name>` first and falling back to the `EVERY_STAGE` entry keeps every pack that already worked unchanged, which your 40-pack translation run confirms. The Iris behaviour was observed black-box with probe packs, so this stays clean-room.
2026-11-02 10:23:09 +01:00
arismoko 22dd5f9a80 Add native item expiration hook 2026-11-02 09:22:37 +00:00
Ludovic Marie b9721a2952 Merge pull request #6949 from Xmilon/fix/cancelled-arrow-sticks
🤖 Agent: queued at a03af1c49e. With no mod loaded nothing changes: `impact` is false only through `tick_through` (a cancelled hook), `canceled_impact_inside` stays false otherwise, and `clip` gets `None`. The hook's contract does change: an arrow no longer stops where it would have hit, it flies its whole motion and ignores the block it's leaving. The doc comment now says so, which is what #6366 asked for. Missing: a test that ticks `tick_through` into a block and checks the arrow comes out unstuck.
2026-11-02 10:22:32 +01:00
Ludovic Marie 6a974f06e0 Merge pull request #6954 from MSedgeMC/chore/port-map-game-002
🤖 Agent: queued at 9a9b86ad93. `full_precision_required` exists in `lan/entity.rs` and covers the item-against-a-wall case `ServerEntity` checks, so `port` is right. It resolves on main.
2026-11-02 10:22:27 +01:00
arismoko 9db36edb93 docs: list the native-mod values a test outside horizon-client can't build 2026-11-02 09:22:23 +00:00
Ludovic Marie 289c963435 Merge pull request #6953 from MSedgeMC/chore/ledger-game2-009.tsv
🤖 Agent: queued at aa497fa3d1. Good that the `addSlotWithItem` row keeps its gap (the /item container write bypasses `try_insert`) instead of claiming a full port. All 4 rows resolve on main.
2026-11-02 10:22:23 +01:00
arismoko 9a73f6109b Wire living heal hook for wither skull owners 2026-11-02 09:22:22 +00:00
Ludovic Marie dd9d211e49 Merge pull request #6947 from MSedgeMC/chore/ledger-rest-002-rows
🤖 Agent: queued at 7800bf7190. `PassengerPredicate.matches` as `inline` in `advancements.rs::entity` is right, since vanilla's is an any-passenger match over a nested predicate and that's how the `passenger` key is read there. All 6 rows resolve on main.
2026-11-02 10:22:19 +01:00
masa-masa-0314andGPT-6 0bac0eb877 Merge upstream vehicle fluid updates without replacing experimental steps
Retain the experimental per-step block effects and final TNT velocity sampling while incorporating the shared fluid update and removing duplicate outer pushes.

Co-Authored-By: GPT-6 (ZCode) <[email protected]>
2026-11-02 17:22:16 +08:00
Ludovic Marie 009212dbc1 Merge pull request #6946 from MSedgeMC/fix/port-map-client2-009
🤖 Agent: queued at 6031f1a85b. Splitting `buildInitialScreens` (the queue popped by `next_ban_notice`) from `addInitialScreens` (`initial_notices` filling it) matches vanilla's two steps, and the onboarding note points to `start_onboarding`. All 8 rows resolve on main.
2026-11-02 10:22:15 +01:00
Ludovic Marie 8af7eb2190 Merge pull request #6945 from MSedgeMC/chore/ledger-client2-014
🤖 Agent: queued at 2f4c6ab64d. The `ConnectScreen.init` row gives the layout it checks (status at height/2 - 50, Cancel only while the connection can still be cancelled), so it can be verified against the code, not just the name. All 11 rows resolve on main.
2026-11-02 10:22:11 +01:00
arismoko 0aec32bd05 Add living heal native hook for player effects 2026-11-02 09:22:03 +00:00
MSedgeMC 0f52737357 port_map: clear stale client2-015 rows main already implements
Eighteen rows: the whole F3+F6 debug entry editor, the dialog list buttons,
dialog text clicks and input controls, the statistics tab state and item
tooltip, and the live world-options game mode buttons.
2026-11-02 09:21:37 +00:00
Ludovic MarieandClaude Opus 5.5 f286ed4396 Sign content tests: skip without prepared resources
#6909's two new content-root tests (signs_take_their_form_wood_and_wall_block_from_their_declaration,
a_feature_gives_a_mods_sign_its_block_entity) expected prepared 26.3
resources. CI's verify job runs the ignored tests without them, so
verify failed on every PR based on main. They now return early like the
file's other content-root tests (let Some(base) = prepared() else return).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:20:50 +01:00
MSedgeMC 1f78a6c2a4 Port map: sixteen gui-inventory-1 rows main already ports
The command block screen scrolls its suggestion list, the container screens
draw the slot_highlight_back and slot_highlight_front sprites in vanilla's
order, a carried stack still lets a bundle's contents tooltip through, the
recipe book's search box takes a drag and its open book stops slot hovering,
the sign editor closes itself when the sign goes or the player walks off, IME
composition reaches a sign and the creative search box, the creative tabs
rebuild on a permission change and its container tick swaps a player out of
creative, a creative display slot refuses a creative_slot_lock stack, and the
smithing table's empty slots cycle their hint icons.

AbstractContainerScreen.tick still has no closeContainer for a dead player, so
that row stays missing.

map-check over a build of the client and server jars: 147 rows, no problems.
2026-11-02 09:20:46 +00:00
Ludovic Marie a04ae40674 Merge pull request #6925 from HrafnaValkyrja/refactor/entity-walk-lightning
🤖 Agent: queued at 0c8f97e4a3. Checked that behaviour is unchanged where the order moved. Orbs and items are now removed one by one, last first, instead of with `retain`, but `remove(i)` keeps the survivors' order, and `hit_items` is only read with `contains`. In the mob stage, a pig or villager conversion only sets `removed` and queues a spawn event, so the indices stay valid mid-walk. The views are still captured before each mob's own damage, which is all the old up-front `collect` guaranteed, since one mob's damage doesn't change another's view. `thunder_count` adds four list scans per strike, and only per strike, so the tick is unaffected.
2026-11-02 10:20:27 +01:00
Ludovic Marie bfc2e84e67 Merge pull request #6924 from Akirabane/fix/6908-bubble-particles
🤖 Agent: queued at 51e63a4f8e. `surfaces` reports only the column's top block, as `onAboveBubbleColumn` does, and the test pins the case one block lower where nothing is reported. The `Vec`s stay empty unless an entity is at a surface, so a normal tick doesn't allocate. One ordering point: the pearl's particles are drawn after the whole projectile loop, so their four `world.random` draws land after any later projectile's draws in that tick, where vanilla draws them inside the pearl's own tick. That's harmless for particles, and it's the same deferral #6474 uses for mobs.
2026-11-02 10:20:22 +01:00
Ludovic MarieandClaude Opus 5.5 3a74f011e1 CI: clone the languages into each preparation, as the sounds are
pack_screen's harness draws ten languages (since #6129), and horizon-prepare copies them from the
official launcher's asset store, which no runner has: every preparation held en_us alone and the
harness failed on every PR in its scope. They depend only on the Minecraft version: kept once in the
shared source (languages/<version>/: assets/pack.mcmeta and assets/minecraft/lang/), cloned into the
runner's copy like any later addition, then into each preparation that lacks them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:19:56 +01:00
Ludovic Marie ed32f2ec4c Merge pull request #6952 from horizon-rust/perf/preload-command-registries
🤖 Agent: verified 4 A/B join pairs (worst frame 40-50 → 22-26 ms, 0 frames >33 ms); the preload runs after start_mods so mod templates stay in the cached ids, and a world opened before it finishes waits on the cache's lock as today.
2026-11-02 10:18:58 +01:00
Ludovic Marie ac5f0e2f26 Merge pull request #6941 from DUzzL/fix/6775-dragon-knockback
🤖 Agent: queued at 94deb80b89. The 16×8 box and pushing the dragon entity but not its parts match `getEntities`. Your arrow analysis is right too: `AbstractArrow` knocks back only a `LivingEntity`, and a part isn't one. I pushed one commit for exact floats: the eye is `8.0F * 0.85F` (6.8000001907 widened, not 6.8), and the direction is normalized with `Vec3.normalize`'s 1.0E-5F cut before `scale(power)`, instead of folding `1/l` into the power. The 24 dragon tests pass and clippy is clean.
2026-11-02 10:18:54 +01:00
MSedgeMC 9a9b86ad93 Port map: ServerEntity.isFullPrecisionEncodingRequired is implemented
The row was listed as missing; lan/entity.rs::full_precision_required makes
the same decision (an item entity's box against a block after a vertical or
horizontal collision needs the full position sync, with primed TNT and a happy
ghast on the precise branch before it). The four rows still marked missing in
the file were re-checked and are still absent.
2026-11-02 09:18:52 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 6e932ea3c5 Merge origin/main into refactor/entity-walk-selectors
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:18:31 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 a7bdd058f9 Merge origin/main into refactor/entity-walk-selectors
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:18:26 -05:00
MSedgeMC aa497fa3d1 Clear stale game2-009 rows main already implements
Four rows still said missing or unsure though the tree has the port: the
bundle append, the any-fuel ghost list, and the dyed and any-potion book
displays.
2026-11-02 09:18:07 +00:00
Ludovic MarieandClaude Opus 5.5 94deb80b89 Dragon explosion push: vanilla's float eye height and normalize
`8.0F * 0.85F` is 6.8000001907 widened, not 6.8; `Vec3.normalize` cuts at
1.0E-5F and divides before `scale(power)`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:17:32 +01:00
Ludovic Marie 5ab538df24 Merge pull request #6920 from breachato/fix/6907-mod-menu-drag-quick-move
🤖 Agent: queued at 77613e9250. The guard reads the drag from where a mod menu type keeps it, `play.crafting.machine` for that window, so the shift-click falls through to `lan_machine_click` → `Menu::click`, which resets the drag as `doClick` does. Vanilla menu types keep their `menu.drag` check. The added test covers `Menu`'s side; the hub routing has none, but the routing change is a single early return.
2026-11-02 10:17:25 +01:00
Ludovic Marie 74e0d72509 Merge pull request #6942 from DUzzL/fix/6806-baby-villager-sound
🤖 Agent: queued at 21f6f79a32. `Mob::voice` is what vanilla's `makeSound` does: `getSoundVolume` plus `getVoicePitch`, which also draws from the mob's random in vanilla's order. That fixes the trader's `no` along with the baby's. The test's ranges hold whatever the random draws (adult 0.8–1.2, baby 1.3–1.7).
2026-11-02 10:17:21 +01:00
Ludovic Marie 9b756fb47b Merge pull request #6933 from breachato/fix/xtask-placeholder-test-crlf
🤖 Agent: queued at 5350241318. Comparing both sides with LF is the narrow fix: the generator's output on Linux and macOS is unchanged, and only the assertion forgives a CRLF checkout, the same as #6375 did for `native_loot_kinds_stay_hookable`.
2026-11-02 10:17:17 +01:00
Ludovic Marie 83d015b998 Merge pull request #6943 from MSedgeMC/chore/ledger-game-008-rows
🤖 Agent: queued at 060e4ac80e. `SpearApproach.ableToAttack` as `inline` in `spear_use.rs::able_to_attack`, shared with `SpearUseGoal`, matches vanilla, where both call the same check. All 22 rows resolve on main.
2026-11-02 10:17:13 +01:00
Ludovic Marie 93da3a648c Merge pull request #6940 from MSedgeMC/chore/ledger-classes-03
🤖 Agent: queued at d0bdadacba. The `TicketStorage.addTicket` row names the only case Horizon has, the PORTAL ticket restarting its 300-tick timeout, which is honest about scope. All 5 rows resolve on main.
2026-11-02 10:17:09 +01:00
Ludovic Marie 885a2b3003 Merge pull request #6939 from MSedgeMC/chore/ledger-game2-005.tsv
🤖 Agent: queued at d3ffd9c8ea. `age_boundary_reached` rebuilding the brain from memories is what `Villager.ageBoundaryReached` → `refreshBrain` does. All 6 rows resolve on main.
2026-11-02 10:17:04 +01:00
[email protected]andCodex gpt-6.1-terra a03af1c49e Fix cancelled arrow impacts sticking in blocks
Co-Authored-By: Codex gpt-6.1-terra <[email protected]>
2026-11-02 10:16:13 +01:00
MSedgeMC 7800bf7190 Clear six stale rest-002 rows the tree already answers
The passenger predicate key is read, any_block_use and default_block_use both
fire, the killing blow's damage source is tested, and the picked-up trigger
fires both of its instances.
2026-11-02 09:15:51 +00:00
[email protected]andCodex gpt-6.1-terra ff5551084a feat(mods): draw item stacks on screens
Co-Authored-By: Codex gpt-6.1-terra <[email protected]>
2026-11-02 10:15:45 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 415b3c2fb4 Merge origin/main into refactor/entity-walk-portals
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:15:30 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 1d2d694dc8 Merge origin/main into refactor/entity-walk-portals
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:15:24 -05:00
MSedgeMC 6031f1a85b Port ledger: eight screen and widget methods are ported, not missing
client2-009's missing rows were stale for the keyboard focus work (#4638,
27f948813): Screen.keyPressed and nextFocusPath land in game/key_focus.rs,
the ban notices in ban_notice.rs and Title::next_ban_notice, and the HUD
reset on disconnect in Game::hud_disconnected. Each corrected row names the
function that answers it, with what differs noted as a gap where something
does.
2026-11-02 09:15:00 +00:00
Akirabane 51e63a4f8e Merge origin/main 2026-11-02 10:14:32 +01:00
Ludovic Marie a7c0339fe4 Merge pull request #6938 from MSedgeMC/chore/ledger-game2-017.tsv
🤖 Agent: queued at 6075780005. The Brushable and Shelf arms of `lan/chunks.rs::entity_tag` match vanilla's `getUpdateTag` (hit_direction plus item; Items plus align_items_to_bottom). All three rows resolve on main.
2026-11-02 10:14:26 +01:00
Ludovic Marie 5519edf613 Merge pull request #6937 from MSedgeMC/chore/ledger-rest-008
🤖 Agent: queued at 1da7f9501c. `TryFindLandNearLiquid` is the only vanilla caller that matters, and `LaySpawn::find` does skip the frog's own column inline, so `inline` is the right status. It resolves on main.
2026-11-02 10:14:21 +01:00
Ludovic Marie cf3e0ac311 Merge pull request #6932 from MSedgeMC/chore/ledger-game2-004.tsv
🤖 Agent: queued at 9a868c0751. Spot-checked: the `Piglin.finishConversion` row says what is still missing (the rest of the carried inventory isn't scattered), so it stays honest as a port with a gap. All 25 paths and symbols resolve on main.
2026-11-02 10:14:16 +01:00
Ludovic Marie 21cc6a9c8a Merge pull request #6931 from MSedgeMC/chore/ledger-rest-001
🤖 Agent: queued at c27c6b8c13. The four rows keep their 'gap: minor' notes, which is right: `system_details` writes no storage, process or JVM lines, so these are partial ports, not full ones. Every path resolves on main.
2026-11-02 10:14:12 +01:00
Ludovic Marie d4d73e26d5 Merge pull request #6930 from MSedgeMC/chore/ledger-rest-007
🤖 Agent: queued at 3ba6ada729. `Direction::approximate_nearest` is the float overload, as the row says: vanilla seeds `best` with `Float.MIN_VALUE`, and NORTH as the fallback is what keeps a zero vector from returning DOWN. The file and symbol resolve on main.
2026-11-02 10:14:08 +01:00
MSedgeMC 2f4c6ab64d port_map: clear stale client2-014 rows main already implements
The Recover World rows are title.rs's recover flow, and the connect, waiting,
now-playing, onboarding-fade, name-field and multiplayer-reason rows all have
code behind them; each now cites the fn.
2026-11-02 09:13:49 +00:00
GIGABAIT93andClaude Opus 5.5 89efa41590 horizon-prepare --launch-api: what this preparer supports, as JSON
The Launch API version (the same line as horizon-client's, a test reads it),
the preparer's version, the pinned Minecraft and Java major, the resources'
schema and output version that --check compares, the options a launcher may
pass and the exit codes. A launcher asks it before it runs a preparer it
didn't build. Part of #3545.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:13:43 +02:00
MSedgeMC a10ada9b34 port_map: clear stale client2-013 rows main already implements
AlertScreen, BackupConfirmScreen and BanNoticeScreens have homes in title.rs and
ban_notice.rs; ChatScreen.removed and ChatScreen.shouldDiscardDraft are
Game::keep_chat_draft and should_discard_draft. ChatScreen.formatChat stays
missing with a corrected note.
2026-11-02 09:13:35 +00:00
Ludovic Marie fd19fc62d1 Merge pull request #6922 from HrafnaValkyrja/refactor/entity-walk-wind
🤖 Agent: queued at 18d82b7fa5. Requeued at your own main merge. You kept #6919's `Blast` for the wind walk and added `pushed_by_wind`, so armour stands move for a mace's burst while `blast_hit` still leaves them to `explosion_breaks_decorations`. One nit for a follow-up: `blast_direction` in explosion.rs zeroes below 1.0E-4, but `Vec3.normalize` cuts at 1.0E-5F, which your wind closure already matches. Clippy is clean and the 51 wind/explosion/entity tests pass.
2026-11-02 10:12:39 +01:00
arismoko 99520e08ff Stub AnvilCraft in the mod API generator's test 2026-11-02 03:12:33 -06:00
Akirabane c89ba071ce Merge origin/main 2026-11-02 10:12:10 +01:00
MSedgeMC 060e4ac80e Clear stale game-008 ledger rows the tree already implements
The piglin fight activity's spear behaviours, the piglin's Mount.create and
the frog's TryFindLandNearLiquid are in the tree; the verdict map still
called them missing. Cite them, and leave the two rows that are really
absent alone.
2026-11-02 09:11:48 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 0c8f97e4a3 Merge origin/main into refactor/entity-walk-lightning
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:11:28 -05:00
MSedgeMC d0bdadacba port_map: clear five stale classes-03 rows
The ticket rows were marked missing because portal tickets were said not to
be modelled. ticket_levels.rs has them: ChunkTickets::add_portal adds the
PORTAL ticket placePortalTicket places (same type and level restarts the
300-tick timeout, else the ticket is added) and is the one addTicketWithRadius
call, at the fixed radius 3. levels_to_tick keeps a dimension ticking while a
portal ticket is in it, which is shouldKeepDimensionActive.

EntityBasedExplosionDamageCalculator.getBlockExplosionResistance is the
source-side override exploded_positions applies: a passed-in resistance for one
block (the respawn anchor's water-like block), the wither skull's 0.8 cap and
the TNT minecart's rail exemption.

AttachedStemBlock.getCloneItemStack picks the stem's seed, which
block_items::clone_item does.
2026-11-02 09:10:49 +00:00
MSedgeMC d3ffd9c8ea Clear stale game2-005 rows main already implements
Six rows still said missing or unsure though the tree has the port: the
villager age-up brain rebuild, the wandering trader's two saved fields, the
100-tick sleep gate, the player display-name decoration and the shoulder
parrot's saved variant.
2026-11-02 09:09:30 +00:00
MSedgeMC 6075780005 chore(port_map): clear stale game2-017.tsv rows main already implements
Three rows said missing while the tree ports them: the brushable and
shelf update tags (lan/chunks.rs entity_tag) and the sign command source
(sign_edit.rs sign_command_source).
2026-11-02 09:08:42 +00:00
MSedgeMC 1da7f9501c Clear stale rest-008 row main already implements
Vec3i.differsHorizontally is inlined in the frog's land search, which skips
the frog's own column the way TryFindLandNearLiquid does.
2026-11-02 09:07:49 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 18d82b7fa5 Merge origin/main into refactor/entity-walk-wind
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:07:17 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 b5d5127455 Merge origin/main into refactor/entity-walk-wind
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 04:07:11 -05:00
arismoko dca1953712 Fix attachment sync snapshots, recipients and wire limits 2026-11-02 09:05:59 +00:00
DetimesandClaude Sonnet 5.5 c3d30de706 Native mods: mod API entries for the third batch of wave-2 hooks
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:05:24 +00:00
DetimesandClaude Sonnet 5.5 35b6063c08 Fix useless i32 conversion in the bundle select hook
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:04:47 +00:00
MSedgeMC 9a868c0751 Clear stale game2-004 rows main already implements
The piglin, skeleton, drowned and zombie rows below still said missing (or
unsure) though the tree has the port. Each now cites it.
2026-11-02 09:04:17 +00:00
MSedgeMC c27c6b8c13 Clear four stale rest-001 SystemReport rows
putGraphics and putProcessDetails both read missing while the tree sets the
Graphics Adapter crash detail and reports the process's resident memory; the
two gap notes that claimed no GPU line now say what is still absent.
2026-11-02 09:04:08 +00:00
DetimesandClaude Sonnet 5.5 506e69b156 Native mods: player level change (native_player_level_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:04:01 +00:00
DetimesandClaude Sonnet 5.5 ce4475d99c Native mods: guest kick (native_player_kicked)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:04:01 +00:00
DetimesandClaude Sonnet 5.5 5037df86cf Native mods: guest jump (native_player_jump)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:04:01 +00:00
DetimesandClaude Sonnet 5.5 9fde5062fc Native mods: guest arm swing (native_player_arm_swing)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:04:01 +00:00
DetimesandClaude Sonnet 5.5 7289ff4d2d Native mods: guest input flags (native_player_input)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:04:01 +00:00
DetimesandClaude Sonnet 5.5 05cfd373d7 Native mods: guest sneaking change (native_player_toggle_sneak)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:04:01 +00:00
DetimesandClaude Sonnet 5.5 f2f90968b1 Native mods: vehicle creation (native_vehicle_create)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:04:00 +00:00
DetimesandClaude Sonnet 5.5 d4f22ed9db Native mods: vehicle destruction (native_vehicle_destroy)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:04:00 +00:00
DetimesandClaude Sonnet 5.5 1fce42234d Native mods: vehicle damage (native_vehicle_damage)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:04:00 +00:00
Ludovic Marie ce8d86cb23 Merge pull request #6323 from some-du6e/far-terrain/1-kept-chunks
🤖 Agent: queued at 1b9c56b67a. Thanks for answering the Bobby question by removing it; the stack is clean on licences now. Cost check: `lod.observe` runs only when `self.lod` exists, and LOD Distance defaults to 0, so a default client keeps nothing and pays nothing. When LOD is on, the summary and the region write happen on the far-terrain worker. I merged main: its new `forget_remote_columns` now goes through your `forget_remote_chunk`, so batch forgets are observed too. Clippy is clean and the 57 lod/server_terrain tests pass. One follow-up: 'nothing is pruned' means a player who visits many servers grows `server-terrain/` without bound, so a size cap or age-out would be worth an issue.
2026-11-02 10:03:25 +01:00
masa-masa-0314andGPT-6 a315017dd1 Merge upstream minecart water-state fixes and experimental redstone
Keep the experimental speed and rider rotation paths while adopting upstream cached water state and float-derived dimensions and drag.

Co-Authored-By: GPT-6 (ZCode) <[email protected]>
2026-11-02 17:03:10 +08:00
Ludovic Marie 0120978873 Merge remote-tracking branch 'origin/main' into perf/pacing-scenarios-2 2026-11-02 10:02:40 +01:00
Ludovic MarieandClaude Opus 5.5 b4d558b65e rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:02:40 +01:00
Ludovic MarieandClaude Opus 5.5 1b9c56b67a Merge main into #6323
remote.rs: main's forget_remote_columns now calls forget_remote_chunk, so
a dropped column is still observed for far terrain; client_lib.rs gets
server_terrain in main's module list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:02:17 +01:00
Ludovic Marie d359f7fd6f Merge pull request #6919 from HrafnaValkyrja/refactor/entity-walk-explosions
🤖 Agent: merged main now that #6734 has landed underneath; it merged without conflicts, so I requeued it.
2026-11-02 10:02:09 +01:00
Ludovic Marie 374f28afcf Merge pull request #6491 from HrafnaValkyrja/fix/fluid-push-vehicles
🤖 Agent: queued at 1bdee6af8d. Merged main: #6665 had added the same `in_water` field. I kept your `update_entity_fluids` call after the move, which both pushes the cart with the stream and sets the water state, plus main's doc comment, and replaced both helper functions with `c.in_water`. some-du6e's review is fully resolved; clippy is clean and the 28 minecart/boat tests pass.
2026-11-02 10:02:05 +01:00
MSedgeMC 3ba6ada729 Clear stale rest-007 row main already implements
Direction.getNearest is ported: Direction::approximate_nearest picks the axis
with the largest dot product over the six normals, seeding the best with
Float.MIN_VALUE and north as the fallback, the same as the float overload.
2026-11-02 09:01:34 +00:00
Ludovic MarieandClaude Opus 5.5 23827747cc Merge main into #6919
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:01:00 +01:00
DUzzL 1e66efcf06 Bed and anchor blasts push the ender dragon 2026-11-02 10:00:57 +01:00
Ludovic MarieandClaude Opus 5.5 1bdee6af8d Merge main into #6491
minecarts.rs: both branches added in_water; keeps this PR's
update_entity_fluids (stream push + water state) after the move, main's
doc comment, and drops both helper functions for c.in_water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:00:36 +01:00
HrafnaValkyrja a977d85017 Merge remote-tracking branch 'origin/main' into refactor/entity-walk-selectors 2026-11-02 04:00:23 -05:00
HrafnaValkyrja 0ee91d9097 Merge branch 'fix/pearl-test-drag' into refactor/entity-walk-selectors 2026-11-02 04:00:23 -05:00
Ludovic MarieandClaude Opus 5.5 cde41dabbf Work out item and block-model face normals once per pose
ItemRenderState::submit inverted the pose's 3x3 for every quad of a layer
(transform_normal); a dropped generated item has dozens of quads under one
pose. FaceNormals inverts it once and keeps each facing's normal: the same
arithmetic, so the same bits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:59:47 +01:00
Ludovic Marie f9aebd3b3e Merge remote-tracking branch 'origin/main' into perf/preload-command-registries 2026-11-02 09:59:16 +01:00
MSedgeMC df454e97ac port_map: clear the two stale classes-02 rows
PlayerHeadItem.getName was marked missing with a note saying Horizon shows
the plain item name. item_tooltip.rs::player_head_name reads the head's
minecraft:profile name and name_line titles it through
block.minecraft.player_head.named, so the row is a port.

ShearsItem.useOn was marked missing and unsure about SHEARS_TRIM.
game/growth.rs::Game::shears_crop is its port: a shears that hits a growing
plant head below age 25 sets GrowingPlantHeadBlock.getMaxAgeState, plays
block.growing_plant.crop, sends the block_change, wears the shears by one and
reports the use. Pumpkin carving stays PumpkinBlock's own path in
game/pumpkin.rs.
2026-11-02 08:58:40 +00:00
Ludovic Marie 5d65d7b50d Merge pull request #6734 from HrafnaValkyrja/refactor/entity-walk-plates
🤖 Agent: queued at c789d7bebf. `for_each_entity!` expands to plain per-slice loops, and `count_triggers` makes `LIVING` a const generic, so the per-tick plate check has no `dyn` and no allocation, and the 19-case side-by-side shows no change. I added `run-parity` so the plate/tripwire harnesses run on the merge.
2026-11-02 09:58:14 +01:00
Ludovic Marie 2006baf20a Merge pull request #6918 from levzzz5154/native/pearl-chorus-teleport
🤖 Agent: queued at ebb131c40c. Both hooks run only for a server's player (`lan_acting_uuid`), so mobs' and the in-frame player's teleports are untouched. A non-finite target keeps vanilla's spot, and chorus fruit clamps a mod's y into the level so the 16-try walk-down can't run away.
2026-11-02 09:58:10 +01:00
Ludovic Marie de87d9cd5a Merge pull request #6732 from HrafnaValkyrja/fix/items-on-slime
🤖 Agent: queued at 6ed4e7ef68. `vertical_collision_velocity` now follows `restituteMovementAfterCollisions`: it scales by the portion of the move made, and the drag uses the widened 0.98F rather than a flat 0.8 bounce. `slime_step_on` costs one block lookup per grounded item per tick, the same check vanilla's `stepOn` makes.
2026-11-02 09:58:05 +01:00
Ludovic Marie c6bbb06779 Merge pull request #6921 from GIGABAIT93/feat/wheel-cycles-setting
🤖 Agent: queued at c89c95255d. Defaulting `wheel_cycles_settings` to true keeps vanilla's `CycleButton.mouseScrolled`, and both wheel paths (Horizon's graphics list and the options screen) check it, each with a test that the list scrolls instead when it's off.
2026-11-02 09:58:01 +01:00
Ludovic Marie dd1840498d Merge pull request #6916 from HrafnaValkyrja/fix/pearl-test-drag
🤖 Agent: queued at 1098eb754d. The pearl test now expects `f64::from(0.99f32)` per tick, matching the widened throwable drag that landed with #6797; the old literal `0.99` only passed while the drag was a double.
2026-11-02 09:57:57 +01:00
Ludovic Marie fd309836ad Merge pull request #6915 from MSedgeMC/fix/item-not-precise
🤖 Agent: queued at 247d5d4317. Checked against 26.3: only HappyGhast, FallingBlockEntity and PrimedTnt ever call `setRequiresPrecisePosition`, so for an item it is always false, and #6882's `full_precision_required` is now the only route to the exact-position packet.
2026-11-02 09:57:53 +01:00
Arslan ArmanulyandClaude Opus 5.5 5a54774bf7 Shader packs: the standard macros Iris 1.11.7 defines
Observed with probe packs of our own in Iris 1.11.7 on 26.3 (each stripe of
the final pass shows whether a macro is defined, or its value in the
colour's bytes):

- MC_GL_VERSION and MC_GLSL_VERSION are 330 (the GL context's 3.3, whatever
  the pack's #version); Horizon said 460.
- IRIS_VERSION 11107 and IRIS_HAS_TRANSLUCENCY_SORTING (Horizon sorts
  translucent terrain as vanilla does).
- MC_MIPMAP_LEVEL follows the Mipmap Levels option: the renderer defines it
  with its mipmap levels.
- One GL vendor and renderer (OptiFine's shaders.txt, E and F): OTHER, since
  Horizon draws through wgpu, not a GL driver. Iris defines the driver's
  (MC_GL_VENDOR_NVIDIA here), but packs take driver extensions behind it
  (RenderPearl's GL_NV_gpu_shader5 int16 types), which Horizon has not.

Left as they are: IRIS_TAG_SUPPORT (block tags are not read yet) and
MAX_COLOR_BUFFERS (Iris says 32; Horizon has 16 colortex buffers).

Fixes #6881

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:57:24 +05:00
breachatoandClaude Opus 5.5 5350241318 new_hook_placeholder_is_reported_then_filled compares with LF line endings
The generator writes native_hooks.rs with LF, so on a Windows checkout
(core.autocrlf) the refilled file never equalled the CRLF original and
the test failed. Compare both with LF.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:57:12 +01:00
HrafnaValkyrja e943c00ece Merge remote-tracking branch 'origin/main' into refactor/entity-walk-portals 2026-11-02 03:55:16 -05:00
HrafnaValkyrja e66262accd Merge branch 'fix/pearl-test-drag' into refactor/entity-walk-portals 2026-11-02 03:55:16 -05:00
Ludovic Marie 6c951a6f51 Merge pull request #6786 from Domanffe/chore/port-map-ported-rows
🤖 Agent: merged main. All nine rows' target files and symbols exist on main.
2026-11-02 09:54:35 +01:00
Ludovic Marie f13c3e1665 Merge pull request #6772 from MSedgeMC/chore/ledger-render-verified
🤖 Agent: merged main. client2-001.tsv keeps main's post-effect rows (gfx.rs ports them now) with your world-icon rows, and `hasRenderedAllSections` now targets plain `game.rs::tick_world_icon` with the `drawn_sections > 10` detail in the notes. All 15 rows resolve on main.
2026-11-02 09:54:31 +01:00
DetimesandClaude Sonnet 5.5 995c8d1a88 Native mods: mod API entries for the second batch of wave-2 hooks
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:54:25 +00:00
breachatoandClaude Opus 5.5 eda5cea7db Native mods: an item frame or painting about to break, cancellable (native_hanging_break)
Asked where a guest's hit (attack_frame, Player.attack's painting
branch), a blast, or a lost wall would break a frame or painting,
before anything drops or sounds. A kept painting stays at its index, so
the blast and survival loops step past it.

Fixes #6697.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:53:53 +01:00
Ludovic MarieandClaude Opus 5.5 34b2a90efb Merge main into #6786
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:53:06 +01:00
DetimesandClaude Sonnet 5.5 87a95d16cd Fix useless i32 conversion in the recipe book settings event
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:53:02 +00:00
Ludovic MarieandClaude Opus 5.5 8697636e2b Port map: keep hasRenderedAllSections' target a plain path::symbol
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:52:44 +01:00
DetimesandClaude Sonnet 5.5 df67a4d5c0 Native mods: mob leash removed (native_entity_unleash)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:52:37 +00:00
DetimesandClaude Sonnet 5.5 f18e81c7d3 Native mods: mob about to drop an item (native_entity_drop_item)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:52:37 +00:00
DetimesandClaude Sonnet 5.5 193bd2c4dc Native mods: dropped item despawn (native_item_despawn)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:52:37 +00:00
DetimesandClaude Sonnet 5.5 71c339dff5 Native mods: explosion about to begin (native_explosion_prime)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:52:37 +00:00
DetimesandClaude Sonnet 5.5 a08041e393 Native mods: player launches projectiles (native_player_launch_projectile)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:52:37 +00:00
HrafnaValkyrja a1a973a444 Merge remote-tracking branch 'origin/main' into refactor/entity-walk-lightning 2026-11-02 03:52:23 -05:00
HrafnaValkyrjaandClaude Opus 5.5 7797b1d1a4 Format after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 03:52:20 -05:00
Ludovic MarieandClaude Opus 5.5 5a029e68b7 Merge main into #6772
client2-001.tsv: main's post-effect rows (now ported in gfx.rs) with this
PR's world-icon rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:51:54 +01:00
Akirabane b8a37a57c6 Merge origin/main 2026-11-02 09:51:42 +01:00
Ludovic Marie 1331c9ea3c Merge pull request #6640 from some-du6e/optifine/fusion-mesh
🤖 Agent: queued at b4330bef5c. Hot path: `Mesher::face` only asks `fusion.pick` when the face has a rule (`face.fusion` is None without a Fusion pack), so CTM-only and vanilla meshing pay one Option check. Building from the public wiki, since Fusion is All Rights Reserved, plus the MIT FusionExamples notice, keeps it clean. I merged main: mesher.rs now passes main's new `CtmAxes` orientation argument along with your shared `push`. horizon-optifine (134) and the fusion/connected/mesher tests (74) pass.
2026-11-02 09:51:15 +01:00
AkirabaneandClaude Sonnet 5.5 3ae5bccf37 Ender pearls, armor stands and falling blocks at the top of a bubble column send its surface particles
Entity.handleOnAboveBubbleColumn ends in sendBubbleColumnParticles, and
ThrownEnderpearl, ArmorStand and FallingBlockEntity all reach it. Each now
reports the column blocks it is at the top of, and the game draws them with
Game::bubble_column_particles, as it already does for mobs, items, arrows and
primed TNT.

Fixes #6908

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 09:51:14 +01:00
DUzzL 21f6f79a32 Baby villagers pitch their unhappy sound like vanilla 2026-11-02 09:50:10 +01:00
Ludovic Marie 78b0fe0ea4 Merge pull request #6909 from navinor/feat/mod-signs
🤖 Agent: merged main (#6612 had renamed the paths around the sign classifier and the renderer load). Kept your `sign()` constructor and `add_mod_signs`; clippy is clean and the 17 sign tests pass.
2026-11-02 09:50:05 +01:00
Ludovic MarieandClaude Opus 5.5 b4330bef5c Merge main into #6640
mesher.rs: main's CtmAxes orientation argument with this PR's shared overlay push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:48:48 +01:00
Ludovic MarieandClaude Opus 5.5 642d5e1f41 Merge main into #6909
Keeps the mod-sign constructor with main's paths after #6612.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:48:32 +01:00
arismoko 28cb0a5146 docs: fix the typed API from mixin code examples 2026-11-02 08:48:24 +00:00
HrafnaValkyrja ed05ba3f6c Merge remote-tracking branch 'origin/main' into refactor/entity-walk-lightning 2026-11-02 03:47:41 -05:00
HrafnaValkyrja 532827b148 Merge branch 'fix/pearl-test-drag' into refactor/entity-walk-lightning 2026-11-02 03:47:40 -05:00
Ludovic Marie be95d45958 Merge pull request #6899 from ClickingMaster/fix/cmds-parity
🤖 Agent: merged main to pick up #6894's `test_world_input_capture` stub (the red verify was that test, which this branch predates), then requeued.
2026-11-02 09:46:41 +01:00
Ludovic Marie a8c02eac4b Merge pull request #6900 from senjinthedragon/hdr-sky
🤖 Agent: merged main to pick up #6894's `test_world_input_capture` stub (the red verify was that test, which this branch predates), then requeued.
2026-11-02 09:46:37 +01:00
Ludovic Marie 519661b583 Merge pull request #6913 from MSedgeMC/fix/tnt-explosion-power
🤖 Agent: merged main to pick up #6894's `test_world_input_capture` stub (the red verify was that test, which this branch predates), then requeued.
2026-11-02 09:46:33 +01:00
Ludovic Marie 7ec94e034c Merge pull request #6904 from senjinthedragon/roadmap-hdr
🤖 Agent: merged main to pick up #6894's `test_world_input_capture` stub (the red verify was that test, which this branch predates), then requeued.
2026-11-02 09:46:29 +01:00
Ludovic MarieandClaude Opus 5.5 98bbcccf99 Merge main into #6899
Picks up #6894's fixture stub for test_world_input_capture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:45:03 +01:00
Ludovic MarieandClaude Opus 5.5 6c15677f21 Merge main into #6900
Picks up #6894's fixture stub for test_world_input_capture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:44:46 +01:00
Ludovic MarieandClaude Opus 5.5 3bf2089385 Merge main into #6913
Picks up #6894's fixture stub for test_world_input_capture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:44:25 +01:00
Ludovic Marie 3d26e92d44 Merge pull request #6906 from masa-masa-0314/feat/redstone-experiments
🤖 Agent: queued at c66d16827c. The experimental wire path only runs with `minecraft:redstone_experiments` in the level's enabled features, and the default path gains just an `Option<Orientation>` that stays None. Since this changes `neighbor_changed` and the wire updates everyone goes through, I added `run-parity` so the redstone harnesses check the default behaviour before it lands. A harness for the experimental mode itself would be a good follow-up.
2026-11-02 09:44:19 +01:00
Ludovic Marie 8b631b958d Merge pull request #6914 from levzzz5154/native/trade-with-villager
🤖 Agent: queued at 60191c6b70. `Event::Traded` now carries the offer as it stood at that trade, so a shift-click buying three in a row gives each event its own `uses` (your test shows 1, 2, then 1 on the other offer). The hook fires only on the server, inside `lan_acting`.
2026-11-02 09:44:15 +01:00
MSedgeMC 934e3638d2 Give the TNT test fixtures the new power field
The four PrimedTnt literals in the module's tests need it now that the struct carries
explosion_power.
2026-11-02 08:44:11 +00:00
Ludovic MarieandClaude Opus 5.5 c062d62c61 Merge main into #6904
Picks up #6894's fixture stub for test_world_input_capture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:44:05 +01:00
arismoko cda58134c6 Format attachment existence reads 2026-11-02 08:43:44 +00:00
HrafnaValkyrja 44dce559b1 Merge remote-tracking branch 'origin/main' into refactor/entity-walk-wind 2026-11-02 03:43:19 -05:00
HrafnaValkyrja 8ea68796ef Merge branch 'fix/pearl-test-drag' into refactor/entity-walk-wind 2026-11-02 03:43:18 -05:00
levzzzandClaude Opus 5.5 ebb131c40c Native teleport hooks: ignore non-finite targets, keep chorus y in the level
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:42:53 +05:00
Ludovic Marie 769a7f3681 Merge pull request #6612 from elythepreciouscat/perf/static-block-entity-batching
🤖 Agent: queued at 04ada3a4c3. Good perf work; the chest-wall numbers line up with dropping the per-frame vertex rebuild. One change: the PR had moved block entities off Entity Culling's occlusion pass for every setting, so with culling on and every model cache off (the default), a chest behind a wall was drawn again. That case now keeps the old per-block `cull_entity_vertices` path (`CacheOptions::any_model_cache` is pub for it). I also merged main; clippy is clean and the 39 block-entity tests pass.
2026-11-02 09:42:28 +01:00
breachatoandClaude Opus 5.5 77613e9250 A shift-click during a drag on a mod menu type only ends the drag
On a mod's own menu type the drag lives in the connection's machine menu,
not in the hub menu's drag, so lan_mod_quick_move passed the click to the
mods. Leave it to lan_machine_click, which resets the quick craft as
AbstractContainerMenu.doClick does.

Fixes #6907.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:42:22 +01:00
Ludovic Marie f8588a4196 Merge pull request #6416 from jaszkus/feat/launcher-windows-setup
🤖 Agent: queued at 76eee70652. Checked the uninstaller: it deletes only `horizon-launcher.exe` and its update leftovers from the install folder, and the opt-in 'Also delete the launcher's data' runs only when the data folder is the launcher's own `horizon-launcher` (a custom `HORIZON_LAUNCHER_HOME` is listed, not deleted). The game's saves (`game_home`) are never touched. I merged main's lang files; Mac clippy is clean, and CI's Windows job builds the `cfg(windows)` parts.
2026-11-02 09:41:03 +01:00
Ludovic Marie e835f01a74 Merge pull request #6665 from apeirs/fix/minecart-float-drag
🤖 Agent: queued at 44ee33c861. Requeued after the LAND-STOP: #6820's new `checkFallDamage` code still called the removed `cart_in_water`. It now reads `c.in_water`, which is right because vanilla's `isInWater` there is `wasTouchingWater` from the tick before. The 12 minecart tests pass.
2026-11-02 09:40:58 +01:00
HrafnaValkyrja d6622bf9e5 Merge remote-tracking branch 'origin/main' into refactor/entity-walk-explosions 2026-11-02 03:40:57 -05:00
HrafnaValkyrja 74f7d8c82a Merge branch 'fix/pearl-test-drag' into refactor/entity-walk-explosions 2026-11-02 03:40:56 -05:00
DetimesandClaude Sonnet 5.5 db78bf77e0 Native mods: mod API entries for the wave-2 hooks
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:40:36 +00:00
MSedgeMC 247d5d4317 Keep a dropped item's move a delta when only its data changed
ServerEntity.getRequiresPrecisePosition is false for an ItemEntity, so needing its data sent
again must not force an exact-position packet; Horizon set precise from needs_sync, which sent
every re-synced drop as EntityPositionSync.
2026-11-02 08:40:34 +00:00
HrafnaValkyrjaandClaude Opus 5.5 1098eb754d Pearl test: expect the air drag as a widened float
ab0afa317 made the throwable air drag the float 0.99 widened to a double, and
this test skips without game data, so it kept the double and went unseen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 03:40:31 -05:00
Detimes c96c1819cf Merge remote-tracking branch 'upstream/main' into wave2-tier1 2026-11-02 08:39:12 +00:00
DetimesandClaude Sonnet 5.5 22d7022f94 Native mods: world border size change finish (native_world_border_bounds_change_finish)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 43d5a85a59 Native mods: vault loot (native_vault_loot)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 5107e8d666 Native mods: hopper inventory search (native_hopper_inventory_search)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 0e4f11045c Native mods: hopper inventory move (native_inventory_move_item)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 35e0278e74 Native mods: hopper transfer attempt (native_prepare_hopper_transfer)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 3f2066e153 Native mods: block damage abort (native_block_damage_abort)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 41a4cfcef1 Native mods: player bundle item select (native_player_bundle_item_select)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 6f0f784634 Native mods: player ready arrow (native_player_ready_arrow)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 a54009b829 Native mods: advancement criterion grant (native_player_advancement_criterion_grant)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 bfa7c8ab39 Native mods: player item break (native_player_item_break)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 1f8b1e9a92 Native mods: recipe book settings change (native_player_recipe_book_settings_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 fbef07f9b2 Native mods: stop using item (native_player_stop_using_item)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
DetimesandClaude Sonnet 5.5 16c0286bb5 Native mods: client loaded world (native_player_client_loaded_world)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 08:39:03 +00:00
Ludovic Marie 57ddd46c32 Merge pull request #6682 from xMelonekMaX/fix/dialog-text-clicks
🤖 Agent: queued at 42165d4d45. Merged main: server_e2e.py now registers both your `dialog_text` check and main's `book_menu` in one `CHECKS` dict (the merge had left two assignments, so the second would have dropped yours). The 2 dialog lib tests pass.
2026-11-02 09:38:53 +01:00
Ludovic Marie ccd2827a4c Merge pull request #6693 from gigamans442/fix/shader-integer-clamp-20261101
🤖 Agent: queued at 83e7424576. Merged main: `compile()` now runs main's `image_parameters` rewrite first, then `integer_bounds`. The rewrite only casts when every declaration of that name in the stage is `int` and both bounds are unsigned literals, so an ambiguous name is left alone.
2026-11-02 09:38:48 +01:00
Ludovic Marie 5a592a82c1 Merge pull request #6690 from gigamans442/fix/shader-lifecycle-20261101
🤖 Agent: queued at cfbd424754. Merged main, keeping both new test modules in post.rs; the 133 horizon-shaders tests pass. `setup_pending` reruns `setup` only at load and after a resize, and the shadow pass now waits for `begin`, which is the lifecycle order the format describes.
2026-11-02 09:38:43 +01:00
Ludovic MarieandClaude Opus 5.5 76eee70652 Merge main into #6416
Lang files: main's entries plus the keys this PR adds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:38:41 +01:00
Ludovic Marie dda38dff90 Merge pull request #6761 from Vaspyyy/perf/startup-trace-world-fill
🤖 Agent: queued at b1feecff54. Merged main: main now reads the recipe folder once for the whole sync, so I gave that read its own `sync.recipe_folder` span ahead of `sync.registries` (otherwise the registries step would have absorbed it). `test_startup_trace` and the 8 startup tests pass.
2026-11-02 09:38:39 +01:00
arismoko dc14ce3c6e Move the trait-mod testing section before Adding a hook 2026-11-02 08:38:18 +00:00
levzzzandClaude Opus 5.5 2c8033a994 Native mods: ender pearl and chorus fruit teleports (cancel, retarget)
NeoForge's EntityTeleportEvent.EnderPearl and .ChorusFruit for server
players: native_ender_pearl_teleport may move the landing, change the
5 fall damage or cancel (no endermite roll, teleport, damage or sound);
native_chorus_fruit_teleport runs before each of the up to 16 attempts,
may move the spot or end the effect. TeleportConsumeWorld gains a
default teleport_allowed so other users (foxes, the parity probe) keep
vanilla's draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:38:18 +05:00
levzzzandClaude Opus 5.5 60191c6b70 Native trade hook: each trade's own offer snapshot; xtask stub; docs
Event::Traded now carries the offer as notifyTrade left it, so a
shift-click whose trades level the villager up reports each trade's own
prices. xtask's mod API check gets a MerchantOffer stand-in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:37:30 +05:00
MSedgeMC 806d5935e5 Explode a primed TNT with its own explosion_power
PrimedTnt reads explosion_power (4 by default, kept to 0..128) and explodes with it;
Horizon always used 4.0, so a summoned TNT with explosion_power:10 hit like ordinary TNT.
2026-11-02 08:37:13 +00:00
Ludovic Marie 365f130876 Merge pull request #6780 from DUzzL/fix/6610-tnt-arrow-credit
🤖 Agent: queued at bd366fac94. Merged main: main's `tnt_prime_allowed` check now wraps your owner-credited `spawn_primed_tnt`. `tnt_blast_owner` returns None once the mob is gone, like `PrimedTnt.getOwner` after the owner leaves the level, so a dead skeleton's TNT kill does not name it.
2026-11-02 09:37:12 +01:00
masa-masa-0314andGPT-6 2a36c8144b Tests: include carts in the remote motion fixture
Keep the existing squid animation assertions while allowing the extracted shared velocity handler to access its minecart list.

Co-Authored-By: GPT-6 (ZCode) <[email protected]>
2026-11-02 16:37:04 +08:00
arismoko 292df3c604 Format the play_sound delegation 2026-11-02 08:36:43 +00:00
Ludovic Marie e0bafed170 Merge pull request #6905 from horizon-rust/perf/armor-stands-gpu
🤖 Agent: armor stands posed on the GPU path (bit-exact test over the stand models), item sprite rect cached per quad run; stress_armor_stands 148 → 216 fps; verify passed.
2026-11-02 09:36:05 +01:00
Ludovic Marie 74ebb5bc2f Merge pull request #6638 from wxundup/feat/native-build-log-details
🤖 Agent: queued at 03379ce30f. Merged main (#6637 landed underneath) and kept main's lang entries plus the nine new log-panel keys in each file. Launcher clippy is clean and the tests pass; `prepared_versions_get_what_the_asset_store_adds_without_preparing_again` failed once in the full run and passes alone, and this PR does not touch backend.rs.
2026-11-02 09:34:57 +01:00
Ludovic MarieandClaude Opus 5.5 04ada3a4c3 Merge main into #6612
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:34:33 +01:00
Ludovic MarieandClaude Opus 5.5 e56cf5c261 Entity Culling still culls block entities when no model cache is on
The per-section caches skip the per-vertex occlusion pass, but with every
cache off a chest behind a wall was drawn again with culling enabled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:34:33 +01:00
arismoko 607bb4bfe1 Regenerate the mod API 2026-11-02 08:33:58 +00:00
arismoko 653d32aebb Keep synced attachments on the client 2026-11-02 08:33:24 +00:00
arismoko 6a5380383f Regenerate the mod API 2026-11-02 08:33:07 +00:00
Ludovic MarieandClaude Opus 5.5 9ee964b459 Preload the command registries' resource caches: the Join click's 45 ms frame
The first world opened or server joined built build_registries' caches in its frame:
resource_ids (thousands of recipe and loot table files, 7.7 ms on an M4 Pro), the block
properties (6.1 ms) and command-registries.json (4.9 ms). Joining a server froze one frame
for 40-50 ms. A helper thread fills them once the game has started (after the mods, whose
templates it lists); whoever needs one first waits for it, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:32:43 +01:00
Ludovic Marie 2b1786d143 Merge pull request #6631 from Ragbie/fix/armor-stand-turtle-eggs
🤖 Agent: queued at 34427987a3. `armor_stand_turtle_eggs` follows `TurtleEggBlock.stepOn`/`fallOn`'s order (egg, then mob_griefing, then `nextInt(chance)`), so the server's random is drawn only on an egg, and it returns early for a stand in the air or on a client. I merged main for the stale mod_api output; the 10 armor_stand tests pass.
2026-11-02 09:32:00 +01:00
Ludovic MarieandClaude Opus 5.5 03379ce30f Merge main into #6638
Lang files: main's entries plus the keys this PR adds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:31:56 +01:00
arismoko a7372a5028 Regenerate the mod API 2026-11-02 08:31:34 +00:00
Ludovic MarieandClaude Opus 5.5 44ee33c861 Fall damage reads the cart's last-tick water state
Main's checkFallDamage called the removed cart_in_water; isInWater is
wasTouchingWater, which this PR keeps as in_water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:31:17 +01:00
Ludovic MarieandClaude Opus 5.5 e4a8f9ebd3 Merge main into #6665
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:30:49 +01:00
Ludovic MarieandClaude Opus 5.5 42165d4d45 Merge main into #6682
server_e2e.py: both dialog_text and main's book_menu checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:30:11 +01:00
GIGABAIT93andClaude Opus 5.5 c89c95255d Merge upstream main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 10:29:56 +02:00
Ludovic MarieandClaude Opus 5.5 83e7424576 Merge main into #6693
compile() runs main's image_parameters rewrite first, then integer_bounds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:29:26 +01:00
Ludovic MarieandClaude Opus 5.5 cfbd424754 Merge main into #6690
post.rs: both new test modules kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:28:48 +01:00
Ludovic Marie b4e130b02a Merge pull request #6716 from ClickingMaster/feat/mods-sort-dropdown
🤖 Agent: queued at 18d256d935. The registry sort orders by the earliest or latest `published` day with downloads as the tie-break, and 'Sort by: {order}' is translated in all ten lang files. I merged main and kept main's entries plus your one new key per file.
2026-11-02 09:28:46 +01:00
HrafnaValkyrja 879efa1272 Merge remote-tracking branch 'origin/main' into refactor/entity-walk-explosions 2026-11-02 03:28:35 -05:00
Ludovic Marie a168dea4d8 Merge pull request #6886 from MSedgeMC/fix/playsound-range-squared
🤖 Agent: queued at 41f880696f. Merged main: #6871 had added its own `mod tests` to world_cmds.rs, so both tests now share one module. `playsound_placement` plus `look_at`'s tests pass (3 total).
2026-11-02 09:28:26 +01:00
masa-masa-0314andGPT-6 165ba4d162 Merge upstream main and retain feature-aware command packets
Co-Authored-By: GPT-6 (ZCode) <[email protected]>
2026-11-02 16:28:21 +08:00
Ludovic MarieandClaude Opus 5.5 b1feecff54 Merge main into #6761
The shared recipe-folder read gets its own span, ahead of sync.registries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:28:20 +01:00
Ludovic MarieandClaude Opus 5.5 bd366fac94 Merge main into #6780
Keeps main's tnt_prime_allowed check around the owner-credited priming.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:28:07 +01:00
arismoko e409dcdedd Fix player_save_to_file docs: guests not heard on integrated autosaves 2026-11-02 08:27:38 +00:00
arismoko 500fd70a2f Send synced attachments from the server 2026-11-02 08:27:08 +00:00
masa-masa-0314andGPT-6 555e5e33ca Minecarts: connect experimental speed rules, track motion and rider rotation
Register the feature-gated max_minecart_speed rule through commands, network trees, persistence and world creation. Correct experimental track iteration and emit weighted motion steps with pitch. Connect the rotateWithMinecart accessibility option to vanilla's smoothed rider yaw behavior.

Co-Authored-By: GPT-6 (ZCode) <[email protected]>
2026-11-02 16:26:49 +08:00
Ludovic MarieandClaude Opus 5.5 41f880696f Merge main into #6886
world_cmds.rs: main's look_at (#6871) with this PR's playsound_placement,
both tests in one tests module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:26:28 +01:00
Ludovic Marie 18d256d935 Merge main into #6716
Lang files: main's entries plus the keys this PR adds.

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

# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/ja.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/tr.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-02 09:25:59 +01:00
Ludovic Marie f558e467eb Merge pull request #6882 from MSedgeMC/fix/item-precision-encoding
🤖 Agent: queued at 6315a38687. `full_precision_required` matches 26.3's `isFullPrecisionEncodingRequired`, including the swapped axes for `horizontalCollision`. I made the flag use `Mth.equal`'s 1e-5 tolerance on x and z, as `Entity.move` does (it was an exact compare), so float noise on a free-moving item no longer forces full-precision syncs.
2026-11-02 09:25:54 +01:00
Ludovic MarieandClaude Opus 5.5 20f847e5ff Merge main into #6665
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:25:19 +01:00
arismoko 04e36b1c1a Add synced attachment data model and wire format 2026-11-02 08:24:59 +00:00
Ludovic MarieandClaude Opus 5.5 34427987a3 Merge main into #6631
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:24:49 +01:00
Ludovic Marie e6e008dffb Merge pull request #6888 from DUzzL/fix/6573-fishing-hook-entities
🤖 Agent: queued at a8618bd7ed. Merged main for #6894's fixture stub (the red verify was main's `test_native_split_mouse_turns_only_its_owner`, not this PR). The `!a.marker` check is what `ArmorStand.isPickable` does.
2026-11-02 09:24:43 +01:00
Ludovic Marie 65dd3126e7 Merge pull request #6875 from arslanarm/shaderpacks/interface-block-qualifiers
🤖 Agent: queued at 4a8e739a6a. Merged main, keeping both new translate tests (#6784's `functions_reaching_their_end_return_zero` and yours); the 132 horizon-shaders tests pass. The point from my first review still applies as a follow-up: a block with an instance in one stage and none in the other will not link.
2026-11-02 09:24:38 +01:00
Ludovic MarieandClaude Opus 5.5 6315a38687 horizontalCollision uses Mth.equal's tolerance, as Entity.move does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:23:05 +01:00
Ludovic Marie ce018133ee Merge pull request #6884 from levzzz5154/native/menu-quick-move
🤖 Agent: queued at dd98a7bfa0. `lan_mod_quick_move` returns before `as_guest` unless the block is a `BlockEntity::Mod`, so vanilla containers keep their path untouched. If no mod takes the click, `settle` restores `entity.items` from the snapshot, so nothing is moved twice.
2026-11-02 09:23:00 +01:00
Ludovic MarieandClaude Opus 5.5 4a8e739a6a Merge main into #6875
Keeps both new translate tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:22:42 +01:00
arismoko 41074f04df docs: the typed API from mixin code 2026-11-02 08:22:36 +00:00
Ludovic MarieandClaude Opus 5.5 a8618bd7ed Merge main into #6888
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:21:21 +01:00
Ludovic Marie 974f247152 Merge pull request #6880 from Teiwazik/shader/item-light-colors
🤖 Agent: queued at 1c223ab049. Hot path: with dynamic lights on, `brighter` cloned a `String` for every held item (a zombie's sword too) twice per frame, and the coloured-light pass ran without any native pack. Now only lit sources name their item, and `native_frame_visuals` checks `has_light_colors()` first, so packs without `[[lights]]` pay nothing.
2026-11-02 09:20:56 +01:00
Ludovic Marie 438ea3b58a Merge pull request #6873 from MSedgeMC/fix/menu-tooltip
🤖 Agent: queued at 6c8f7a1ede. Checked against the 26.3 bytecode: `getOffset` passes `distance / widgetHeight` to `Mth.lerp` as an int division, so the offset is `height - 3` until the pointer is a whole height away, then 5. I made `widget_offset` divide as ints and set the test to vanilla's positions (120 and 170, not 118 and 180). I also added the whole-height case.
2026-11-02 09:20:52 +01:00
Ludovic Marie de5ca143a5 Merge pull request #6897 from levzzz5154/native/sleep-finished-time
🤖 Agent: queued at 0c646c7f8e. The hook fires once per night skip, on the server's own day time, and the `asked >= t` check after every mod keeps `setTimeAddition`'s refusal of a time in the past, so a mod cannot rewind the clock. With no mods it is an empty loop.
2026-11-02 09:20:48 +01:00
arismoko e16069360a Document attachment has and get_existing and add their API fragment 2026-11-02 08:19:59 +00:00
arismoko 568e7e55f6 Add attachment reads that never insert the default 2026-11-02 08:19:37 +00:00
Ludovic MarieandClaude Opus 5.5 1c223ab049 Coloured item lights cost nothing without a pack that colours lights
Only a lit source names its item, and the frame's coloured-light pass runs
only when the native pack has [[lights]] entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:19:30 +01:00
Ludovic MarieandClaude Opus 5.5 6c8f7a1ede getOffset divides the distance by the height as ints, as vanilla does
MenuTooltipPositioner.getOffset passes `distance / widgetHeight` (two ints)
to Mth.lerp, so the offset is height - 3 until the pointer is a whole
height away, then 5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:19:14 +01:00
Ludovic Marie f4f27813c0 Merge pull request #6874 from arslanarm/shaderpacks/normal-specular-map-macros
🤖 Agent: queued at 4b4db8c36d. Defining `MC_NORMAL_MAP`/`MC_SPECULAR_MAP` is safe without a resource pack's maps: pack_post binds `dummy_normal` and `dummy_black` to the `normals`/`specular` samplers, so those branches read a flat normal and no specular instead of garbage.
2026-11-02 09:19:14 +01:00
levzzzandClaude Opus 5.5 0dd9865328 Native mods: hear each villager trade (native_trade_with_villager)
NeoForge's TradeWithVillagerEvent: once per trade a server player makes
with a villager or wandering trader, after the statistic and the
villager_trade trigger, with the trader's id, its type and the offer as
it was right after that trade (a shift-click's trades each see their own
use count). Listen only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:18:40 +05:00
HrafnaValkyrja 21b256b2d2 Merge remote-tracking branch 'origin/main' into fix/fluid-push-vehicles 2026-11-02 03:18:01 -05:00
arismoko 31c6f2f549 Keep the player file path a pure builder and create the directory once per save loop 2026-11-02 08:17:37 +00:00
arismoko 6f8cecd0db Name piglins in the getProjectile docs, skip the crossbow end sound when a mod empties the ammunition, reuse profile helpers 2026-11-02 08:17:30 +00:00
arismoko d0c6715e0b Document value-changing trait injections 2026-11-02 08:17:16 +00:00
Ludovic Marie e85933607d Merge pull request #6871 from MSedgeMC/fix/look-at-wrap
🤖 Agent: queued at 4cbcd2e4fc. `look_at` now matches 26.3's `Entity.lookAt` bytecode: the yaw is `wrapDegrees((float)(atan2 * 57.2957763671875) - 90f)`. I swapped `to_degrees()` for that float constant and wrapped the pitch the same way. `tp … facing` and `/rotate … facing` now share it.
2026-11-02 09:16:55 +01:00
arismoko 760dbcc535 Test value-changing trait injections 2026-11-02 08:16:50 +00:00
arismoko 8c7a49575f Add play_sound and send_particles to LevelWriteHost 2026-11-02 08:16:12 +00:00
arismoko 5780b6c940 Let trait_method injections return a changed value 2026-11-02 08:16:08 +00:00
levzzz 0c646c7f8e Merge branch 'main' into native/sleep-finished-time 2026-11-02 13:16:06 +05:00
levzzz dd98a7bfa0 Merge branch 'main' into native/menu-quick-move 2026-11-02 13:16:01 +05:00
levzzz 0141d16433 Merge pull request #6894 from levzzz5154/fix/input-capture-fixture-trace
test_world_input_capture: stub the input trace in the native mouse fixture
2026-11-02 13:15:51 +05:00
Ludovic Marie 28930fce9b Merge pull request #6878 from MSedgeMC/fix/jukebox-placed-record
🤖 Agent: queued at 32b3543820. `carries_record` reads the item's `block_entity_data` key rather than the block entity just filled, the same order as vanilla's `setPlacedBy` after `updateCustomBlockEntityTag`. Creating the empty `Jukebox` in `for_block` also means a placed jukebox saves its (empty) block entity, as vanilla does.
2026-11-02 09:15:43 +01:00
arismoko 1cd6ba0960 mod API: expose on, mod_key, settings, set_block_property and config_dir in Host 2026-11-02 08:15:40 +00:00
Ludovic MarieandClaude Opus 5.5 4cbcd2e4fc Use lookAt's float degree constant, and wrap the pitch as vanilla does
Entity.lookAt multiplies by 57.2957763671875 rather than Math.toDegrees.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:15:38 +01:00
arismoko df7afc32e9 xtask: let Host expose receiverless helpers as &self methods 2026-11-02 08:15:11 +00:00
arismoko 57693fbf63 Test a trait mod's hook with a partial Host double 2026-11-02 08:15:00 +00:00
arismoko 7906dcd4f4 Give every mod API Host method a default body that panics with its name 2026-11-02 08:14:30 +00:00
SenjinandClaude Opus 5.5 f92d8ea0bb Roadmap: HDR monitors shipped in v0.2.74
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:13:16 +01:00
Ludovic MarieandClaude Opus 5.5 c8d486ca9e Pose armor stands on the GPU path, and cut per-item lookups
Armor stands drew their body, its emissive overlay, four armour layers and
their trims through mobs::render::draw on the CPU every frame. They now go
through mobs::render::put, so the GPU path (#6757) poses them when it runs;
held items and wings stay on the CPU. The models become shared (Arc) like
the mobs' so the store keeps one copy, the frame's skin setup moves to the
start of entity_geometry (mob_geometry no longer owns it), and culling of a
GPU-posed entity is shared (cull_skinned).

Two lookups ran per quad or per layer: the item renderer looked up each
quad's sprite rect by name (SipHash) though an item's quads share a sprite,
and each armour layer built a new pose map. The rect is cached like the
translucency already was; one pose map serves every layer.

A test poses the stand models (normal and small, main and four armour
layers, turned head and arm, hidden parts, the glint) both ways and
compares the vertices to the bit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:12:50 +01:00
Ludovic Marie cccbb1d39f Merge pull request #6898 from horizon-rust/perf/bulk-remove-columns
🤖 Agent: verified A/B on the Mac (RD 32 teleport render-thread CPU max 14-17 → 7-10 ms); the per-key removal path is unchanged for small drops and whenever far terrain fades sections, so walking and LOD behaviour stay as on main.
2026-11-02 09:12:31 +01:00
HrafnaValkyrjaandClaude Opus 5.5 285babf9c2 Port map: bubble columns reach carts now; note the new-minecart order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 03:12:08 -05:00
ClickingMaster 82fef33c48 Merge remote-tracking branch 'origin/main' into fix/cmds-parity 2026-11-02 00:11:50 -08:00
ClickingMasterandClaude Sonnet 5.5 aeaf04a44e Format command numbers like Java's %.Nf (half-up on shortest decimal)
worldborder, spreadplayers, debug stop and tp printed numbers with Rust
{:.N} (exact value, half-even); vanilla uses String.format, which rounds
the shortest decimal text half-up. Route them through java_text::format_fixed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:11:48 -08:00
arismoko e9a18f8fbe Let every mod read and change getProjectile's ammo, start mob calls from the held projectile, fire mob crossbow rockets 2026-11-02 08:10:39 +00:00
Ludovic Marie cd091784aa Merge remote-tracking branch 'origin/main' into perf/pacing-scenarios-2 2026-11-02 09:10:32 +01:00
Ludovic Marie ea55997570 Merge pull request #6797 from MSedgeMC/fix/splash-potion-summon
🤖 Agent: queued at 6638d28c2d. Merged main: the landing path keeps your `PotionOwner` plus main's `native_potion_splash`. Since summoned potions now land through `ProjectileKind::Potion` in creatures.rs, I call the hook there too (except for experience bottles), so mods see both paths. `a_summoned_potion_flies_as_the_item_its_compound_names` and the 17 projectile tests pass.
2026-11-02 09:10:23 +01:00
Ludovic MarieandClaude Opus 5.5 6638d28c2d Projectile potions call native_potion_splash too
A summoned splash or lingering potion now lands through the projectile
path, so mods hear it the same as a thrown one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:08:56 +01:00
Ludovic MarieandClaude Opus 5.5 99cf9591ad Merge main into #6797
Keeps the PR's PotionOwner and main's native_potion_splash hook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:08:56 +01:00
Alexander Krylov f7a4f29418 Merge origin/main into chore/port-map-ported-rows 2026-11-02 11:08:55 +03:00
HrafnaValkyrjaandClaude Sonnet 5.5 858384befb Carts: reset fall distance in water and bubble columns, push carts before the fluid push; boats: refreshed status counts as known
AbstractMinecart.tick runs pushAndPickupEntities before updateFluidInteraction,
so the cart push now runs inside tick_cart ahead of the stream push.
updateFluidInteraction and onInsideBubbleColumn both reset the fall distance.
refresh_boat_status sets status_known, as vanilla's oldStatus = status runs on
every boat.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 03:08:35 -05:00
arismoko a615551701 Leave the deprecated in-frame menu path out of the anvil craft hooks 2026-11-02 08:08:05 +00:00
Senjin e7cddee501 Merge remote-tracking branch 'origin/main' into hdr-sky
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-11-02 09:07:14 +01:00
Ludovic Marie cabe8b5de7 Merge pull request #6800 from JustKarkat/fix/native-mod-max-stack-size
🤖 Agent: queued at 9ed22e8d8a. Requeued after the LAND-STOP: main's `loot_function`/`loot_condition` were missing from `mod_sizes_are_sanitised`'s `NativeLoadHooks`, so I added them as no-ops. The 1..=i32::MAX clamp applies only to a mod's value; data packs keep vanilla's 1..99.
2026-11-02 09:06:42 +01:00
arismoko a9f3a84399 Document the save hook's directory too 2026-11-02 08:06:08 +00:00
arismoko b95349183e Document that the player directory exists for the file hooks 2026-11-02 08:06:02 +00:00
arismoko d311c4d882 Create the player directory before the player file hooks run 2026-11-02 08:05:51 +00:00
arismoko c65ee1b3c6 Fix advancement progress and stat award hook review findings 2026-11-02 08:05:25 +00:00
Ludovic Marie 74511fa09a Merge pull request #6571 from Qwerner88880/hooks-build-warnings
🤖 Agent: queued at d78433b072. Warnings go only to the native-mods build log, and conflict grouping keys on (site, kind, selector, ordinal), so HEAD and RETURN on one function are reported once, separately. I merged main for the stale mod_api output. Launcher clippy is clean and the 77 native_mods tests pass.
2026-11-02 09:04:39 +01:00
Ludovic MarieandClaude Opus 5.5 9ed22e8d8a Fill main's two new loot hooks in the stack-size test's NativeLoadHooks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:03:50 +01:00
Senjin 07cdeb150c Merge branch 'hdr-clouds' into hdr-sky 2026-11-02 09:03:29 +01:00
arismoko c848175bed Apply LivingGetProjectileEvent to mob crossbows, use the bow's hand, and the client's own profile 2026-11-02 08:03:18 +00:00
levzzzandClaude Opus 5.5 4397ada8e0 test_world_input_capture: stub the input trace in the native mouse fixture
Game::device_event now records each grabbed motion with note_input
(#6712), so the fixture that compiles the method on its own failed to
build on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:02:28 +05:00
Ludovic MarieandClaude Opus 5.5 a6b2255754 Merge main into #6800
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:02:14 +01:00
Ludovic Marie 0bde939b10 Merge pull request #6641 from fy2ne/feat/6565-native-screen-own-background
🤖 Agent: queued at 22c453af45. `own_background` only skips the plain panel and slot frames; `native_screen_draw` still runs, and the default (false) leaves existing screens unchanged. I merged main (stale mod_api output) and dropped the defaults-only test.
2026-11-02 09:01:36 +01:00
Ludovic MarieandClaude Opus 5.5 d78433b072 Merge main into #6571
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:00:59 +01:00
GPT-6 / ZCode c66d16827c Merge upstream/main into feat/redstone-experiments
Preserve upstream hopper pickup hooks alongside the experimental redstone feature accessor.

Co-Authored-By: GPT-6 / ZCode <[email protected]>
2026-11-02 16:00:56 +08:00
arismoko 7c2cc943ae Make the anvil craft hooks server-side and keep the station early return 2026-11-02 08:00:24 +00:00
levzzzandClaude Opus 5.5 52a4b747f5 Native sleep hook: document the after-all-mods check and the weather reset
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 13:00:07 +05:00
GIGABAIT93andClaude Opus 5.5 2113057446 Extras: the scroll wheel can leave settings alone
"Scroll wheel changes settings" (wheelCyclesSettings, on by default as
vanilla's CycleButton.mouseScrolled). Off, the wheel only scrolls the list
on every settings page, Video's (GraphicsUi) included, and a keyboard-
focused option doesn't take it either. Closes #5455.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:59:39 +02:00
Ludovic MarieandClaude Opus 5.5 22c453af45 Drop a test that only checked the struct's defaults
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:59:18 +01:00
GPT-6 / ZCode ba04314c12 Redstone: implement the experimental wire evaluator and oriented updates
Gate experimental propagation on the saved world feature flag and preserve the default redstone evaluator. Port vanilla 26.3 orientation transforms, queued power changes and oriented neighbor notifications.

Co-Authored-By: GPT-6 / ZCode <[email protected]>
2026-11-02 15:59:13 +08:00
levzzzandClaude Opus 5.5 a97ef0aaa4 Native mods: the time players wake at after sleeping (native_sleep_finished_time)
NeoForge's SleepFinishedTimeEvent at vanilla's ServerLevel.tick point, where
enough players have slept and advance_time moves the clock to the morning:
a mod may set another wake-up time; one before the current time is refused,
as setTimeAddition refuses it. native_time_skip then hears the jump to the
chosen time. Part of #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:58:15 +05:00
Ludovic Marie 57c3055ce8 Merge pull request #6596 from ErikKalikas/mod-item-energy
🤖 Agent: queued at 5aceab866a. This follows `SimpleEnergyHandler`: a capacity under 1 means no buffer, the limits are clamped to 0..=capacity, and an emptied item drops its `horizon_energy` key so it stacks with new ones. The helpers act on one item and hand back the changed copy, so nothing runs per tick. I merged main and made `ItemEnergyBuffer` public, re-exported from `mod_api`, since a mod builds it and the generated code since #6253 names it.
2026-11-02 08:57:55 +01:00
Ludovic MarieandClaude Opus 5.5 cc635eda92 Merge main into #6641
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:56:58 +01:00
arismoko 19e698fa1c Fix stat award and advancement progress hooks 2026-11-02 07:56:45 +00:00
Ludovic MarieandClaude Opus 5.5 5aceab866a Let the mod API name ItemEnergyBuffer
Since #6253 the hook's types appear in the generated mod_api code, which
only sees what mod_api re-exports: the buffer is now public (a mod builds
it) and re-exported there, and xtask's test stubs it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:56:13 +01:00
MSedgeMC b160ede58b Drive the item sync test through its update interval 2026-11-02 07:55:56 +00:00
arismoko a35e9b5e3f Fire living_get_projectile for creative, quick releases and crossbow mobs 2026-11-02 07:54:16 +00:00
arismoko 56725bdfdd Keep the anvil take in the predicted click and skip createResult after a cancelled Pre 2026-11-02 07:54:11 +00:00
arismoko 60ca8bcd1b Fix native player file hooks: qualify Path, fire owner SaveToFile after level.dat write 2026-11-02 07:53:38 +00:00
Arslan ArmanulyandClaude Opus 5.5 5131f3f960 Shader packs: Iris's customTexture.<name> textures
Iris's enhanced custom textures (`customTexture.<name> = <path> [<type>
<internalFormat> <dimensions> <pixelFormat> <pixelType>]`) give a sampler
of its own that every program may read, with the same sources as
OptiFine's `texture.<stage>.<name>` (ShaderDoc, "Enhanced Custom
Textures"). Horizon read only `texture.*`, so a pack's `sampler3D
texClouds` (Shrimple) was "not provided yet" in every program.

`load_textures` reads `customTexture.*` for every stage; a sampler finds
it by its name and dimension when its own stage has no texture of that
name.

Part of #6812

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:53:32 +05:00
Ludovic MarieandClaude Opus 5.5 9c9cb89f98 Mod API entries and regenerated output for #6596
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:51:38 +01:00
MSedgeMC 2b5514dba9 Square the /playsound range as a float
PlaySoundCommand compares the squared distance with
Mth.square(getRange(volume)), which multiplies in float and only then widens.
Horizon widened first and squared in double, so a target between the two
squares was in range where vanilla has it out of range. The comparison and
the minVolume placement move into playsound_placement, which takes the float
range.
2026-11-02 07:51:30 +00:00
Ludovic MarieandClaude Opus 5.5 0f1430f092 Merge main into #6596
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:51:25 +01:00
levzzzandClaude Opus 5.5 d2c99b0ae3 Quick-move hook: review fixes
An unhandled call puts the block's items back, so nothing a mod moved without
taking the click is kept twice; a removed block closes the menu and resends the
inventory; a dead player's click and a shift-click during a drag go to the
game; the docs say the clicked slot gets the remainder. Tests for the
write-back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:49:29 +05:00
levzzzandClaude Opus 5.5 c200620069 Native mods: a mod container routes shift-clicks (native_menu_quick_move)
A server hook before the game's own quick-move routing, as a Java menu
overrides AbstractContainerMenu.quickMoveStack: a mod container on a vanilla
menu type or on its own menu type takes what it moves out of the clicked
stack and sets handled; the slot keeps the rest and the client gets the menu
again. Lets a storage terminal whose screen hides the container's slots take
a shift-click from the inventory, and a machine route fuel and inputs.
Fixes #6562, part of #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:49:29 +05:00
arismoko b0e4c06e91 Add native_player_stat_award hook 2026-11-02 07:49:10 +00:00
arismoko ed02ec25c2 Add native_living_get_projectile hook (LivingGetProjectileEvent) 2026-11-02 07:48:43 +00:00
arismoko 1dcaf71154 Add AnvilCraftEvent.Pre and Post hooks 2026-11-02 07:48:43 +00:00
arismoko 86db18cd6e Add native_arrow_nock hook (ArrowNockEvent) 2026-11-02 07:48:43 +00:00
MSedgeMC c8d2f85728 Send an item stopped by a block its exact position
ItemEntity now keeps Entity.verticalCollision and Entity.horizontalCollision
from the move its resolver returned, EntityState carries them to the tracker,
and ServerEntity.send_changes applies vanilla's
isFullPrecisionEncodingRequired: an item that a block stopped and whose x/z is
not on the 1/4096 delta grid goes out as a full position sync, not a
quantised delta.
2026-11-02 07:48:14 +00:00
arismoko 34da960a6c Add native_player_advancement_progress hook 2026-11-02 07:47:31 +00:00
TeiwazikandClaude Opus 5.5 f266bfd434 Shader API: held and dropped items light in their [[lights]] colour
With dynamic lights on, a held or dropped item whose block the pack colours by [[lights]] joins the
frame's coloured lights (MaterialInput.colored_light) at its dynamic light's place and reach. The
sources come from one place for both, so the plain and coloured lights agree.

Fixes #6858

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-02 08:47:30 +01:00
arismoko 1c4209f298 Add native_player_save_to_file (PlayerEvent.SaveToFile) 2026-11-02 07:47:16 +00:00
arismoko c5e5ac8df0 Add native_player_load_from_file (PlayerEvent.LoadFromFile) 2026-11-02 07:47:02 +00:00
arismoko 146bb12bcf Add native_player_clone (PlayerEvent.Clone) 2026-11-02 07:46:47 +00:00
Ludovic MarieandClaude Opus 5.5 89edb463b0 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:46:11 +01:00
DUzzL de5621ad5b Fishing bobbers hook armor stands and boats 2026-11-02 08:46:08 +01:00
SenjinandClaude Opus 5.5 aa4e81be32 HDR: clouds take the sky's lighting
Their blending covered the sky's value, so in HDR the sky behind them rose and they didn't,
and looked darker against it. A second pass over them writes the sky's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:44:51 +01:00
martipopsandClaude Opus 5.5 41883f70f0 Merge upstream main (mouse controls merged) into controller setup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:44:49 -05:00
MSedgeMC 32b3543820 A jukebox placed from an item carrying a disc keeps it, and shows it
JukeboxBlock.setPlacedBy turns has_record on when the placed item's block
entity data contains a RecordItem, and a placed jukebox gets its own block
entity so that data has somewhere to land.
2026-11-02 07:44:16 +00:00
Aaron Wang bb6ec90df7 Let a mod's blocks be signs, by their template
A mod block only became a sign when its name ended in _sign, and then its
text never reached a client: the chunk encoder gave a mod block its own block
entity type and dropped any entity that wasn't the mod's. Its item also placed
vanilla's wall sign, and its editor looked for a texture named after the block.

Go by the template's class, as vanilla's instanceof SignBlock does, for the
block entity, the renderer, the editor, chaining and respawning, and for the
block entities world generation gives the blocks a feature places. A content
block declares wood_type, its WoodType name, and wall_block, the wall form its
item places, one of the same mod's blocks: a pair that doesn't fit leaves out
that mod alone. Mod signs go out with vanilla's sign and hanging_sign block
entity types, so text, glow, dye, wax and saving work as they do on an oak
sign. A stone block named road_sign is no longer a sign (#5532).

Written with Claude Opus 5.5 in OpenCode.
2026-11-02 02:39:32 -05:00
ElyTheCat b1fb827aff Merge branch 'main' into perf/static-block-entity-batching 2026-11-02 11:08:19 +03:30
HrafnaValkyrja b7f8df9510 Merge remote-tracking branch 'origin/main' into fix/fluid-push-vehicles 2026-11-02 02:32:51 -05:00
SenjinandClaude Opus 5.5 ba7f538d48 HDR: stars shine as points of light
Their colour is premultiplied in the shader, as the celestial blend did, so the alpha can
carry the mark: the bright tier, lifted as far as they show.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:32:03 +01:00
MSedgeMC 884b7dfcef Options: a keyboard-focused widget's tooltip stays by its widget
Vanilla places the tooltip of a keyboard-focused widget with
MenuTooltipPositioner, which keeps it 3 to 5 pixels from the widget's edge and
5 pixels inside the screen instead of pinning it to the pointer.
2026-11-02 07:29:27 +00:00
MSedgeMC a2ed6c2882 A facing target wraps the yaw to -180..180
Entity.lookAt wraps the yaw with Mth.wrapDegrees, so facing a target to the
north-west leaves 135 and one due east -90. /tp ... facing stored the raw
atan2 - 90 (-225 there) and /rotate ... facing reduced it modulo 360 (270), so
anything reading the rotation saw a different number for the same direction.

Both commands now share one helper that wraps the yaw as vanilla does, with a
unit test on the angles.
2026-11-02 07:25:25 +00:00
Arslan ArmanulyandClaude Opus 5.5 25e50f6493 Shader packs: interface block members keep their own qualifiers
GLSL lets the members of an in/out interface block carry their own
interpolation and layout qualifiers (GLSL 4.60 4.3.9: `out VertexData { flat
int id; } vOut;`). naga's frontend refuses them ("Expected Void, Struct or a
type, found Interpolation(Flat)"), which skipped every gbuffers program of
Shrimple. Such a block's members now become plain varyings
(`hz_<block>_<member>`, matched between stages by block and member name, as
GLSL matches them), `instance.member` reads them, and lines stay where they
were. Blocks with an instance array or an instance used whole are left as
written.

Part of #6812 (the translation failures; the black picture has other causes)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 12:22:04 +05:00
Ludovic MarieandClaude Opus 5.5 2a5da68674 Resident test: build the column set with collect
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:07:34 +01:00
Arslan ArmanulyandClaude Opus 5.5 4b4db8c36d Shader packs: define MC_NORMAL_MAP and MC_SPECULAR_MAP
OptiFine defines both when its normal and specular map options are on (its
default), and Iris 1.11.7 defines both too (observed with a probe pack of our
own: its final pass paints one stripe per macro). Horizon defined neither, so
packs took their no-normal-map branches; Bliss declares FlatNormals only in
the other branch and its terrain, block and entity programs failed with
"Unknown variable: FlatNormals".

Fixes #6811

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:45:50 +05:00
MSedgeMC 99a257fc7c Follow the widened air drag in the two projectile tests
getAirDrag is 0.99F in vanilla, widened to double, so a throwable's horizontal step is
0.9900000095367432 rather than 0.99; two tests still asserted the old value.
2026-11-02 06:41:25 +00:00
Ludovic MarieandClaude Opus 5.5 86f802cac7 Drop a teleport's chunk columns at once: a 10 ms frame at render distance 32
A far teleport moves the server's chunk centre: the client drops most of its view (3700
columns at 32 chunks) in one SetChunkCacheCenter, then gets thousands of ForgetLevelChunk.
Renderer::remove_columns looked up every section key of every column in five stores and
removed resident sections one by one (each a slot move and a column lookup): 10 ms of one
frame on an M4 Pro, and each ForgetLevelChunk did it again for its own column, with a pass
over every block entity.

remove_columns now filters each store once when the columns hold at least as many section
keys as there are resident sections (Resident::remove_columns rebuilds the dense list), and
the released sections drop on a thread of their own. poll_remote forgets a run of
ForgetLevelChunk at once, before the next packet whatever it is, as the centre change does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:37:20 +01:00
Ludovic MarieandClaude Opus 5.5 3393b393bf tools: pacing_bench elytra, portal and join scenarios; HORIZON_SPIKES shows the time between frames
The new travel scenarios: an elytra-speed straight line at 33 blocks/s, two trips through a
Nether portal (checked to arrive in the Nether), and joining a dedicated server of the same
build in a game of its own. The Nether scenario travels through chat as a player does: the
control socket's `command` waits on the server thread inside the frame, a 40-70 ms stall
that no player sees.

HORIZON_SPIKES now also reports a frame whose wait before it (idle: the control socket,
world entry, pads) passed the threshold, with those steps as spans, and times poll_remote.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:27:13 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 6f33e2bba3 refactor: decide gravity and fluid push once for every entity kind
Adds EntityRules (NoGravity, default gravity, isPushedByFluid) and
Entity::push_by_fluid / push_by_bubble_column defaults; TNT now uses
update_entity_fluids instead of its own copy of the fluid world.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 00:43:05 -05:00
JustKarkat b05b0d939a Native mods: let an item's stack size go past 99
`item_defaults::read` clamped `minecraft:max_stack_size` to 1 to 99 after
the `modify_default_components` hook, so a mod's bigger size was silently
taken away; and a value that was not a number skipped the clamp, which each
reader then read as its own default (the item table 64, the item models 1).

A data pack's own size is now held to vanilla's 1 to 99 before the hook, a
mod's to 1 to `i32::MAX` (the count the wire's VarInt and the save's int
carry), and a value that is not a number leaves the data pack's own. A mod
that removes the component still removes it.

A container slot now caps at the item's own size instead of vanilla's fixed
99, so a modded stack above 99 survives a click, a drag and a shift-click.
For every data pack item that is the same answer as before (their sizes are
at most 99); the creative slot packet's 1 to 99 check and `/give`'s
`max_stack * 100` limit follow the same bound.

Fixes #6713
2026-11-01 23:21:51 -06:00
ElyTheCat 00d205ee47 Merge branch 'main' into perf/static-block-entity-batching 2026-11-02 08:50:16 +03:30
MSedgeMC ab0afa3174 Summoned splash and lingering potions are projectiles, not inert entities
ThrowableItemProjectile.getDefaultItem/readAdditionalSaveData and
AbstractThrownPotion.getDefaultGravity: /summon minecraft:splash_potion makes a
ThrownSplashPotion that falls with gravity 0.05, carries Motion, flies as its
Item (the default splash potion when the summon names none) and splashes with
that item's potion_contents; a lingering one leaves its cloud.

Horizon loaded both potion types as an inert "other" entity kept as its
compound - nothing ticked it, its data had no Motion, no blast could push it and
nothing splashed - and ProjectileKind::Potion carried one baked effect (the
witch's) instead of the stack, so the kind now holds the item it flies as and a
landed potion goes through the same splash() a thrown one does; the witch builds
the stack vanilla's Witch.performRangedAttack makes. ThrowableProjectile's air
drag is vanilla's 0.99F float widened to double, not 0.99, so a thrown item's
position matches the server's (the splash potion's Pos and Motion after a summon
now match to the last digit).

Fixes #6642
2026-11-02 05:08:44 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 d225506eff refactor: gather selector targets with the entity walk
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-02 00:02:11 -05:00
ElyTheCatandCodex 1f420bfc12 Add block entity graphics text to English language table
Co-Authored-By: Codex <[email protected]>
2026-11-02 08:26:56 +03:30
Alexander KrylovandHermes Agent 0bf2ebb6b3 Port map: the debug stick's forward and backward cycling
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 07:52:19 +03:00
HrafnaValkyrja be05eaab28 Merge commit '40b190f49' into refactor/entity-walk-fluids 2026-11-01 23:51:31 -05:00
HrafnaValkyrja 288df0addd Merge remote-tracking branch 'origin/main' into refactor/entity-walk-fluids 2026-11-01 23:51:31 -05:00
Alexander KrylovandHermes Agent 83dd998b90 Port map: eight rows whose methods the tree already ports
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-02 07:48:35 +03:00
HrafnaValkyrja c8141f144e Merge origin/main 2026-11-01 23:37:36 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 80f78b6628 Skip a thunderHit stage no entity is in
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 23:37:12 -05:00
ElyTheCatandCodex c13fcd609e Merge origin/main into perf/static-block-entity-batching
Co-Authored-By: Codex <[email protected]>
2026-11-02 08:02:32 +03:30
MSedgeMC 29b910447a Clear fifteen ledger rows a render bisect found implemented
The world icon, the spectator post effects, the two first-person hand cases, the sound device
watch and preview, the particle cap and the pack format validators are all in Horizon; the rows
still said missing.
2026-11-02 04:25:11 +00:00
HrafnaValkyrjaandClaude Sonnet 5.5 cb02bd20a4 refactor: walk every entity for portals
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 23:09:48 -05:00
DUzzL 7abd58a8dd Format tnt.rs 2026-11-02 05:09:48 +01:00
VaspyyyandClaude Opus 5.5 49f441fd7f Startup trace: the world's fill as counters, and the steps of the open's gaps
HORIZON_STARTUP_TRACE showed ~100 ms of the server thread and the ~700 ms
from world.first_frame to world.view_full without a step. Now:

- a `world.fill` counter track each frame until the view is full: chunks
  queued and at each streaming stage, resident columns, meshes waiting to
  upload and packets waiting from the server (Perfetto draws them; the
  tracer's timeline ignores them);
- server.sync_adopt and server.start_mods on the server thread;
- sync.* inside SyncData::load (registries, tags, commands, recipes,
  advancements);
- world.prepare and world.make_streaming on "World initialization".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-02 04:54:29 +01:00
HrafnaValkyrjaandClaude Sonnet 5.5 655e90a041 Entity interface 5: move wind onto the walk (#6540)
wind_knockback walks every entity with for_each_entity_mut; each kind
says what it ignores and which box and eye height it is pushed through.
Behaviour unchanged; rockets and tridents (#6647) go through the same loop.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 22:41:19 -05:00
DUzzL b8c519ce8f TNT lit by a burning arrow belongs to the arrow's owner
TntBlock.onProjectileHit primes the TNT with the projectile's owner,
PrimedTnt.getOwner then names the blast's causing entity. A creeper
killed by TNT a skeleton lit now drops a music disc.
2026-11-02 04:36:25 +01:00
HrafnaValkyrja 69936d0b80 Merge origin/main into refactor/entity-walk-portals 2026-11-01 22:33:31 -05:00
HrafnaValkyrja 9730bf403c Merge refactor/entity-walk-plates (slice 3 final) into refactor/entity-walk-explosions 2026-11-01 22:16:37 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 4c61e542cb refactor: walk every entity for explosions
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 22:16:22 -05:00
HrafnaValkyrja 9a3ad873bc Merge refactor/entity-walk-plates (slice 3 final) 2026-11-01 22:15:53 -05:00
HrafnaValkyrja c789d7bebf Merge remote-tracking branch 'origin/main' into refactor/entity-walk-plates 2026-11-01 22:14:59 -05:00
martipops 14419addc0 Merge branch 'split/fresh-mouse-controls' into split/fresh-controller-setup 2026-11-01 22:14:22 -05:00
HrafnaValkyrjaandClaude Opus 5.5 43e2faf5e8 refactor: walk every entity for plates and tripwires
Add for_each_entity! over EntityLists, one loop per list with no
allocation or dyn, and move the plate and tripwire counts and the
entityInside sweep onto it. Bat and marker armor stand become
is_ignoring_block_triggers overrides. No behaviour change intended.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 22:11:00 -05:00
martipopsandClaude Opus 5.5 e061527c49 Merge mouse controls (main merged) into controller setup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:10:14 -05:00
HrafnaValkyrja 365cc67662 Move lightning's thunderHit onto the entity walk 2026-11-01 22:04:01 -05:00
HrafnaValkyrjaandClaude Opus 5.5 6ed4e7ef68 Regenerate the mod API output after the merge with main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 21:54:53 -05:00
HrafnaValkyrja 3032e8c55a Merge remote-tracking branch 'origin/main' into fix/items-on-slime 2026-11-01 21:54:45 -05:00
HrafnaValkyrjaandClaude Opus 5.5 b32c93c4d6 Items and primed TNT bounce and slide on slime like vanilla
Entity.restituteMovementAfterCollisions adds back the gravity and air drag of
the part of the tick after the hit, and SlimeBlock.stepOn drags the horizontal
speed (also on idle ticks). Primed TNT now bounces too, and a collided step
shorter than sqrt(1e-7) is not applied, as in Entity.move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 21:54:32 -05:00
ElyTheCatandCodex 21098a3651 Regenerate mod API output
Co-Authored-By: Codex <[email protected]>
2026-11-02 06:14:45 +03:30
ClickingMasterandClaude Sonnet 5.5 2508fa5be2 Launcher: Sort by on the registry, below the GitHub row; no scroll jump
- The Horizon registry hid the filter bar, so it had no sort. It now shows the
  dropdown below "Add from GitHub", and sorts by Downloads, Newest (first
  published) or Updated (latest version).
- A sort or filter change keeps the listed rows until the new ones arrive, so the
  page doesn't shrink and the scroll doesn't jump to the top.
- The dropdown is 40px tall with its text centred on the button's face.

Refs #6714

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:41:37 -08:00
HrafnaValkyrja 0dcf45ce2c Merge remote-tracking branch 'origin/main' into refactor/entity-walk-plates 2026-11-01 21:41:27 -05:00
Ludovic MarieandClaude Opus 5.5 13db28f018 Merge main into #6571
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:31:53 +01:00
Ludovic MarieandClaude Opus 5.5 149efc8302 Merge main into #6416
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:31:00 +01:00
Ludovic MarieandClaude Opus 5.5 0af63f66e6 Merge main into #6665
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:29:05 +01:00
Ludovic MarieandClaude Opus 5.5 701d1a6cd1 Merge main into #6631
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:28:35 +01:00
ClickingMasterandClaude Sonnet 5.5 97199d16da Launcher: Sort by dropdown on the Mods page
The Mods page's sort was a row of five segments beside the Filters button. It is now a
"Sort by: <order>" dropdown (the Language row's pattern) listing Best match, Relevance,
Downloads, Follows, Newest and Updated. The list closes on a pick and when the page changes.

Refs #6714

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 18:26:13 -08:00
Ludovic MarieandClaude Opus 5.5 4b0a92f9c1 Merge main into #6596
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:24:49 +01:00
ElyTheCat 9b1b96779d Merge branch 'main' into perf/static-block-entity-batching 2026-11-02 05:54:45 +03:30
Ludovic MarieandClaude Opus 5.5 fbf4c8631e Merge main into #6641
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:24:09 +01:00
HrafnaValkyrja 0d3c092469 merge main 2026-11-01 21:24:04 -05:00
HrafnaValkyrja 4e149f24f9 wip-walk 2026-11-01 21:17:04 -05:00
martipopsandClaude Opus 5.5 024d281d9a Merge split/fresh-mouse-controls (main merged) into split/fresh-controller-setup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:12:01 -05:00
martipopsandClaude Opus 5.5 9d53e1b519 Merge mouse controls (main merged) into controller setup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:54:14 -05:00
some-du6e 16e35d4fe6 Merge remote-tracking branch 'origin/main' into optifine/fusion-mesh
# Conflicts:
#	crates/horizon-client-render/src/assets.rs
2026-11-01 19:29:52 -06:00
gigamans442andCodex GPT-6 72c964b7ac Shader translator: resolve signed scalar clamp arguments with unsigned literal bounds
Fixes #6681.

Retain upstream behavior outside this isolated host/compiler fix.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:24:32 -05:00
gigamans442andCodex GPT-6 e66238af6c Shader packs: discover setup/begin programs and run their lifecycle stages
Fixes #6676.

Retain upstream behavior outside this isolated host/compiler fix.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:24:02 -05:00
ElyTheCatandOpenAI Codex 6675c767e7 Keep entity occlusion culling off block entities
Keep block entity frustum checks and section cache rendering independent of the ordinary entity occlusion culler. Show mode-specific culling help in the graphics UI.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 04:43:52 +03:30
some-du6eandClaude Opus 5.5 7e70a7719f Fusion faces: a two-byte rule, sprite objects, and tinted overlay pieces
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:08:38 -06:00
some-du6eandClaude Opus 5.5 e0218fefbd Fusion models: each face joins by its own key, and linked texture folders are read
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:58:06 -06:00
some-du6eandClaude Opus 5.5 c2979141de Fusion packs: each baked face carries its rule, so multipart and weighted models join right
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:57:09 -06:00
some-du6eandClaude Opus 5.5 285d8ee836 Fusion packs: connecting models join their textures in the world
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:57:09 -06:00
AltMugenandCodex 670350d7e9 Fix dragon contacts with living mobs and revenge target lookup
Include living mobs in the dragon's wing and head/neck contacts. Preserve
vanilla wing impulse, sitting behavior, team damage rules, hurt cooldowns,
and the dragon as the causing entity. Resolve the separately simulated
dragon in MobWorld so existing revenge and melee goals can target it.

Add a real-Java contact oracle, Rust transcript and repeatable parity
harness. Seven scenarios compare health, velocity and revenge attribution;
all JSON values matched exactly in the local Java 26.3 run. The normal
integrated-server check also passed (enderman health 30, dragon target).
Add an optional
integrated-server check for contact damage and enderman revenge.

The upstream damage-event/hurt-flash fix is already present and is retained.
This does not claim parity of the entire fight, pathfinding, or post-attack
enchantment effects. Full pr_ready currently cannot pass because Clippy is
not installed; individual verification: 1,934 Rust tests, 598 Python tests, rustfmt,
cargo check and the seven Java/Rust scenarios passed.

Co-Authored-By: Codex <[email protected]>
2026-11-02 03:41:42 +03:00
some-du6eandClaude Opus 5.5 0ddb4d89e4 Far terrain on servers: read Bobby's cache too
Far terrain on a server without horizon:lod draws only the chunks
Horizon kept of it. A player who also plays with Bobby has a cache of
the same server's chunks, often of areas Horizon never saw.

Far terrain now finds Bobby's folder of the level in the game folder
(HORIZON_MINECRAFT_DIR, else the official launcher's): .bobby/<address
as typed, ':' as '_'>/<hashed seed>/<namespace>/<path>, names compared
without case and with or without the default port. A chunk Horizon's
store doesn't have (or holds as an old record) is read from Bobby's
region file, decoded as a vanilla chunk with its stored light
(save::decode_lit, handed in from game.rs), summarized as a loaded chunk
is (lod::summarize) and kept in server-terrain/, so Bobby's folder is
read once per chunk and never written. Bobby's region headers count for
the wide cells too. A chunk of another DataVersion, one that doesn't
decode or has no light gives nothing. A Bobby chunk is not kept over one
the server sent while it was decoded. Friend worlds and splitscreen
guests still keep no server terrain.

Tests: folders by address (port typed or not, ':' as '_', case), another
seed, another dimension, another port; a Bobby-shaped region counted for
wide cells, read, summarized and kept, its file left unchanged, another
version's chunk ignored; with prepared resources, a chunk with both
palette shapes (names, and compounds with {"": name} wrappers) decoded
with its light and kept; and with HORIZON_TEST_BOBBY, every Overworld
chunk of a .bobby folder Bobby really wrote, found by address and seed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:35:57 -06:00
HrafnaValkyrja 40b190f497 Merge remote-tracking branch 'origin/main' into fix/fluid-push-vehicles 2026-11-01 19:34:22 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 6c1d39a4d4 Push minecarts through the shared fluid update; boats keep a null first status
Carts use entities::update_entity_fluids (no second copy of the push) and keep
bubble columns. Drop game/body_fluid.rs and the boat push, now in #6453. A
boat's oldStatus stays unknown on tick 1, so one spawned in water no longer jumps.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 19:33:56 -05:00
martipopsandClaude Opus 5.5 3c3060ae5a Localize the splitscreen join and controller prompts
The join strip, the waiting player's prompt and the controller notices
read horizon.splitscreen.prompt.* keys (en_us, fr_fr) instead of English
literals.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:32:34 -05:00
fy2neandAntigravity 851d2a3592 Native mods: support own_background on NativeScreen
pub own_background: bool on NativeScreen leaves out Horizon's default opaque
rectangular background panel and slot frames in draw_native_screen when true,
allowing mod screens with non-rectangular geometries to draw their own
background without transparent areas exposing the grey frame.

Co-Authored-By: Antigravity <[email protected]>
2026-11-01 16:30:58 -08:00
970edcd28b Expose latest native build diagnostics in the launcher
Add a top-right log viewer with complete clipboard copy and open/reveal file
actions. Keep snapshots fresh across attempts and queued reads, bound the log
viewport, and wrap navigation and actions in narrow windows. Localize all
new labels and document the workflow.

Co-Authored-By: GLM 5.3 <[email protected]>
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-01 19:30:35 -05:00
ErikKalikasandClaude Opus 5.5 0e3fbaf000 Native mods: the item energy hook's mod API contract
native_item_energy_buffer fills an out parameter, so its fragment carries
a replace contract on buffer, as #6253 gives item_fluid_tank and
block_entity_energy. Review by arismoko on #6596.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:22:23 +02:00
martipopsandClaude Opus 5.5 ecf18374a8 Merge upstream main into controller Setup
Upstream's pad rule wins: in a world a pressed controller goes to waiting
players, then joins as the next player; P1 takes it only in a menu or while
waiting for one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:19:06 -05:00
Maksymilian AdamkiewiczandCodex 93353465f5 Merge origin/main into fix/dialog-text-clicks
Co-Authored-By: Codex <[email protected]>
2026-11-02 01:15:55 +01:00
Maksymilian AdamkiewiczandCodex 9e0f4a8ddc Merge origin/main into fix/dialog-container-close
Co-Authored-By: Codex <[email protected]>
2026-11-02 01:15:54 +01:00
Evrim CicekandClaude Opus 5.5 4d38650b48 Armor stands: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:12:58 +03:00
HrafnaValkyrja 36a87d0147 Merge remote-tracking branch 'origin/main' into fix/fluid-push-vehicles 2026-11-01 19:08:58 -05:00
ElyTheCatandOpenAI Codex b90be5a701 Update matched block-entity benchmark evidence
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 03:31:28 +03:30
ElyTheCatandOpenAI Codex 29c042c74c Batch static block entity rendering by section
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 03:19:15 +03:30
some-du6eandClaude Opus 5.5 3acfde75ba Far terrain: BlueMap's coarser levels stand in while the finest loads
Main's far terrain samples only the tiles its plan draws, nearest first,
with no coarser tile under the near ring to keep drawn: while BlueMap's
finest level (one pixel a block) was still downloading, the ring past
the near chunks stayed empty.

A cell up to a chunk wide whose finest map tile is on its way is drawn
from the finest coarser level that has it (BlueMap::coarser_column, the
first to arrive); the tile is sampled again when the finest arrives
(Lod::refresh). LodSampler::waited now means a hole: a cell on its way
with nothing coarser to stand in (a void there is a void, not a hole).
Only a sample with a hole is thrown away (what it would replace stays
drawn); one stood in for is drawn. Columns whose finest tile is out of
the download reach take the coarser levels too, which reach farther
(BlueMap::chunk), and chunk detail with them. A coarser level arriving
names chunk-wide cells among those it changes, and every arrival drops
the cached chunks over it (and a cell around, for a coarser level), so
they are built and sampled again with it.

Tests: coarser levels stand in for a waiting finest one (the finest of
them), and past level 1's reach; a hole without any is waited; a waited
sample is thrown away and a stood-in one drawn; in the scheduler
scenario (prepared resources, ignored), a source standing in coarse
columns keeps every chunk past the near terrain drawn while the finest
loads, and the finest replaces them once in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:31:46 -06:00
some-du6eandClaude Opus 5.5 5a50f85880 Stop BlueMap far terrain from re-meshing what didn't change
With BlueMap on, a tile arriving re-sampled every far-terrain tile over it
up to the roots, and a coarse one also cleared the detail of thousands of
chunks, each re-requested and re-meshed.

- Arrivals name the cell widths their level is drawn in (Lowres::widths):
  Lod::refresh samples only those tiles again. Changes are handed over at
  most every 250 ms, so a burst is sampled once.
- A sample, or chunk detail, equal to what is drawn meshes nothing. A tile
  refreshed while its sample was in flight is sampled again.
- Tiles looked up out of reach are remembered and wanted when the player
  comes near or the server is tried again.
- A level-1 arrival drops only the chunks it covers from the cache. The
  cache size and pruning walk the disk outside the lock the frame takes.
- Columns end 8 blocks under their lowest neighbour's top, not at the
  bottom of the world: the edge of the map's data drew a stone wall along
  the horizon.
- The map's settings, once a visit: a network error backs off and keeps
  the cached map; only an answer without this level's map drops it. When
  the lowres settings changed, the map's cached tiles are deleted before
  the new settings are kept (if they can't be, no map this visit), and
  lookups or chunks built under the old settings meanwhile aren't kept.
- A map dropped or replaced redraws everywhere, not only in the download
  radius (announce_all). A failed cache write no longer hides the tile
  just downloaded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:31:46 -06:00
some-du6eandClaude Opus 5.5 b899e4c200 Draw far terrain from a server's BlueMap past the kept chunks
On a server the player names in horizon-bluemap.txt (blueMap: lines, off
otherwise), far terrain fills what Horizon didn't keep from the server's
BlueMap web map: its lowres tiles (a colour, height and block light a
column) are downloaded around the player, one at a time and at most ten
a second on their own thread, kept on disk, and drawn as the natural
block nearest each colour. A tile that arrives refreshes the far-terrain
tiles over it (Lod::refresh).

The maps have a file of their own beside horizon-graphics.txt, which
shared instances and modpacks carry: the map is asked for tiles around
where the player stands, so only the player names one. BlueMap Radius
stays a graphics setting. Tiles are cached up to 256 MiB a server
(oldest first), fetched again after three days, written whole (temp file
and rename), and an error page never replaces a cached tile; a server
that stops answering is left alone longer each time, up to ten minutes.

The settings: Graphics holds them, horizon-graphics.txt keeps
blueMapRadius:, and the blueMap: lines are read from
horizon-bluemap.txt.

Chunk detail read before a refresh over its chunk is dropped and asked
again, never kept over the newer data (Lod::refreshed_since).

The launcher's import from other launchers leaves horizon-bluemap.txt
and server-terrain/ out, so an instance another launcher made can't name
a map for the player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:31:46 -06:00
some-du6eandClaude Opus 5.5 76d4f7058a BlueMap's lowres tiles, settings and palette, for far terrain
The first half of far terrain from a server's BlueMap web map, nothing
wired yet: the player's settings and their formats (blueMapRadius:, and
the blueMap: lines of their own file, horizon-bluemap.txt; the next
commit reads and writes them), the web app's settings.json and a map's
lowres layout (tileSize, lodFactor, lodCount, validated so a hostile map
can't overflow or index past a tile), where a block's pixel is at every
level (LowresLayer.saveTile), the tile paths (pathFromCoords), decoding
a lowres tile's PNG (colour, height clamped to the world's reach, block
light; gzipped too), and the palette that turns a colour into the
natural block nearest it, tinted by its biome as the game draws it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:31:46 -06:00
some-du6eandClaude Opus 5.5 497db27914 Far terrain: a sample still waiting for BlueMap's data isn't drawn
With a BlueMap, a far-terrain cell up to a chunk wide whose map tile was
still downloading was sampled empty, and that tile, ready, replaced the
coarser one drawn there. A band of nothing stood past the near chunks
until the map's tile came and the tile was sampled again, ring by ring
outward.

- A sample with data still on its way (LodSampler::waited) is thrown
  away: what it would replace stays drawn. The tile waits in the retry
  map for 10 s (WAIT_RETRY: a shorter wait let near retries take every
  sample slot), or is sampled again as soon as the data comes
  (Lod::refresh), at once if it came while the sample was taken. A
  refresh also marks first samples still in flight.
- A finished mesh job's tiles go up finest first, then nearest: a tile
  re-meshed clipped around a finer one no longer shows its hole frames
  before the finer one is drawn.
- On a server, far terrain's finest tiles stop where the server stops
  sending chunks (its view distance and a ring), not at the render
  distance: thousands fewer chunk-sized tiles to fill in.

Tests without resources drive the scheduler by hand: a waited sample is
retried when its data comes, a tile refreshed mid-sample is sampled once
more, the same samples mesh nothing, and kept chunks re-sample the wide
tiles over them. With nothing coarser under the near ring yet, the ring
stays empty while the finest level downloads (the ignored scenario test
added here fails here; the next commit replaces its source with one that
stands in): the next commit's coarser levels fill it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:31:46 -06:00
some-du6eandClaude Opus 5.5 0e7b0899a8 Find a server's BlueMap by itself, behind Find BlueMap
With Find BlueMap on (Video Settings > Advanced > Rendering, off by
default), joining a server no blueMap: line names asks a few likely
addresses for BlueMap's settings.json on the BlueMap worker, one at a
time with short timeouts, and keeps the answer per server (a map found
is checked again after a week, none after a day). BlueMap Radius sits
next to it. A blueMap: line always wins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:31:46 -06:00
some-du6eandClaude Opus 5.5 9df7144bde Far terrain: sample again the tiles a server chunk was kept too late for
A tile sampled before the server sent a chunk of it kept that sample
while it stayed in the plan. Cells up to a chunk wide take the chunk's
detail when it is observed, but wider ones (past 4 km, and the coarse
fallback drawn while finer tiles load) never did, and a sample in flight
while the chunk was being stored overwrote it with what it read before.

When a source keeps the chunks it observes (LodSource::keeps_observed:
server terrain), the tiles over a newly kept chunk whose wide cell there
is empty, or whose sample is still being taken, are made stale and
sampled again, their old samples drawn meanwhile; an unchanged sample
meshes nothing. Local worlds are unchanged.

Tests: resampled_over's choice, without resources; with prepared
resources (ignored), a player flying past 1800 chunks with a view distance
of 10 keeps every dropped one drawn, and 32 x 32 chunks kept after their
far tiles were sampled fill in (all 1024 stayed empty before).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:31:46 -06:00
some-du6eandClaude Opus 5.5 c0c6fa6b17 Server far terrain: no slab of one chunk over a cell it barely covers
With no BlueMap, a far-terrain cell wider than a chunk took the column
of the kept chunk nearest its middle, however few the cell held. On a
fresh cache the coarse fallback tiles, sampled first, drew a few chunks
around the player as flat slabs hundreds of blocks wide (floating ones,
from a sky build), which finer tiles then replaced with the nothing they
know of, nearest first: terrain that appeared, then vanished outward.

A wide cell now takes a kept chunk only where at least half of its
chunks are kept, as Column::reduced keeps what fills half a cell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:31:46 -06:00
ErikKalikasandClaude Opus 5.5 65199450e4 Native mods: energy in an item, Capabilities.Energy.ITEM
native_item_energy_buffer gives a mod item a buffer (capacity, the most
one insert and one extract move), checked as a block's is. The amount is
kept in the stack's custom_data as the int horizon_energy; an empty item
carries nothing, so it stacks with a new one. native_item_energy reads one
item of a stack; native_item_energy_insert and native_item_energy_extract
answer the amount moved and the single item it becomes, without changing
the stack given. Fixes #6553.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:31:33 +02:00
Evrim CicekandClaude Opus 5.5 fb7006fd64 Armor stand egg harness: the edge stand drops off the single egg
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:31:10 +03:00
some-du6eandClaude Opus 5.5 dc23eab6ff Graphics settings: the docs screenshot of the see-through screen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:29:36 -06:00
some-du6eandClaude Opus 5.5 7d95db693f Far terrain on servers: keep what the server sent
On a server that doesn't offer horizon:lod (any vanilla, Paper or Spigot
server), every chunk it sends is kept as its far-terrain summary, on
disk per server, world and dimension (server-terrain/<server>/<hashed
seed>/<namespace>/<level>/, Anvil region files), and drawn beyond the
server's view distance on this visit and later ones: a server never
sends its seed, so the client draws what it was sent. A chunk the client
forgets (ForgetLevelChunk, or a moved or resized storage) is observed
one last time first. A server's offer (horizon:lod) still comes first,
and a friend's world keeps nothing (its code changes at every share),
nor a splitscreen guest's (the player's own world, its other guests
writing the same folder).

The far terrain is rebuilt when the level changes; a proxy's next server
comes through reconfiguration. Cells wider than 128 chunks aren't
searched for kept chunks, and a failed write is logged once.
tools/server_lod_e2e.py's server without an offer now expects these kept
chunks (source "server-terrain").

server_terrain lives in horizon-client-render beside lod and streaming.

The launcher leaves server-terrain/ out when it duplicates an instance:
it is a cache, rebuilt as the instance plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:28:24 -06:00
some-du6eandClaude Opus 5.5 5ab246e635 Graphics settings: see-through panels and flat controls
Solid beveled panels made the screen read heavy. The plate and the strip
are now the page colour, see-through, with a 1 px edge; groups a lighter
tint on them; buttons, tick boxes and the value stepper flat; a slider a
thin accent track under its value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:16:57 -06:00
Evrim CicekandClaude Opus 5.5 fb2c9f59d8 Armor stands: the egg under them is their supporting block's
Review: getOnPos(0.2) takes mainSupportingBlockPos's column, so a stand
whose centre is past the egg's edge still stands on it. The stand now keeps
Entity.setOnGroundWithMovement's supporting block (GroundState, as the
player does), and its landing location effects run before fallOn, as
LivingEntity.checkFallDamage orders them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:08:47 +03:00
huiandZCode AI agent (GLM-5.3) fe1e0e5004 Launcher: warn when mods inject at one place, or a HEAD can cancel on a hook point
The launcher reads every mod's [[injection]] list before it builds, but
it said nothing when two mods inject at one place, or when a cancelling
HEAD sits on a native hook point. Both orders are documented (priority,
then mod id, then injection id), but nothing told the player or the
author. horizon-mixins refuses only competing OVERWRITEs.

The build now says three things in native/build.log, before anything
runs, so a build that fails later still shows them:

  warning: mods beta, alpha both inject at hook `frame` HEAD; beta runs first (priority descending, mod id ascending).
  warning: mods alpha (HEAD), beta (RETURN) inject into hook `tick`: HEAD runs on entry, RETURN on return, whatever their priorities.
  warning: alpha's `stop` can cancel at hook `frame` HEAD: a Break skips the later injections and the function's body.

A place is the function, the kind, the selector and the ordinal as the
manifest writes them: two same-place injections run in horizon-mixins'
tie-break order (plan.rs sorts by file, node, call site, constant site,
then priority descending, mod id ascending, injection id ascending), so
only they get a "runs first". A mod's priority defaults to 0, as
horizon-mixins defaults it, not to the README example's 100. The
injections are read leniently: an entry without an id and a site is
left out, horizon-mixins refuses it when it applies. A raw `target` is
not matched against the catalog — whether it names a hook point's
function only the source knows — so the cancellation warning names a
`hook`, and only a `HEAD`: the interior kinds' cancellation skips
less than the body. Neither warning stops the build.

Fixes #6490.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-02 00:03:57 +01:00
some-du6eandClaude Opus 5.5 3febe729ef Graphics settings: the docs screenshot of the docked screen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:01:53 -06:00
martipopsandClaude Opus 5.5 104c359194 Merge mouse controls (main merged) into controller Setup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:58:45 -05:00
Evrim CicekandClaude Opus 5.5 5f1c085f97 Armor stands crack the turtle eggs they stand or land on
An armor stand is a LivingEntity, so TurtleEggBlock.stepOn (1 in 100 a tick,
from LivingEntity.aiStep's applyEffectsFromBlocks) and fallOn (1 in 3) crack
an egg under it while mob griefing is on. The stand tick never ran either.
The mobs' egg crack moves to Game::crack_turtle_egg so both share it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:52:55 +03:00
some-du6eandClaude Opus 5.5 2b863f6657 Graphics settings: a showing slider's value on its track
Beside it, it cut the setting's name short on a 720p window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:51:53 -06:00
Maksymilian AdamkiewiczandCodex 4d3c7c4bb6 fix: handle custom click actions in dialog message text
Keep custom text component click events, their identifiers and optional
payloads through parsing, style inheritance and serialization. Dispatch
dialog message clicks through the existing action handler, including the
dialog's after_action behavior.

Use the rendered glyph order and advances for message hit testing and
request a pointing cursor over clickable text and inline sprites. Show
text hover tooltips and handle the shared custom event in chat and books
as Screen.defaultHandleGameClickEvent does.

Extend the vanilla server e2e checks with message text and sprite clicks,
optional payloads, inert blank space and all three after_action modes.

Validation: 53 core tests and 10 dialog tests pass; dialog, dialog_text and
titles e2e checks pass on a vanilla server; focused Clippy and fast builds
pass. Full self-check limitations are recorded in the local PR draft.

Co-Authored-By: Codex <[email protected]>
2026-11-01 23:50:34 +01:00
some-du6eandClaude Opus 5.5 14dd000617 Graphics settings: the tests that set offer_hdr take a lock
The slider step test needs HDR offered, and ran only after the HDR test
had left it on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:37:13 -06:00
MSedgeMC 474421407c Hurt a glide by the distance the block actually stopped
LivingEntity.handleFallFlyingCollisions compares the delta before the glide move with the
one after it, which Entity.move leaves as the movement it resolved. Horizon read the after
value from the player's velocity, which move_entity never rewrites, so it equalled the
before value and the damage almost never came out positive.
2026-11-01 22:24:39 +00:00
HrafnaValkyrja f4e15c90c6 Merge remote-tracking branch 'origin/main' into fix/fluid-push-vehicles 2026-11-01 17:03:51 -05:00
HrafnaValkyrjaandClaude Sonnet 5.5 7c59e6e02a Minecarts and boats are pushed by flowing fluid; carts by bubble columns
Entity.updateFluidInteraction pushes every entity that does not opt out;
primed TNT had it, carts and boats did not. Carts also skipped bubble
columns, and their "in water" flag was a point test instead of the last
fluid update. A boat's previous status starts as none (oldStatus is
null), so a boat spawned in water no longer snaps to the surface.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 17:03:38 -05:00
martipopsandClaude Opus 5.5 80a2c258d7 pausemenu e2e: Splitscreen Settings shows only while another player plays
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:20:19 -05:00
martipops c73d296888 Merge branch 'split/fresh-mouse-controls' into split/fresh-controller-setup 2026-11-01 16:14:47 -05:00
some-du6eandClaude Opus 5.5 0c65feceab Graphics settings: pages shown alone in titled groups, a docked description
The pages are a column of names on the left, under the search field, with
Undo and Done at its foot. The plate shows the open page alone, its
settings in named groups on panels of their own; a search shows every
page's matches under their page. The hovered setting's cost and
description sit in a strip under the plate, and what the page holds
while none is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:04:55 -06:00
martipopsandClaude Opus 5.5 ccd5b8b2b5 Localize controller Setup: Give Up Pad and the held-player hints
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:49:24 -05:00
martipops 877d3c95a7 Merge branch 'split/fresh-mouse-controls' into split/fresh-controller-setup
# Conflicts:
#	crates/horizon-client-render/src/split_ui.rs
2026-11-01 15:48:00 -05:00
martipopsandOpenAI Codex 4b80f4dea2 Merge current main through controller Setup
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 15:36:26 -05:00
Maksymilian AdamkiewiczandCodex a40190c896 fix: close waiting dialogs on server container close
Handle ClientboundContainerClose while a dialog, waiting screen or warning
is open, discarding the underlying screen and copied menu state as vanilla's
LocalPlayer.clientSideCloseContainer does. Keep clearDialog's distinct
parent-restoring behavior and its treatment of the waiting screen.

Extend the vanilla server e2e checks with packet injection covering waiting
dialogs, parents, warning, replacement, lectern, key release and death.

Validation: existing dialog/titles e2e checks pass; the new dialog_close check
passes three consecutive runs; 9 library dialog tests, 8 screen tests and
2 input-capture Python tests pass; focused Clippy and fast builds pass.

Co-Authored-By: Codex <[email protected]>
2026-11-01 21:13:47 +01:00
apeirs 1e6f4e737e Minecarts: float drags and size, and the water state of the tick before
Three ways an old-behaviour minecart's motion drifted from vanilla's:

- comeOffTrack scales by getAirDrag() (0.95F) and applyNaturalSlowdown
  by 0.95F in water: floats widened, not 0.95.
- EntityType.MINECART is sized(0.98F, 0.7F): the box that stops a cart at
  a block is the float width's, half of 0.9800000190734863.
- isInWater is wasTouchingWater, which AbstractMinecart.tick updates in
  updateFluidInteraction after behavior.tick: the move goes by the tick
  before, and a new cart's first move is dry. Horizon looked at the
  block it stood in, live, so a cart on waterlogged rails slowed by one
  0.95F too many.

Harness: command_exec_corpus `minecart drag`, positions and motions to
the last bit.
2026-11-01 21:02:13 +01:00
martipopsandOpenAI Codex 5296c18b05 Raise native split windows before testing their focus handoff
Exercise P2’s actual controller menu path and check P1’s capture and real mouse movement before any recovery click.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 15:00:02 -05:00
martipopsandOpenAI Codex 8f7ccd6d0e Close the selected players retained menu before testing windowed movement
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:58:30 -05:00
martipopsandOpenAI Codex d884729a0e Observe settled positions in the native input regression
A paused client can retain its last velocity without moving; wait for actual position stability before measuring another owner’s movement.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:57:24 -05:00
martipopsandOpenAI Codex 09940babb5 Merge portable native mouse fixture updates
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:56:47 -05:00
martipopsandOpenAI Codex ee6bc633a6 Make native desktop input tests opt-in and always clean up their game
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:56:23 -05:00
martipopsandOpenAI Codex 47d40049f4 Check native camera control for both owners and controller menus
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:55:14 -05:00
martipopsandOpenAI Codex 5ad2149330 Exercise splitscreen ownership with real OS keyboard and mouse events
Add an explicit macOS desktop regression that observes player movement and camera rotation through native event routing, across shared views, menus, held-key handoff, separate windows and the last guest leaving. Requires Accessibility permission and an idle desktop; the portable capture and motion regressions run in the regular Python suite.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:53:50 -05:00
martipopsandOpenAI Codex a0172fe3bf Merge the native input ownership fix into controller Setup
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:53:16 -05:00
some-du6eandClaude Opus 5.5 0eee46a1c5 Graphics settings: fourth review round
- The arrows reach the nearest live row past each edge over greyed
  ones, and with nothing focused in view Down takes the first setting
  from the view's top on and Up the last down to its bottom: on a
  display without HDR, a 720p window opened at HDR still reaches its
  shortcut.
- Narration: keyboard focus on a tab, Undo or Done is focus, not hover.
- Tests: a held scroller lets go when the window gets too short; the
  HDR page's shortcut reached past its greyed rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:51:09 -06:00
some-du6eandClaude Opus 5.5 f3065383d4 Graphics settings: next_back for the last row in view (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:42:02 -06:00
some-du6eandClaude Opus 5.5 a8022ee8c1 Graphics settings: third review round
- The arrows reach the rows in view and exactly one past each edge
  (counted in rows, not pixels), so Up from Done no longer skips the
  row at the list's bottom edge.
- Undo restores what the screen edits (Horizon's graphics settings and
  the vanilla options on its rows), not the narrator or other options
  changed meanwhile.
- A held scroller lets go once the list is too short for it, before
  its thumb's clamp could panic.
- Tests: Up from Done scrolls in by a row at most; with the focus
  wheeled out of view, Down takes the first row in view; Undo leaves
  an option the screen doesn't edit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:40:20 -06:00
martipopsandOpenAI Codex ca17f7aa3e Read profile ownership without borrowing the whole game
Keep guest skin requests isolated while the remote connection is mutably borrowed.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:35:43 -05:00
jaszkusandOpenCode ebcf944172 Launcher: a per-user Windows setup and uninstaller
`horizon-launcher-setup.exe` is the launcher's own binary under another name:
started with no arguments, it opens a setup window in the Night look instead of
Home. It copies itself to `%LOCALAPPDATA%\Programs\Horizon Launcher`, makes the
Start menu shortcut (and the desktop one if asked) and an entry in Apps &
features under HKCU, and starts the installed copy; no administrator is needed.

`horizon-launcher.exe uninstall` (or `... uninstall --quiet [--remove-data]`)
takes it all away: the shortcuts, both registry keys and the install folder,
which is always one called "Horizon Launcher" and is removed with `remove_dir`,
never `remove_dir_all`. The game's folders are never touched, and the launcher's
data folder only with `--remove-data`.

`setup --quiet [--dir <folder>] [--desktop-shortcut]` installs without a
window. Both commands are handled in `main` before the CLI parser, so neither
the completions nor the MCP tools list them: an agent cannot uninstall the
launcher.

A self-update keeps the Uninstall entry's version and size current when the
launcher runs from the install folder. The release workflow publishes the setup
exe beside the ZIP, so it is in the release's signed SHA256SUMS, and the Windows
smoke test installs and uninstalls it in a scratch folder.

Co-Authored-By: OpenCode <[email protected]>
2026-11-01 20:34:06 +01:00
martipopsandOpenAI Codex 3edf7be3ff Keep connection and account helper documentation together
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:33:36 -05:00
some-du6eandClaude Opus 5.5 6c1583de25 Format graphics_style.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:32:37 -06:00
martipopsandOpenAI Codex eae08083a8 Keep offline guests separate from P1’s signed-in identity
Exercise signed-in P1 joining with offline guests, and adapt the remaining live Setup scenarios to the saved enable switch and current pause menu behavior.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:30:57 -05:00
some-du6eandClaude Opus 5.5 5495c02e8d Graphics settings: second review round
- The arrows reach the next row across a page's break, and Up or Down
  with the focus out of view lands on a row in view.
- The search keeps the row being changed and matches each setting's
  own description, not the one that follows its value.
- HDR's sliders and calibration wait for a display that offers HDR, as
  the old screen's HDR button did.
- Ctrl+A/C/V/X in the search are always the field's.
- A list too short for vanilla's scroller draws none and takes no press
  (its thumb's clamp would panic).
- Tests: arrows across a page break, the wheel through OptionsUi, HDR's
  rows blocked without an HDR display.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:30:04 -06:00
martipops 1ff8ce7976 Merge branch 'split/fresh-mouse-controls' into split/fresh-controller-setup 2026-11-01 14:29:48 -05:00
martipopsandClaude Opus 5.5 08d0f9c8d5 Splitscreen e2e: gui_scales, sixteen and monitor on 100% displays and portrait monitors
Found on Windows (100% scale, a portrait second monitor): a half of the 1000 x 600 window has
no room for GUI scale 2, nor a sixteenth for P1's menus, so those two take a larger window;
two players on a portrait monitor share it top and bottom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:28:06 -05:00
martipopsandClaude Opus 5.5 e359da805f Splitscreen: controller pick-up in Setup, Give Up Pad, Leave, frame readout
From the full feature (#3541):
- Setup with a controller: A picks a player's tile up, left/right or LB/RB cycle where it goes
  (the yellow < Name > arrows, the map showing it there), A drops, B puts it back;
- Give Up Pad: P1's controller is offered to join; the same pad comes back to P1 when the
  offer is declined, runs out or the player leaves before entering the world (P1Pad);
- a player whose controller disconnected shows (no controller) on its Setup tile; other
  players' pause menus say Leave;
- HORIZON_SPLIT_STATS=1: where a frame's time goes, every 5 s (frames.rs).
The live harness gains the full feature's helpers (pick, seed, args, page, setup, play_on,
close_page; press by prefix, still waiting for a drawn, enabled button) and its scenarios
three, live, profile_availability, gui_scales, fullscreen, leave, signedin, monitor, eight,
sixteen, pointer and pausemenu.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:28:06 -05:00
some-du6eandClaude Opus 5.5 ce1526aaf7 Graphics settings: review fixes
- Undo from the keyboard no longer turns itself back on; Undo keeps
  fullscreen (F11, the window manager); a calibration lights Undo.
- The search matches names and descriptions, not values, so a row
  stays while its value changes; clearing it or pasting starts the list
  over and lets go of the picked tab.
- The scroller is vanilla's thumb, the size Scroll drags by, 6 px wide
  as its press column.
- The arrows reach only the rows in view and the next past an edge;
  the wheel only scrolls (a controller's D-pad scrolls with it).
- Long names are cut before their control; hover is vanilla's white
  edge, without an off-palette colour; the shortcut's caption goes
  through the lang table; the title is Graphics.
- One graphics_events in options_ui instead of three copies;
  graphics_level is graphics_page.
- screenshot-build saves its cache only when it builds its own branch.
- Docs: the design system's Blocks section names this screen and how
  the game draws it; Leaf Culling's place; stale comments.
- Tests: Undo after several changes and with fullscreen changed, the
  keyboard's Undo, HDR's working path, Calibrate and the shortcut row,
  a 720p window, the scroll after a resize, the wheel's ends, the
  search's clear button; the open tab checked against what the list
  shows rather than its own rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 13:16:24 -06:00
some-du6eandClaude Opus 5.5 4f9236a24d docs: the graphics settings screen's screenshot
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:54:45 -06:00
some-du6eandClaude Opus 5.5 d5312a807b Graphics settings: one branch for the described row (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:42:23 -06:00
some-du6eandClaude Opus 5.5 5dfd4c5468 Graphics settings: more rows on a short screen
20 px rows, and no title under 300 GUI pixels (a 720p window at scale 3):
the tabs say where you are, and the list gets the room.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:28:18 -06:00
some-du6eandClaude Opus 5.5 9ce062bdc4 Graphics settings: one screen with tabs, search and descriptions
Horizon's Simple, Advanced and HDR graphics screens become one screen
holding vanilla's video options and Horizon's own, built from
Minecraft's own screen patterns in the design system's Blocks look:
the pages as tabs along a beveled panel, a search field that filters
every page as you type, a row per setting with a vanilla kind of
control (a value button, a tick box, a slider with its value on the
track), the hovered setting's description and cost in vanilla's
tooltip under the panel, and Undo and Done along the foot.

Adds screenshot-build.yml, a Linux build for a pull request's
screenshots.

Refs #5705

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 12:13:08 -06:00
1292 changed files with 156795 additions and 33328 deletions

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ line are in the output) and the Rust fn before changing anything.
That one line is what makes the next agent find it.
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
animation) must be reached from `game/remote*.rs`.
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
usually the same bug on the other path.
-5
View File
@@ -4,8 +4,3 @@ docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
+3 -2
View File
@@ -96,8 +96,9 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
# recompiling the workspace with a different dependency graph.
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
+9
View File
@@ -185,6 +185,15 @@ jobs:
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- 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'
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
run: |
set -euo pipefail
+20
View File
@@ -15,6 +15,8 @@ name: launcher
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
# unset (forks), they are signed ad hoc.
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
# gh workflow run launcher --ref main -f dry_run=false -f force=true
@@ -241,6 +243,18 @@ jobs:
# The client starts: --help prints before any window or GPU is needed.
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
soft help
# The per-user Windows setup: the same binary under the setup's name installs into a scratch
# folder, and the copy it installs uninstalls itself. HKCU of the runner only.
if [[ "$RUNNER_OS" == "Windows" ]]; then
setup="$RUNNER_TEMP/setup-smoke"
cp "$launcher" "$RUNNER_TEMP/horizon-launcher-setup.exe"
"$RUNNER_TEMP/horizon-launcher-setup.exe" setup --quiet --dir "$setup"
installed="$setup/Horizon Launcher/horizon-launcher.exe"
[[ -f "$installed" ]] || { echo "::error::the setup copied no launcher"; exit 1; }
"$installed" uninstall --quiet
[[ ! -f "$installed" ]] || { echo "::error::uninstall left the launcher"; exit 1; }
rm -rf "$setup"
fi
rm -rf "$HORIZON_LAUNCHER_HOME"
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
@@ -276,6 +290,12 @@ jobs:
fi
fi
ls -l out
- name: Setup exe (Windows)
# The launcher's binary under another name: started with no arguments it shows the per-user
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
if: runner.os == 'Windows'
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
+12 -15
View File
@@ -55,6 +55,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
@@ -147,7 +148,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
@@ -178,28 +179,24 @@ state stays separate from the library tests. This compiles gameplay twice for ex
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
`fn tick` returns `tick_remote()` when `remote` is set; a server ticks its levels (`fn tick_world`).
- A server's players run through the shared gameplay code: `as_guest` swaps the guest's
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
(`pack`), both in game.rs.
## The two single-player paths
## Single player runs on a server thread
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
until they are ported.
`game/integrated.rs` runs a second `Game` (`serving`) on a "Server thread" with one level per dimension;
the window joins it as a client through memory (`remote`), exactly as a LAN guest or a dedicated server's
player does (#1514). A window's `Game` never ticks a world itself: the old in-frame path is gone
(`--in-frame` and `--no-server-thread` only print a notice; `HORIZON_SERVER_THREAD` is ignored).
Open `[server-thread]` issues list the gaps left.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
thread when you touch player-facing gameplay:
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
Test the server thread when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
+2 -1
View File
@@ -96,7 +96,8 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
enough), then claim it. `/unclaim` gives it back.
enough), then claim it. `/unclaim` gives it back. Issues labelled `tracker`, `proposal` or `not claimable` aren't
claimed whole: claim one of the issues a tracker lists, or open one for the part you take.
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
Generated
+87 -5
View File
@@ -329,6 +329,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -367,6 +373,7 @@ version = "0.1.0"
dependencies = [
"horizon-mods",
"serde_json",
"zip",
]
[[package]]
@@ -1210,6 +1217,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -1266,6 +1279,7 @@ name = "horizon-assets"
version = "0.1.0"
dependencies = [
"horizon-core",
"log",
"png",
"serde",
"serde_json",
@@ -1318,10 +1332,12 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1450,8 +1466,10 @@ dependencies = [
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
@@ -1461,6 +1479,7 @@ dependencies = [
"horizon-theme",
"horizon-threads",
"horizon-world",
"image-webp",
"log",
"lz4_flex",
"miniz_oxide 0.8.9",
@@ -1479,6 +1498,7 @@ dependencies = [
"wgpu",
"winit",
"zip",
"zune-jpeg",
]
[[package]]
@@ -1486,6 +1506,7 @@ name = "horizon-command"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-protocol",
"horizon-world",
]
@@ -1497,6 +1518,18 @@ dependencies = [
"libm",
]
[[package]]
name = "horizon-cosmetics"
version = "0.1.0"
dependencies = [
"base64",
"glam",
"png",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1551,6 +1584,25 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mod-api-gen"
version = "0.1.0"
dependencies = [
"heck",
"quote",
"serde",
"serde_json",
"syn 2.0.119",
]
[[package]]
name = "horizon-mod-api-gen-tests"
version = "0.1.0"
dependencies = [
"horizon-mod-api-gen",
"serde_json",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -1583,6 +1635,7 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"log",
"regex",
]
@@ -1622,6 +1675,7 @@ dependencies = [
"horizon-core",
"horizon-world",
"ipconfig",
"log",
"serde_json",
"sha1",
"sha2",
@@ -1651,6 +1705,7 @@ dependencies = [
"ab_glyph",
"base64",
"chrono",
"getrandom 0.3.4",
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
@@ -1890,6 +1945,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -2113,8 +2178,6 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
"cty",
@@ -3113,6 +3176,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -5130,10 +5199,8 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
name = "xtask"
version = "0.1.0"
dependencies = [
"horizon-mod-api-gen",
"horizon-theme",
"quote",
"serde_json",
"syn 2.0.119",
]
[[package]]
@@ -5295,3 +5362,18 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]
+8 -1
View File
@@ -1,5 +1,5 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
@@ -12,6 +12,7 @@ members = [
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
"crates/horizon-cosmetics",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
@@ -30,6 +31,8 @@ members = [
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mods",
"crates/horizon-mod-api-gen",
"crates/horizon-mod-api-gen/dispatch-tests",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
@@ -67,6 +70,10 @@ large_enum_variant = "allow"
wgpu-hal = { path = "third_party/wgpu-hal" }
# naga 30.0.1, with the fixes to its GLSL frontend that shader packs need (THIRD_PARTY_NOTICES.md).
naga = { path = "third_party/naga" }
# libmimalloc-sys 0.1.49 whose mimalloc v3 counts bits without POPCNT when MSVC builds it: the
# game died before main on CPUs without it (Core 2, #7264; THIRD_PARTY_NOTICES.md). Drop this and
# the folder once a libmimalloc-sys release has microsoft/mimalloc#1291's fix.
libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
+4 -13
View File
@@ -92,7 +92,7 @@ Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://
<details><summary>Technical</summary>
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -234,11 +234,11 @@ Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggr
#### One single-player path: remove the in-frame world
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
Single player has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.
<details><summary>Technical</summary>
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. [#6473](https://github.com/horizon-rust/horizon/issues/6473) removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -634,16 +634,6 @@ A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https:/
</details>
#### HDR monitors
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
<details><summary>Technical</summary>
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
#### MSAA, anisotropic filtering, exclusive fullscreen
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
@@ -904,6 +894,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
- **HDR monitors** (v0.2.74): Output for HDR monitors: brighter highlights and richer colour where your screen supports it. [#1441](https://github.com/horizon-rust/horizon/issues/1441) · [#2021](https://github.com/horizon-rust/horizon/issues/2021) · [#2292](https://github.com/horizon-rust/horizon/issues/2292)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
+62 -12
View File
@@ -14,6 +14,15 @@ Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
0.1.49 by Octavian Oncescu, with the [mimalloc](https://github.com/microsoft/mimalloc) v3 sources it
bundles (Copyright (c) 2018-2025 Microsoft Corporation, Daan Leijen), both under the MIT license
(`LICENSE.txt` and `c_src/mimalloc/v3/LICENSE` in that folder). It holds only the v3 sources. One
change: `mi_popcount` in `include/mimalloc/bits.h` uses mimalloc's portable bit count on MSVC
x86/x64 instead of the POPCNT instruction, which CPUs before Nehalem lack (#7264).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
@@ -27,6 +36,17 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## Hugeicons
`crates/horizon-theme/icons/glass/*.svg` are icons from the free set of
[Hugeicons](https://github.com/hugeicons/hugeicons) (`@hugeicons/core-free-icons` 4.4.0), under
the MIT license (`crates/horizon-theme/icons/glass/LICENSE-hugeicons.txt`), exported with a 1.6
stroke in `currentColor`.
`crates/horizon-theme/icons/glass/discord.svg` is Discord's logo as drawn by
[Simple Icons](https://github.com/simple-icons/simple-icons) 13.21.0 (CC0 1.0). The logo is Discord's
trademark, used only on the button that opens Horizon's Discord server.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
@@ -41,19 +61,17 @@ trademarks; Horizon is not affiliated with AMD.
## Mod loader logos
The launcher shows the logo of the mod loader an instance plays with.
The launcher shows the logo of the mod loader an instance plays with, and offers the logos as
instance icons.
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
unmodified (the launcher scales it up by whole pixels).
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
fetches them from their own sites when an instance needs them
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
- `launcher/assets/logos/{fabric,quilt,neoforge,forge}.png` are the loaders' logos as the launcher
mockup on [playhorizon.dev/launcher](https://playhorizon.dev/launcher) shows them (horizon-site
`public/media/launcher/*.webp`, converted to PNG), each its project's own mark:
[Fabric](https://fabricmc.net/), [Quilt](https://quiltmc.org/) (its art is
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)),
[NeoForged](https://neoforged.net/) (its art, by Ridanisaurus, is
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)) and
[Minecraft Forge](https://minecraftforge.net/). They name the loader an instance runs.
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
the project endorses Horizon.
@@ -115,3 +133,35 @@ licensed under the MIT license:
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## BlueMap
`crates/horizon-client-render/src/bluemap.rs` reads the lowres tiles of a server's
[BlueMap](https://github.com/BlueMap-Minecraft/BlueMap) web map. It follows three of BlueMap's files:
`core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java` (`saveTile`) for the
tiles' pixel layout, `common/webapp/src/js/util/Utils.js` (`pathFromCoords`) for their paths, and
`core/src/main/java/de/bluecolored/bluemap/core/map/MapSettingsSerializer.java` for a map's
`settings.json`, at commit
[96ae320d1d39](https://github.com/BlueMap-Minecraft/BlueMap/tree/96ae320d1d39581c10058d8222f2290f2fcb5333).
BlueMap is licensed under the MIT license:
> Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
> Copyright (c) contributors
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal
> in the Software without restriction, including without limitation the rights
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
> copies of the Software, and to permit persons to whom the Software is
> furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in
> all copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
> THE SOFTWARE.
+3
View File
@@ -10,5 +10,8 @@ description = "cargo horizon-mod new/build/run/check: make Horizon content mods
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
serde_json = "1.0.151"
[dev-dependencies]
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[lints]
workspace = true
+133 -7
View File
@@ -1,7 +1,8 @@
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
//! items, data and assets, no code. Code mods are native (docs/native-mods.md).
//!
//! - `new <path> [--name "Name"]`: a content mod from the template.
//! - `new <path> [--name "Name"]`: a content mod from the template; with
//! `--template native [--horizon <commit>]`, a native trait mod's bundle.
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
//! and `data/`.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
@@ -32,7 +33,7 @@ fn main() -> ExitCode {
Some("run") => run(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] [--template content|native [--horizon <commit>]] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -74,6 +75,11 @@ fn new(args: &[String]) -> Result<(), String> {
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
match flag(args, "--template") {
None | Some("content") => {}
Some("native") => return new_native(dir, &id, flag(args, "--horizon"), fill),
Some(_) => return Err("--template is content or native".into()),
}
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let files = [
(
@@ -114,11 +120,6 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
if flag(args, "--template").is_some_and(|t| t != "content") {
return Err(
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
);
}
for (file, text) in files {
let file = fill(file);
let file = file.as_str();
@@ -137,6 +138,67 @@ fn new(args: &[String]) -> Result<(), String> {
Ok(())
}
/// A native trait mod's bundle in `dir` (docs/native-mods.md, "Trait mods"): its
/// `horizon-mixin.toml` and an `[api]` crate with one hook. It is what the launcher
/// builds (`native-build <dir>`) and packs as `mixins/<id>/` of a `.hnmod`
/// (docs/api/mod-files.md). `horizon` pins the Horizon commit it is made for.
fn new_native(
dir: &Path,
id: &str,
horizon: Option<&str>,
fill: impl Fn(&str) -> String,
) -> Result<(), String> {
// `[mod] id`, as `horizon-native.json` needs it: the template keeps to its characters.
if id.len() > 64 {
return Err(format!("mod id {id}: 64 characters at most"));
}
let (compatibility, rev) = match horizon {
Some(commit) if horizon_mods::index::native::revision(commit) => (
format!("[compatibility]\nhorizon_revision = \"{commit}\"\n"),
format!(", rev = \"{commit}\""),
),
Some(commit) => {
return Err(format!(
"--horizon {commit}: a Horizon commit is 40 lowercase hex digits"
));
}
// Unpinned, it builds on the instance's own Horizon.
None => (
"# The Horizon commit it is made for; unpinned, it builds on the instance's own.\n\
# [compatibility]\n# horizon_revision = \"<40 hex digits>\"\n"
.to_owned(),
String::new(),
),
};
let files = [
(
"horizon-mixin.toml",
include_str!("../template/native/horizon-mixin.toml"),
),
(
"api/Cargo.toml",
include_str!("../template/native/api.toml"),
),
("api/src/lib.rs", include_str!("../template/native/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
];
for (file, text) in files {
let to = dir.join(file);
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
.map_err(|e| e.to_string())?;
let text = fill(text)
.replace("{compatibility}", &compatibility)
.replace("{rev}", &rev);
std::fs::write(&to, text).map_err(|e| format!("{}: {e}", to.display()))?;
}
println!(
"Created {} (native mod id {id}). Next: horizon-launcher native-build {} --instance <instance> --trust-native-code",
dir.display(),
dir.display()
);
Ok(())
}
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
/// manifest and the files in `assets/` and `data/`.
fn build() -> Result<PathBuf, String> {
@@ -272,4 +334,68 @@ mod tests {
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_native_template_packs_into_an_hnmod_the_registry_accepts() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("horizon-mod-native-{}", std::process::id()));
let path = dir.join("machine-mod");
let commit = "0123456789abcdef0123456789abcdef01234567";
let args = [
path.display().to_string(),
"--template".into(),
"native".into(),
"--horizon".into(),
commit.into(),
];
new(&args).unwrap();
let api = std::fs::read_to_string(path.join("api/Cargo.toml")).unwrap();
assert!(api.contains(&format!("rev = \"{commit}\"")), "{api}");
// Packed as the launcher packs a mod folder: the manifest, then the bundle under
// `mixins/<id>/` (docs/api/mod-files.md).
let manifest = serde_json::json!({
"schema": 1, "id": "machine_mod", "version": "0.1.0", "game": "26.3",
"horizon": commit, "mixins": "b".repeat(40),
});
let hnmod = dir.join("machine_mod.hnmod");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&hnmod).unwrap());
let options = zip::write::SimpleFileOptions::default();
zip.start_file("horizon-native.json", options).unwrap();
zip.write_all(manifest.to_string().as_bytes()).unwrap();
for file in [
"horizon-mixin.toml",
"api/Cargo.toml",
"api/src/lib.rs",
".gitignore",
] {
assert!(horizon_mods::index::native::allowed(file), "{file}");
zip.start_file(format!("mixins/machine_mod/{file}"), options)
.unwrap();
zip.write_all(&std::fs::read(path.join(file)).unwrap())
.unwrap();
}
zip.finish().unwrap();
let report = check::check(&hnmod, check::kind_of(&hnmod).unwrap(), None, None).unwrap();
assert!(report.text().contains("machine_mod"), "{}", report.text());
// A pin that is not a commit is refused; with none, the folder builds on the
// instance's Horizon.
let bad = [
dir.join("bad").display().to_string(),
"--template".into(),
"native".into(),
"--horizon".into(),
"main".into(),
];
assert!(new(&bad).unwrap_err().contains("40 lowercase hex"));
let free = dir.join("free");
new(&[
free.display().to_string(),
"--template".into(),
"native".into(),
])
.unwrap();
let mixin = std::fs::read_to_string(free.join("horizon-mixin.toml")).unwrap();
assert!(!mixin.contains("\nhorizon_revision"), "{mixin}");
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -0,0 +1,9 @@
[package]
name = "mod-{id}"
version = "0.1.0"
edition = "2024"
[dependencies]
# The launcher points this at the Horizon source it builds the game from. Any other
# dependency must be a crate and version in Horizon's Cargo.lock.
horizon-client = { git = "https://github.com/horizon-rust/horizon"{rev} }
@@ -0,0 +1,9 @@
# {name}: a native trait mod (docs/native-mods.md, "Trait mods"). Build it with
# `horizon-launcher native-build <this folder> --instance <instance> --trust-native-code`.
[mod]
id = "{id}"
version = "0.1.0"
{compatibility}
[api]
path = "api"
@@ -0,0 +1,18 @@
//! The trait mod: the launcher links this crate into the game and calls `hooks()`. Each
//! method of `Hooks` is one of Horizon's hook points; implement the ones the mod needs
//! (docs/native-mods.md, "Trait mods").
use horizon_client::mod_api::{Hooks, Host, PlayerLoggedInEvent, Text};
pub fn hooks() -> Box<dyn Hooks> {
Box::new(Mod)
}
struct Mod;
impl Hooks for Mod {
/// A player joined the server (NeoForge's `PlayerLoggedInEvent`): greet them.
fn player_logged_in(&self, host: &mut dyn Host, event: &mut PlayerLoggedInEvent<'_>) {
let line = format!("{id} says hello, {}!", event.name);
host.tell(event.player, Text::literal(line));
}
}
+1
View File
@@ -8,6 +8,7 @@ description = "Horizon version-specific model and resource decoding"
[dependencies]
horizon-core = { path = "../horizon-core" }
log = "0.4"
serde = "1.0.229"
serde_json = { version = "1.0.151", features = ["raw_value"] }
+350 -4
View File
@@ -1,5 +1,10 @@
//! Cuboid geometry in block units, including blockstate rotation and UV locking.
use crate::{json::*, model::*, resolve::ResolvedCuboidModel};
use crate::{
json::*,
model::*,
obj::{MtlTexture, ObjFace, ObjModel},
resolve::{ResolvedCuboidModel, ResolvedLoader},
};
mod transform;
pub use transform::BlockModelTransform;
@@ -13,7 +18,40 @@ pub struct ModelQuad {
pub material: Material,
pub tint_index: i32,
pub shade_direction_override: Option<Direction>,
/// The face's or element's `light_emission`.
pub light_emission: u8,
/// Least (block, sky) light the quad is drawn with: its emission, raised by `neoforge_data`.
pub light: [u8; 2],
/// `neoforge_data` colour (ARGB) multiplied into the quad's tint.
pub color: Option<i32>,
/// `neoforge_data` override of the model's ambient occlusion for this quad.
pub ambient_occlusion: Option<bool>,
pub render_type: Option<RenderType>,
/// False for a loader quad that takes no directional shading.
pub shade: bool,
}
/// NeoForge's `render_type` names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderType {
Solid,
Cutout,
CutoutMipped,
Translucent,
Tripwire,
}
impl RenderType {
/// Accepts a bare or `minecraft:` name; others (mods' own types) are `None`.
pub fn by_name(name: &str) -> Option<Self> {
match name.strip_prefix("minecraft:").unwrap_or(name) {
"solid" => Some(Self::Solid),
"cutout" => Some(Self::Cutout),
"cutout_mipped" => Some(Self::CutoutMipped),
"translucent" => Some(Self::Translucent),
"tripwire" => Some(Self::Tripwire),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug)]
@@ -186,6 +224,17 @@ fn bake_elements(
skip_flat: bool,
) -> Result<Vec<ModelQuad>> {
let mut output = Vec::new();
bake_into(model, transform, skip_flat, &mut output)?;
Ok(output)
}
/// The elements, then what the model's loader adds.
fn bake_into(
model: &ResolvedCuboidModel,
transform: BlockModelTransform,
skip_flat: bool,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
for element in model.elements() {
let element = element
.as_ref()
@@ -207,11 +256,108 @@ fn bake_elements(
force_translucent: false,
});
output.push(bake_face(
element, face, *direction, matrix, material, transform,
element,
face,
*direction,
matrix,
material,
transform,
render_type,
)?);
}
}
Ok(output)
match model.loader() {
Some(ResolvedLoader::Composite(children)) => {
for child in children {
bake_into(child, transform, skip_flat, output)?;
}
}
Some(ResolvedLoader::Obj {
model: Some(obj),
flip_v,
shade,
hidden,
}) => bake_obj(model, obj, *flip_v, *shade, hidden, transform, output)?,
Some(ResolvedLoader::Obj { model: None, .. }) | None => {}
}
Ok(())
}
/// Obj faces are unculled quads in block units; the blockstate transform moves them whole.
fn bake_obj(
model: &ResolvedCuboidModel,
obj: &ObjModel,
flip_v: bool,
shade: bool,
hidden: &std::collections::BTreeSet<String>,
transform: BlockModelTransform,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) {
let slot = match obj.materials.get(&face.material) {
Some(MtlTexture::Sprite(sprite)) => {
output.push(obj_quad(
face,
Material {
sprite: identifier(sprite)?,
force_translucent: false,
},
flip_v,
shade,
render_type,
transform,
));
continue;
}
Some(MtlTexture::Slot(slot)) => slot,
None => &face.material,
};
let material = if slot.is_empty() {
None
} else {
model.material(slot)?
};
let material = material.cloned().unwrap_or(Material {
sprite: "minecraft:missingno".to_owned(),
force_translucent: false,
});
output.push(obj_quad(
face,
material,
flip_v,
shade,
render_type,
transform,
));
}
Ok(())
}
fn obj_quad(
face: &ObjFace,
material: Material,
flip_v: bool,
shade: bool,
render_type: Option<RenderType>,
transform: BlockModelTransform,
) -> ModelQuad {
let positions = face.vertices.map(|v| transform.position(v.position));
ModelQuad {
positions,
uv: face
.vertices
.map(|v| [v.uv[0], if flip_v { 1.0 - v.uv[1] } else { v.uv[1] }]),
facing: facing(&positions).unwrap_or(Direction::Up),
cull: None,
material,
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type,
shade,
}
}
/// `FaceBakery.bakeQuad` for a quad with explicit corners and unit-atlas UVs
/// (`uv` in 0..16 texels), as `ItemModelGenerator` builds its layers.
@@ -230,6 +376,7 @@ pub fn bake_flat_face(
rotation: None,
shade_direction_override: None,
light_emission: 0,
neoforge_data: None,
};
let face = Face {
cull: None,
@@ -237,6 +384,8 @@ pub fn bake_flat_face(
texture: String::new(),
uv: Some(uv),
rotation: 0,
light_emission: None,
neoforge_data: None,
};
bake_face(
&element,
@@ -245,6 +394,7 @@ pub fn bake_flat_face(
None,
material,
BlockModelTransform::new([0; 3], false),
None,
)
}
fn bake_face(
@@ -254,6 +404,7 @@ fn bake_face(
matrix: Option<ElementMatrix>,
material: Material,
transform: BlockModelTransform,
render_type: Option<RenderType>,
) -> Result<ModelQuad> {
let mut positions = corners(element.from, element.to, direction).map(|v| v.map(|c| c / 16.0));
if let (Some(rotation), Some(matrix)) = (&element.rotation, matrix) {
@@ -303,6 +454,11 @@ fn bake_face(
uv.swap(index, found);
}
}
let data = face
.neoforge_data
.unwrap_or_default()
.over(element.neoforge_data.unwrap_or_default());
let light_emission = face.light_emission.unwrap_or(element.light_emission);
Ok(ModelQuad {
positions,
uv,
@@ -311,6 +467,196 @@ fn bake_face(
material,
tint_index: face.tint_index,
shade_direction_override: element.shade_direction_override,
light_emission: element.light_emission,
light_emission,
light: [data.block_light, data.sky_light].map(|l| l.unwrap_or(0).max(light_emission)),
color: data.color,
ambient_occlusion: data.ambient_occlusion,
render_type,
shade: true,
})
}
#[cfg(test)]
mod loader_tests {
use super::*;
use crate::resolve::{AssetReader, ModelLibrary};
use std::{collections::BTreeMap, sync::Arc};
struct Files(BTreeMap<&'static str, &'static str>);
impl AssetReader for Files {
fn read_text(&self, location: &str) -> Option<String> {
self.0.get(location).map(|text| (*text).to_owned())
}
}
fn library(models: &[(&str, &str)], files: &[(&'static str, &'static str)]) -> ModelLibrary {
let models = models
.iter()
.map(|(name, json)| ((*name).to_owned(), CuboidModel::from_json(json).unwrap()))
.collect();
ModelLibrary::new(models)
.unwrap()
.with_assets(Arc::new(Files(files.iter().copied().collect())))
}
fn bake(library: &ModelLibrary, name: &str) -> Vec<ModelQuad> {
bake_block_model(
&library.resolve(name).unwrap(),
BlockModelTransform::new([0; 3], false),
)
.unwrap()
}
fn sprites(quads: &[ModelQuad]) -> Vec<&str> {
quads.iter().map(|q| q.material.sprite.as_str()).collect()
}
const CUBE: &str =
r##"{"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"up":{"texture":"#all"}}}]}"##;
const COMPOSITE: &str = r#"{"loader":"neoforge:composite","textures":{"all":"block/shared"},
"children":{"zeta":{"parent":"t:cube","textures":{"all":"block/own"}},"alpha":{"parent":"t:cube"}}}"#;
#[test]
fn composite_bakes_children_in_document_order_with_slot_fallback() {
let library = library(&[("t:cube", CUBE), ("t:comp", COMPOSITE)], &[]);
let quads = bake(&library, "t:comp");
assert_eq!(
sprites(&quads),
["minecraft:block/own", "minecraft:block/shared"]
);
}
#[test]
fn composite_visibility_hides_a_child() {
let hidden =
COMPOSITE.replace(r#""children""#, r#""visibility":{"zeta":false},"children""#);
let library = library(&[("t:cube", CUBE), ("t:comp", &hidden)], &[]);
assert_eq!(
sprites(&bake(&library, "t:comp")),
["minecraft:block/shared"]
);
}
#[test]
fn an_inheriting_model_overrides_which_children_are_visible() {
let hidden =
COMPOSITE.replace(r#""children""#, r#""visibility":{"zeta":false},"children""#);
let shown = r#"{"parent":"t:hidden","visibility":{"zeta":true,"alpha":false}}"#;
let library = library(
&[("t:cube", CUBE), ("t:hidden", &hidden), ("t:shown", shown)],
&[],
);
assert_eq!(sprites(&bake(&library, "t:shown")), ["minecraft:block/own"]);
}
#[test]
fn loader_is_inherited_by_parent_after_own_elements() {
let derived = r##"{"parent":"t:comp","textures":{"all":"block/derived"},
"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"down":{"texture":"#all"}}}]}"##;
let library = library(
&[
("t:cube", CUBE),
("t:comp", COMPOSITE),
("t:derived", derived),
],
&[],
);
let quads = bake(&library, "t:derived");
assert_eq!(quads[0].facing, Direction::Down);
assert_eq!(
sprites(&quads),
[
"minecraft:block/derived",
"minecraft:block/own",
"minecraft:block/derived"
]
);
}
#[test]
fn own_loader_wins_and_unknown_loader_adds_nothing() {
let other = r#"{"parent":"t:comp","loader":"somemod:thing"}"#;
let library = library(
&[("t:cube", CUBE), ("t:comp", COMPOSITE), ("t:other", other)],
&[],
);
assert!(bake(&library, "t:other").is_empty());
}
#[test]
fn composites_that_nest_through_themselves_are_an_error() {
let looping = r#"{"loader":"neoforge:composite","children":{"a":{"parent":"t:loop"}}}"#;
let library = library(&[("t:loop", looping)], &[]);
assert!(library.resolve("t:loop").is_err());
}
const OBJ: &str = "mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 1 0 1\nv 0 0 1\nv 0 1 0\n\
vt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
usemtl top\nf 1/1 4/4 3/3 2/2\nusemtl side\nf 1/1 2/2 5/4\nusemtl bare\nf 1/1 2/2 5/4\n";
const MTL: &str = "newmtl top\nmap_Kd #lid\nnewmtl side\nmap_Kd #wall\nnewmtl bare\nKd 1 1 1\n";
const OBJ_FILES: [(&str, &str); 2] = [("t:models/x.obj", OBJ), ("t:models/m.mtl", MTL)];
const OBJ_MODEL: &str = r#"{"loader":"neoforge:obj","model":"t:models/x.obj","flip_v":true,
"textures":{"lid":"block/lid","wall":"block/wall"}}"#;
#[test]
fn obj_faces_become_quads_with_materials_and_flipped_v() {
let flat = OBJ_MODEL.replace(r#""flip_v":true,"#, "");
let library = library(&[("t:o", OBJ_MODEL), ("t:flat", &flat)], &OBJ_FILES);
let quads = bake(&library, "t:o");
assert_eq!(
sprites(&quads),
[
"minecraft:block/lid",
"minecraft:block/wall",
"minecraft:missingno"
]
);
assert_eq!(quads[0].facing, Direction::Up);
assert_eq!(quads[1].facing, Direction::South);
assert_eq!(quads[0].positions[1], [0.0, 0.0, 1.0]);
assert_eq!(quads[0].uv[0], [0.0, 1.0]);
assert_eq!(quads[0].uv[2], [1.0, 0.0]);
// A triangle repeats its last vertex.
assert_eq!(quads[1].positions[3], quads[1].positions[2]);
assert_eq!(quads[1].uv[3], quads[1].uv[2]);
assert!(quads.iter().all(|q| q.cull.is_none() && q.shade));
assert_eq!(bake(&library, "t:flat")[0].uv[0], [0.0, 0.0]);
}
#[test]
fn obj_shade_quads_false_and_mtl_override() {
let unshaded = r#"{"loader":"neoforge:obj","model":"t:models/x.obj","shade_quads":false,
"mtl_override":"t:models/o.mtl","textures":{"s":"block/s"}}"#;
let files = [
("t:models/x.obj", OBJ),
("t:models/o.mtl", "newmtl top\nmap_Kd #s\n"),
];
let library = library(&[("t:u", unshaded)], &files);
let quads = bake(&library, "t:u");
assert!(quads.iter().all(|q| !q.shade));
assert_eq!(quads[0].material.sprite, "minecraft:block/s");
}
#[test]
fn missing_or_broken_obj_bakes_nothing() {
let missing = library(&[("t:o", OBJ_MODEL)], &[]);
assert!(bake(&missing, "t:o").is_empty());
let broken = library(
&[("t:o", OBJ_MODEL)],
&[("t:models/x.obj", "v 0 0 0\nf 1 2 3\n")],
);
assert!(bake(&broken, "t:o").is_empty());
}
#[test]
fn obj_visibility_skips_the_objects_a_model_hides() {
let two = "v 0 0 0\nv 1 0 0\nv 1 0 1\nv 0 0 1\no a\nusemtl m\nf 1 2 3 4\no b\nf 4 3 2 1\n";
let files = [("t:models/two.obj", two)];
let base =
r#"{"loader":"neoforge:obj","model":"t:models/two.obj","textures":{"m":"block/m"}}"#;
let hiding = r#"{"parent":"t:base","visibility":{"b":false}}"#;
let restoring = r#"{"parent":"t:hiding","visibility":{"b":true}}"#;
let library = library(
&[
("t:base", base),
("t:hiding", hiding),
("t:restoring", restoring),
],
&files,
);
assert_eq!(bake(&library, "t:base").len(), 2);
let quads = bake(&library, "t:hiding");
assert_eq!(quads.len(), 1);
assert_eq!(quads[0].facing, Direction::Down);
assert_eq!(bake(&library, "t:restoring").len(), 2);
}
}
+54
View File
@@ -17,6 +17,7 @@ use std::{
};
mod component_predicate;
mod foreign;
mod special;
pub use special::{SpecialKind, SpecialModel};
@@ -431,6 +432,17 @@ impl ItemModelNode {
pub fn decode(v: &Value) -> Result<Self> {
let m = object(v)?;
let kind = ident(required(m, "type")?)?;
if kind == "minecraft:condition" {
let property = ident(required(m, "property")?)?;
if !property.starts_with("minecraft:") {
return Ok(Self::Condition {
transformation: optional_transformation(m)?,
property: ConditionProperty::Custom(property),
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
});
}
}
Ok(match kind.as_str() {
"minecraft:empty" => Self::Empty,
"minecraft:model" => Self::Model(ModelLeaf {
@@ -451,12 +463,21 @@ impl ItemModelNode {
.collect::<Result<_>>()?,
transformation: optional_transformation(m)?,
},
"minecraft:condition" if foreign::property_is_foreign(m)? => foreign::wrap(
Self::decode(required(m, "on_false")?)?,
optional_transformation(m)?,
),
"minecraft:condition" => Self::Condition {
transformation: optional_transformation(m)?,
property: ConditionProperty::decode(m)?,
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
},
"minecraft:select" if foreign::property_is_foreign(m)? => {
// No case can match a property Horizon cannot read.
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
foreign::wrap(fallback.unwrap_or(Self::Empty), optional_transformation(m)?)
}
"minecraft:select" => {
let transformation = optional_transformation(m)?;
let property = SelectProperty::decode(m)?;
@@ -484,6 +505,7 @@ impl ItemModelNode {
}
}
"minecraft:range_dispatch" => {
let foreign_property = foreign::property_is_foreign(m)?;
let mut entries = list(required(m, "entries")?)?
.iter()
.map(|e| {
@@ -496,6 +518,17 @@ impl ItemModelNode {
.collect::<Result<Vec<_>>>()?;
// `Comparator.comparingDouble`: Double.compare, a total order.
entries.sort_by(|a, b| f64::from(a.0).total_cmp(&f64::from(b.0)));
if foreign_property {
// A property Horizon cannot read is 0.
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
let chosen = last_index_less_or_equal(&entries, 0.0)
.map(|i| entries.swap_remove(i).1)
.or(fallback);
return Ok(foreign::wrap(
chosen.unwrap_or(Self::Empty),
optional_transformation(m)?,
));
}
Self::RangeDispatch {
transformation: optional_transformation(m)?,
property: NumericProperty::decode(m)?,
@@ -506,12 +539,21 @@ impl ItemModelNode {
.transpose()?,
}
}
"minecraft:special" if SpecialModel::is_foreign(required(m, "model")?) => {
// The mod's own renderer is not run: the base model stands in.
Self::Model(ModelLeaf {
model: ident(required(m, "base")?)?,
transformation: optional_transformation(m)?,
tints: Vec::new(),
})
}
"minecraft:special" => Self::Special {
base: ident(required(m, "base")?)?,
transformation: optional_transformation(m)?,
model: SpecialModel::decode(required(m, "model")?)?,
},
"minecraft:bundle/selected_item" => Self::BundleSelectedItem,
other if foreign::is_foreign(other) => foreign::decode_node(other, m),
_ => return Err(err("unknown item model type")),
})
}
@@ -603,6 +645,8 @@ impl TintSource {
"minecraft:firework" => Self::Firework(default()?),
"minecraft:potion" => Self::Potion(default()?),
"minecraft:team" => Self::Team(default()?),
// A mod's own tint source, which its Java code computes: untinted here.
other if foreign::is_foreign(other) => Self::Constant(-1),
_ => return Err(err("unknown item tint source")),
})
}
@@ -614,6 +658,7 @@ impl TintSource {
#[derive(Clone, Debug, PartialEq)]
pub enum ConditionProperty {
Custom(String),
CustomModelData(i32),
UsingItem,
Broken,
@@ -946,6 +991,10 @@ pub struct Context<'a> {
/// Host services the resolver cannot derive from data alone.
pub trait ItemEnv {
/// A mod's namespaced conditional property, false when no mod supplies it.
fn item_model_property(&self, _property: &str, _stack: &StackView) -> bool {
false
}
/// `PotionContents.getColorOr` for the potion/effects part: the mixed
/// effect colour, or `None` when the contents have no visible effects.
fn potion_effect_color(&self, _potion_contents: &Value) -> Option<i32> {
@@ -1299,6 +1348,9 @@ impl Resolve<'_> {
fn condition(&self, property: &ConditionProperty) -> bool {
let owner = self.ctx.owner;
match property {
ConditionProperty::Custom(property) => {
self.env.item_model_property(property, self.stack)
}
ConditionProperty::CustomModelData(i) => {
self.component("minecraft:custom_model_data")
.and_then(|d| custom_data_at(d, "flags", *i))
@@ -1717,5 +1769,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
Err(low as usize)
}
#[cfg(test)]
mod foreign_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,114 @@
//! Nodes and properties of other namespaces (a mod's, NeoForge's) decode to the closest vanilla behaviour.
use super::{
ItemModelNode, ModelLeaf, Result, TintSource, get, ident, identifier, list, object,
optional_transformation,
};
use crate::{json::error as err, math::Mat4};
use serde_json::{Map, Value};
/// Keys a mod's node usually keeps its real model under, in order of preference.
const MODEL_KEYS: [&str; 4] = ["model", "base_model", "base", "fallback"];
pub fn is_foreign(id: &str) -> bool {
!id.starts_with("minecraft:")
}
/// A composite carries the transformation a collapsed node had.
pub fn wrap(node: ItemModelNode, transformation: Option<Mat4>) -> ItemModelNode {
match transformation {
None => node,
Some(_) => ItemModelNode::Composite {
models: vec![node],
transformation,
},
}
}
pub fn property_is_foreign(m: &Map<String, Value>) -> Result<bool> {
let property = m
.get("property")
.ok_or_else(|| err("missing property"))
.and_then(ident)?;
Ok(is_foreign(&property))
}
fn leaf(model: String, m: &Map<String, Value>) -> ItemModelNode {
let tints = get(m, "tints")
.and_then(|t| list(t).ok())
.map(|t| {
t.iter()
.filter_map(|t| TintSource::decode(t).ok())
.collect()
})
.unwrap_or_default();
ItemModelNode::Model(ModelLeaf {
model,
transformation: optional_transformation(m).ok().flatten(),
tints,
})
}
/// A node of an unknown non-vanilla type: its nested node, else its model id, else nothing.
pub fn decode_node(kind: &str, m: &Map<String, Value>) -> ItemModelNode {
if kind == "neoforge:fluid_container" {
return fluid_container(m);
}
for key in MODEL_KEYS {
match m.get(key) {
Some(nested @ Value::Object(_)) => {
if let Ok(node) = ItemModelNode::decode(nested) {
return wrap(node, optional_transformation(m).ok().flatten());
}
}
Some(Value::String(id)) => {
if let Ok(id) = identifier(id) {
return leaf(id, m);
}
}
_ => {}
}
}
let by_suffix = m
.iter()
.filter(|(k, _)| k.ends_with("_model"))
.find_map(|(_, v)| v.as_str().and_then(|id| identifier(id).ok()));
match by_suffix {
Some(id) => leaf(id, m),
None => ItemModelNode::Empty,
}
}
// The fluid is not drawn: only the base (and an unmasked cover) texture, taken as a model id (a bucket's).
fn fluid_container(m: &Map<String, Value>) -> ItemModelNode {
let textures = m.get("textures").and_then(|t| object(t).ok());
let texture = |key: &str| {
textures
.and_then(|t| t.get(key))
.and_then(Value::as_str)
.and_then(|id| identifier(id).ok())
};
let mut layers = vec![texture("base")];
if m.get("cover_is_mask") == Some(&Value::Bool(false)) {
layers.push(texture("cover"));
}
let mut models: Vec<ItemModelNode> = layers
.into_iter()
.flatten()
.map(|model| {
ItemModelNode::Model(ModelLeaf {
model,
transformation: None,
tints: Vec::new(),
})
})
.collect();
let transformation = optional_transformation(m).ok().flatten();
match models.len() {
0 => ItemModelNode::Empty,
1 => wrap(models.remove(0), transformation),
_ => ItemModelNode::Composite {
models,
transformation,
},
}
}
@@ -0,0 +1,185 @@
use super::*;
fn node(json: &str) -> Result<ItemModelNode> {
ItemModelNode::decode(&serde_json::from_str(json).unwrap())
}
fn model_id(n: &ItemModelNode) -> &str {
match n {
ItemModelNode::Model(leaf) => &leaf.model,
other => panic!("not a model: {other:?}"),
}
}
#[test]
fn node_with_a_model_id_draws_that_model() {
let n = node(r#"{"type":"mod:thing","model":"mod:item/thing","tints":[{"type":"mod:tint"}]}"#)
.unwrap();
assert_eq!(model_id(&n), "mod:item/thing");
let ItemModelNode::Model(leaf) = n else {
unreachable!()
};
assert_eq!(leaf.tints, vec![TintSource::Constant(-1)]);
let n = node(r#"{"type":"mod:thing","base_model":"mod:item/base"}"#).unwrap();
assert_eq!(model_id(&n), "mod:item/base");
let n = node(r#"{"type":"mod:thing","on_model":"mod:item/a"}"#).unwrap();
assert_eq!(model_id(&n), "mod:item/a");
}
#[test]
fn node_with_a_nested_node_decodes_it() {
let n = node(
r#"{"type":"neoforge:trimmed_armor","base_model":{"type":"minecraft:model","model":"mod:item/boots"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "mod:item/boots");
let n = node(
r#"{"type":"mod:x","model":{"type":"mod:y","model":"mod:item/inner"},"transformation":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}"#,
)
.unwrap();
let ItemModelNode::Composite {
models,
transformation,
} = n
else {
panic!("a transformation wraps the nested node")
};
assert!(transformation.is_some());
assert_eq!(model_id(&models[0]), "mod:item/inner");
}
#[test]
fn node_with_nothing_usable_is_empty() {
assert!(matches!(
node(r#"{"type":"mod:facade"}"#).unwrap(),
ItemModelNode::Empty
));
assert!(matches!(
node(r#"{"type":"mod:x","model":7}"#).unwrap(),
ItemModelNode::Empty
));
assert!(ClientItem::from_json(r#"{"model":{"type":"ae2:facade"}}"#).is_ok());
}
#[test]
fn unknown_vanilla_names_still_fail() {
assert!(node(r#"{"type":"minecraft:nope"}"#).is_err());
assert!(node(r#"{"type":"nope","model":"a:b"}"#).is_err());
let leaf = r#"{"type":"minecraft:model","model":"a:b"}"#;
for json in [
format!(
r#"{{"type":"minecraft:condition","property":"minecraft:nope","on_true":{leaf},"on_false":{leaf}}}"#
),
format!(
r#"{{"type":"minecraft:select","property":"minecraft:nope","cases":[],"fallback":{leaf}}}"#
),
format!(
r#"{{"type":"minecraft:range_dispatch","property":"minecraft:nope","entries":[],"fallback":{leaf}}}"#
),
] {
assert!(node(&json).is_err(), "{json}");
}
assert!(
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:nope"}}"#)
.is_err()
);
assert!(ClientItem::from_json(r#"{"model":{"type":"minecraft:nope"}}"#).is_err());
}
#[test]
fn condition_property_is_false() {
let n = node(
r#"{"type":"minecraft:condition","property":"mod:flag",
"on_true":{"type":"minecraft:model","model":"a:yes"},
"on_false":{"type":"minecraft:model","model":"a:no"}}"#,
)
.unwrap();
// A mod's property stays a condition its mod answers per stack
// (`item_model_property`); with no mod answering it, `on_false` is drawn.
let ItemModelNode::Condition {
property: ConditionProperty::Custom(property),
on_false,
..
} = &n
else {
panic!("not a custom condition: {n:?}")
};
assert_eq!(property, "mod:flag");
assert_eq!(model_id(on_false), "a:no");
}
#[test]
fn select_property_takes_the_fallback_or_nothing() {
let n = node(
r#"{"type":"minecraft:select","property":"mod:level",
"cases":[{"when":"low","model":{"type":"minecraft:model","model":"a:low"}}],
"fallback":{"type":"minecraft:model","model":"a:other"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "a:other");
let n = node(r#"{"type":"minecraft:select","property":"mod:level","cases":[]}"#).unwrap();
assert!(matches!(n, ItemModelNode::Empty));
}
#[test]
fn range_property_reads_zero() {
let entry = |t: f32, m: &str| {
format!(r#"{{"threshold":{t},"model":{{"type":"minecraft:model","model":"{m}"}}}}"#)
};
let fallback = r#","fallback":{"type":"minecraft:model","model":"a:fb"}"#;
let make = |entries: &[String], fallback: &str| {
node(&format!(
r#"{{"type":"minecraft:range_dispatch","property":"mod:fill","entries":[{}]{fallback}}}"#,
entries.join(",")
))
.unwrap()
};
let n = make(&[entry(0.2, "a:one"), entry(0.4, "a:two")], fallback);
assert_eq!(model_id(&n), "a:fb");
let n = make(
&[
entry(0.0, "a:zero"),
entry(-1.0, "a:neg"),
entry(0.5, "a:half"),
],
fallback,
);
assert_eq!(model_id(&n), "a:zero");
let n = make(&[entry(0.2, "a:one")], "");
assert!(matches!(n, ItemModelNode::Empty));
}
#[test]
fn special_type_keeps_its_base_model() {
let n = node(
r#"{"type":"minecraft:special","base":"mod:item/cube","model":{"type":"mod:energy_cube"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "mod:item/cube");
let n =
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:shield"}}"#)
.unwrap();
assert!(matches!(n, ItemModelNode::Special { .. }));
}
#[test]
fn fluid_container_draws_its_base_without_the_fluid() {
let n = node(
r#"{"type":"neoforge:fluid_container","fluid":"mod:gas","cover_is_mask":false,"flip_gas":true,
"textures":{"base":"minecraft:item/bucket","fluid":"neoforge:item/mask/bucket_fluid"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "minecraft:item/bucket");
let n = node(
r#"{"type":"neoforge:fluid_container","cover_is_mask":false,
"textures":{"base":"a:item/flask","cover":"a:item/flask_cover"}}"#,
)
.unwrap();
let ItemModelNode::Composite { models, .. } = n else {
panic!("base and cover")
};
assert_eq!(models.len(), 2);
assert!(matches!(
node(r#"{"type":"neoforge:fluid_container"}"#).unwrap(),
ItemModelNode::Empty
));
}
@@ -77,6 +77,13 @@ fn one_of(v: &Value, names: &[&str]) -> Result<String> {
}
impl SpecialModel {
/// A special model type of another namespace, which Horizon has no renderer for.
pub fn is_foreign(v: &Value) -> bool {
v.get("type")
.and_then(Value::as_str)
.and_then(|t| super::identifier(t).ok())
.is_some_and(|t| super::foreign::is_foreign(&t))
}
pub fn decode(v: &Value) -> Result<Self> {
let m = object(v)?;
let float_or = |key: &str, default: f32| get(m, key).map_or(Ok(default), float);
@@ -18,3 +18,96 @@ fn display_context_names_round_trip() {
assert_eq!(DisplayContext::by_name(name).unwrap().name(), name);
}
}
#[test]
fn a_mods_tint_source_is_untinted_and_an_unknown_vanilla_one_fails() {
let tint = |json: &str| TintSource::decode(&serde_json::from_str(json).unwrap());
assert_eq!(
tint(r#"{"type":"ae2:storage_cell_state"}"#).unwrap(),
TintSource::Constant(-1)
);
assert!(tint(r#"{"type":"minecraft:nonsense"}"#).is_err());
}
#[test]
fn custom_condition_is_evaluated_per_stack_at_branch_selection() {
struct Env;
impl ItemEnv for Env {
fn item_model_property(&self, property: &str, stack: &StackView) -> bool {
assert_eq!(property, "example:charged");
stack.count > 1 && stack.patch.get("example:charged") == Some(&Some(Value::Bool(true)))
}
}
let definition = ClientItem::from_json(
r#"{"model":{
"type":"minecraft:condition", "property":"example:charged",
"on_true":{"type":"minecraft:model","model":"example:item/charged"},
"on_false":{"type":"minecraft:model","model":"example:item/idle"}
}}"#,
)
.unwrap();
let mut models = ItemModels::default();
models.definitions.insert("example:tool".into(), definition);
models.prototypes.insert(
"example:tool".into(),
BTreeMap::from([(
"minecraft:item_model".into(),
Value::String("example:tool".into()),
)]),
);
let context = Context {
display: DisplayContext::Gui,
owner: None,
level: None,
seed: 0,
};
let mut stack = StackView::new("example:tool", 1);
let chosen = |stack: &StackView, env: &dyn ItemEnv| {
let resolved = models.resolve(stack, &context, env);
assert_eq!(resolved.layers.len(), 1);
let LayerContent::Model { model } = &resolved.layers[0].content else {
panic!("expected model leaf")
};
model.clone()
};
assert_eq!(chosen(&stack, &NoEnv), "example:item/idle");
assert_eq!(chosen(&stack, &Env), "example:item/idle");
stack
.patch
.insert("example:charged".into(), Some(Value::Bool(true)));
assert_eq!(chosen(&stack, &Env), "example:item/idle");
stack.count = 2;
assert_eq!(chosen(&stack, &Env), "example:item/charged");
assert_eq!(chosen(&stack, &NoEnv), "example:item/idle");
stack.patch.insert("example:charged".into(), None);
assert_eq!(chosen(&stack, &Env), "example:item/idle");
}
#[test]
fn custom_condition_validates_its_name_and_both_branches() {
let node = |property: &str, on_true: Value| {
ItemModelNode::decode(&serde_json::json!({
"type": "minecraft:condition", "property": property,
"on_true": on_true, "on_false": {"type": "minecraft:empty"}
}))
};
let empty = serde_json::json!({"type": "minecraft:empty"});
for name in [
"minecraft:unknown",
"unknown",
"example:Bad",
"bad name:charged",
] {
assert!(node(name, empty.clone()).is_err(), "{name}");
}
assert!(node("example:charged", Value::Null).is_err());
assert!(matches!(
node("minecraft:damaged", empty.clone()).unwrap(),
ItemModelNode::Condition {
property: ConditionProperty::Damaged,
..
}
));
assert!(matches!(node("example:charged", empty).unwrap(),
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
}
+33
View File
@@ -87,6 +87,39 @@ pub(crate) fn object(raw: &RawValue) -> Result<Object<'_>> {
}
Ok(serde_json::from_str(raw.get())?)
}
/// The members of an object in document order, which `object` loses.
pub(crate) fn ordered_object(raw: &RawValue) -> Result<Vec<(String, &RawValue)>> {
struct Members<'a>(Vec<(String, &'a RawValue)>);
impl<'de> serde::Deserialize<'de> for Members<'de> {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Members<'de>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("an object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut members: Vec<(String, &'de RawValue)> = Vec::new();
while let Some(entry) = map.next_entry::<String, &'de RawValue>()? {
members.retain(|(name, _)| *name != entry.0);
members.push(entry);
}
Ok(Members(members))
}
}
deserializer.deserialize_map(Visitor)
}
}
if !raw.get().starts_with('{') {
return Err(error("expected object"));
}
Ok(serde_json::from_str::<Members>(raw.get())?.0)
}
pub(crate) fn array(raw: &RawValue) -> Result<Vec<&RawValue>> {
Ok(serde_json::from_str(raw.get())?)
}
+1
View File
@@ -9,6 +9,7 @@ pub mod item_model;
mod json;
pub mod math;
pub mod model;
pub mod obj;
pub mod palette;
pub mod resolve;
+172 -1
View File
@@ -5,7 +5,7 @@
//! does; render preparation must diagnose these before baking.
use crate::json::*;
use serde_json::value::RawValue;
use std::collections::BTreeMap;
use std::{collections::BTreeMap, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Direction {
@@ -59,11 +59,92 @@ pub enum TextureSlot {
#[derive(Clone, Debug, PartialEq)]
pub struct CuboidModel {
pub parent: Option<String>,
pub loader: Option<Loader>,
pub ambient_occlusion: Option<bool>,
pub gui_light: Option<GuiLight>,
pub textures: BTreeMap<String, TextureSlot>,
pub elements: Option<Vec<Option<Element>>>,
pub display: Option<ItemTransforms>,
/// NeoForge's `render_type`, without a `minecraft:` prefix.
pub render_type: Option<String>,
/// A model's `visibility`: an obj object or composite child name to shown; absent is shown.
pub visibility: BTreeMap<String, bool>,
}
/// A custom model loader's payload; a model inherits its parent's loader unless it names its own.
#[derive(Clone, Debug, PartialEq)]
pub enum Loader {
/// `neoforge:composite`: children in document order.
Composite {
children: Vec<(String, Arc<CuboidModel>)>,
/// Child name to shown; a child absent from the map is shown.
visibility: BTreeMap<String, bool>,
},
/// `neoforge:obj`: `model` and `mtl_override` are asset locations.
Obj {
model: String,
flip_v: bool,
shade_quads: Option<bool>,
mtl_override: Option<String>,
},
/// A loader this decoder does not know: it adds no geometry.
Unknown(String),
}
/// NeoForge's `neoforge_data` of an element or a face; a face's keys win.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct NeoforgeData {
pub block_light: Option<u8>,
pub sky_light: Option<u8>,
/// ARGB.
pub color: Option<i32>,
pub ambient_occlusion: Option<bool>,
}
impl NeoforgeData {
/// `self` over `base`, key by key.
pub fn over(self, base: Self) -> Self {
Self {
block_light: self.block_light.or(base.block_light),
sky_light: self.sky_light.or(base.sky_light),
color: self.color.or(base.color),
ambient_occlusion: self.ambient_occlusion.or(base.ambient_occlusion),
}
}
fn decode(raw: &RawValue) -> Result<Self> {
let obj = object(raw)?;
let light = |key: &str| -> Result<Option<u8>> {
Ok(obj
.get(key)
.map(|r| integer(r))
.transpose()?
.map(|v| v.clamp(0, 15) as u8))
};
// Some mods spell both lights as one `light_emission`.
let emission = light("light_emission")?;
let color = match obj.get("color") {
Some(raw) if raw.get().starts_with('"') => {
let text = string(raw)?;
let text = text.trim();
let hex = text
.strip_prefix('#')
.or_else(|| text.strip_prefix("0x"))
.or_else(|| text.strip_prefix("0X"));
match hex {
Some(hex) => u32::from_str_radix(hex, 16).ok().map(|v| v as i32),
None => text.parse::<u32>().ok().map(|v| v as i32),
}
}
Some(raw) => Some(integer(raw)?),
None => None,
};
Ok(Self {
block_light: light("block_light")?.or(emission),
sky_light: light("sky_light")?.or(emission),
color,
ambient_occlusion: obj
.get("ambient_occlusion")
.map(|r| boolean(r))
.transpose()?,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Element {
@@ -73,6 +154,7 @@ pub struct Element {
pub rotation: Option<ElementRotation>,
pub shade_direction_override: Option<Direction>,
pub light_emission: u8,
pub neoforge_data: Option<NeoforgeData>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Face {
@@ -82,6 +164,9 @@ pub struct Face {
pub uv: Option<[f32; 4]>,
/// Normalized counterclockwise quadrants, in the range 0..4.
pub rotation: u8,
/// A face-level `light_emission`, which wins over its element's.
pub light_emission: Option<u8>,
pub neoforge_data: Option<NeoforgeData>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Axis {
@@ -154,6 +239,11 @@ impl CuboidModel {
} else {
Some(identifier(&parent)?)
};
let loader = obj
.get("loader")
.filter(|raw| raw.get() != "null")
.map(|raw| Loader::decode(raw, &obj))
.transpose()?;
let ambient_occlusion = obj
.get("ambientocclusion")
.map(|r| boolean(r))
@@ -185,15 +275,83 @@ impl CuboidModel {
.get("display")
.map(|r| ItemTransforms::decode(r))
.transpose()?;
let render_type = obj
.get("render_type")
.filter(|raw| raw.get().starts_with('"'))
.map(|raw| string(raw))
.transpose()?
.map(|name| name.strip_prefix("minecraft:").unwrap_or(&name).to_owned());
Ok(Self {
parent,
loader,
ambient_occlusion,
gui_light,
textures,
elements,
display,
render_type,
visibility: visibility(&obj)?,
})
}
/// The models this one needs besides itself: its parent and its composite children's parents.
pub fn referenced_models(&self) -> Vec<String> {
let mut names: Vec<String> = self.parent.iter().cloned().collect();
if let Some(Loader::Composite { children, .. }) = &self.loader {
for (_, child) in children {
names.extend(child.referenced_models());
}
}
names
}
}
fn visibility(obj: &Object<'_>) -> Result<BTreeMap<String, bool>> {
let mut visibility = BTreeMap::new();
if let Some(raw) = obj.get("visibility") {
for (key, raw) in object(raw)? {
visibility.insert(key, boolean(raw)?);
}
}
Ok(visibility)
}
impl Loader {
fn decode(raw: &RawValue, obj: &Object<'_>) -> Result<Self> {
let name = primitive_string(raw)?;
match name.as_str() {
"neoforge:composite" => {
let mut children = Vec::new();
if let Some(raw) = obj.get("children") {
for (name, raw) in ordered_object(raw)? {
children.push((name, Arc::new(CuboidModel::decode(raw)?)));
}
}
Ok(Self::Composite {
children,
visibility: visibility(obj)?,
})
}
"neoforge:obj" => {
let optional = |key: &str| {
obj.get(key)
.filter(|raw| raw.get() != "null")
.map(|raw| primitive_string(raw))
.transpose()
};
Ok(Self::Obj {
model: identifier(&optional("model")?.ok_or_else(|| error("missing model"))?)?,
flip_v: obj
.get("flip_v")
.map(|r| boolean(r))
.transpose()?
.unwrap_or(false),
shade_quads: obj.get("shade_quads").map(|r| boolean(r)).transpose()?,
mtl_override: optional("mtl_override")?
.map(|name| identifier(&name))
.transpose()?,
})
}
_ => Ok(Self::Unknown(name)),
}
}
}
fn nullable<T>(raw: &RawValue, decode: impl FnOnce(&RawValue) -> Result<T>) -> Result<Option<T>> {
if raw.get() == "null" {
@@ -273,6 +431,10 @@ impl Element {
rotation,
shade_direction_override,
light_emission: light_emission as u8,
neoforge_data: obj
.get("neoforge_data")
.map(|r| NeoforgeData::decode(r))
.transpose()?,
})
}
}
@@ -306,6 +468,15 @@ impl Face {
texture,
uv,
rotation: (rotation / 90) as u8,
light_emission: obj
.get("light_emission")
.map(|r| integer(r))
.transpose()?
.map(|v| v.clamp(0, 15) as u8),
neoforge_data: obj
.get("neoforge_data")
.map(|r| NeoforgeData::decode(r))
.transpose()?,
})
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Wavefront OBJ and MTL text, as obj-loader models use them.
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ObjVertex {
/// Block units.
pub position: [f32; 3],
/// Texture coordinates as written, 0..1 across the sprite.
pub uv: [f32; 2],
}
/// One quad; a triangle repeats its last vertex.
#[derive(Clone, Debug, PartialEq)]
pub struct ObjFace {
/// Empty before any `usemtl`.
pub material: String,
/// The `o` (else `g`) it belongs to, which a model's `visibility` names.
pub object: String,
pub vertices: [ObjVertex; 4],
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParsedObj {
pub mtllib: Option<String>,
pub faces: Vec<ObjFace>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MtlTexture {
/// `map_Kd #name`: a texture slot of the model.
Slot(String),
/// `map_Kd namespace:path`: a sprite.
Sprite(String),
}
/// A parsed OBJ with its materials.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ObjModel {
pub faces: Vec<ObjFace>,
pub materials: BTreeMap<String, MtlTexture>,
}
pub fn parse_obj(text: &str) -> Result<ParsedObj, String> {
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut uvs: Vec<[f32; 2]> = Vec::new();
let mut parsed = ParsedObj::default();
let mut material = String::new();
let mut object = String::new();
for (number, line) in text.lines().enumerate() {
let mut words = line.split_whitespace();
let Some(keyword) = words.next() else {
continue;
};
let at = |message: &str| format!("line {}: {message}", number + 1);
match keyword {
_ if keyword.starts_with('#') => {}
"v" => positions.push(floats::<3>(words).ok_or_else(|| at("bad vertex"))?),
"vt" => {
let mut words = words;
let u = words.next().and_then(|w| w.parse().ok());
let v = words.next().map_or(Some(0.0), |w| w.parse().ok());
let (Some(u), Some(v)) = (u, v) else {
return Err(at("bad texture coordinate"));
};
uvs.push([u, v]);
}
"mtllib" => parsed.mtllib = words.next().map(str::to_owned),
"usemtl" => material = words.next().unwrap_or("").to_owned(),
"o" | "g" => object = words.next().unwrap_or("").to_owned(),
"f" => {
let mut corners = Vec::new();
for word in words {
let mut parts = word.split('/');
let position = index(parts.next(), positions.len())
.ok_or_else(|| at("bad vertex index"))?;
let uv = match parts.next() {
None | Some("") => [0.0; 2],
some => {
uvs[index(some, uvs.len()).ok_or_else(|| at("bad texture index"))?]
}
};
corners.push(ObjVertex {
position: positions[position],
uv,
});
}
if corners.len() < 3 {
return Err(at("face needs three vertices"));
}
let mut push = |vertices: [ObjVertex; 4]| {
parsed.faces.push(ObjFace {
material: material.clone(),
object: object.clone(),
vertices,
});
};
if corners.len() <= 4 {
let last = corners[corners.len() - 1];
push(std::array::from_fn(|i| {
corners.get(i).copied().unwrap_or(last)
}));
} else {
for i in 1..corners.len() - 1 {
push([corners[0], corners[i], corners[i + 1], corners[i + 1]]);
}
}
}
_ => {}
}
}
Ok(parsed)
}
fn floats<'a, const N: usize>(mut words: impl Iterator<Item = &'a str>) -> Option<[f32; N]> {
let mut result = [0.0; N];
for value in &mut result {
*value = words.next()?.parse().ok()?;
}
Some(result)
}
/// A 1-based index, or a negative one counted from the end.
fn index(word: Option<&str>, len: usize) -> Option<usize> {
let value: i64 = word?.parse().ok()?;
let index = if value < 0 {
len as i64 + value
} else {
value - 1
};
(0..len as i64).contains(&index).then_some(index as usize)
}
/// Material name to its `map_Kd`; other statements are ignored.
pub fn parse_mtl(text: &str) -> BTreeMap<String, MtlTexture> {
let mut materials = BTreeMap::new();
let mut current = None;
for line in text.lines() {
let mut words = line.split_whitespace();
match words.next() {
Some("newmtl") => current = words.next().map(str::to_owned),
Some("map_Kd") => {
let (Some(name), Some(value)) = (&current, words.last()) else {
continue;
};
let texture = match value.strip_prefix('#') {
Some(slot) => MtlTexture::Slot(slot.to_owned()),
None => MtlTexture::Sprite(value.to_owned()),
};
materials.insert(name.clone(), texture);
}
_ => {}
}
}
materials
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quads_triangles_and_negative_indices() {
let text = "mtllib a.mtl\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
usemtl first\nf 1/1/1 2/2/1 3/3/1 4/4/1\nusemtl second\nf -4//1 -3//1 -2//1\n";
let parsed = parse_obj(text).unwrap();
assert_eq!(parsed.mtllib.as_deref(), Some("a.mtl"));
assert_eq!(parsed.faces.len(), 2);
assert_eq!(parsed.faces[0].material, "first");
assert_eq!(parsed.faces[0].vertices[2].uv, [1.0, 1.0]);
let triangle = &parsed.faces[1];
assert_eq!(triangle.material, "second");
assert_eq!(triangle.vertices[0].position, [0.0, 0.0, 0.0]);
assert_eq!(triangle.vertices[3], triangle.vertices[2]);
assert_eq!(triangle.vertices[2].position, [1.0, 1.0, 0.0]);
}
#[test]
fn bad_indices_are_errors() {
assert!(parse_obj("v 0 0 0\nf 1 2 3\n").is_err());
assert!(parse_obj("v 0 0 0\nv 1 0 0\nf 1 2\n").is_err());
}
#[test]
fn mtl_maps_slots_and_sprites() {
let materials =
parse_mtl("newmtl a\nKd 1 1 1\nmap_Kd #top\nnewmtl b\nmap_Kd mod:block/x\nnewmtl c\n");
assert_eq!(materials["a"], MtlTexture::Slot("top".into()));
assert_eq!(materials["b"], MtlTexture::Sprite("mod:block/x".into()));
assert!(!materials.contains_key("c"));
}
}
+280 -22
View File
@@ -1,18 +1,45 @@
//! Cuboid parent inheritance and texture-slot resolution.
use crate::{json::*, model::*};
use crate::{
json::*,
model::*,
obj::{ObjModel, parse_mtl, parse_obj},
};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
sync::{Arc, Mutex},
};
pub const MISSING_MODEL: &str = "minecraft:builtin/missing";
/// `ItemModelGenerator.GENERATED_ITEM_MODEL_ID`.
pub const GENERATED_MODEL: &str = "minecraft:builtin/generated";
/// Composite children nest through parents that may name the composite again.
const MAX_LOADER_DEPTH: u8 = 8;
/// Reads asset files that models point at (`namespace:path` is `assets/namespace/path`).
pub trait AssetReader: Send + Sync {
fn read_text(&self, location: &str) -> Option<String>;
}
/// Decoded models are immutable and their inherited geometry is shared.
pub struct ModelLibrary {
models: BTreeMap<String, Arc<CuboidModel>>,
missing: Arc<CuboidModel>,
assets: Option<Arc<dyn AssetReader>>,
/// Keyed by obj and mtl override; `None` is a file that failed, already warned about.
objs: Mutex<BTreeMap<(String, Option<String>), Option<Arc<ObjModel>>>>,
}
/// What a model's loader adds to its own elements.
#[derive(Clone, Debug)]
pub enum ResolvedLoader {
/// The shown children, resolved on their own, in order.
Composite(Vec<ResolvedCuboidModel>),
Obj {
/// `None` when the file failed to load.
model: Option<Arc<ObjModel>>,
flip_v: bool,
shade: bool,
/// Objects the model chain's `visibility` hides.
hidden: BTreeSet<String>,
},
}
#[derive(Clone, Debug)]
pub struct ResolvedCuboidModel {
@@ -22,10 +49,17 @@ pub struct ResolvedCuboidModel {
pub textures: BTreeMap<String, Material>,
/// Cyclic and dangling aliases are absent from `textures`.
pub unresolved_textures: BTreeSet<String>,
/// NeoForge's `render_type`; the leaf-most model that sets one wins.
pub render_type: Option<String>,
geometry: Option<Arc<CuboidModel>>,
generated: bool,
loader: Option<ResolvedLoader>,
}
impl ResolvedCuboidModel {
/// Geometry from a custom loader, baked after the elements.
pub fn loader(&self) -> Option<&ResolvedLoader> {
self.loader.as_ref()
}
/// Whether the top geometry is `builtin/generated`'s layer extrusion
/// (no model closer to the leaf has elements of its own).
pub fn uses_item_generator(&self) -> bool {
@@ -66,6 +100,7 @@ impl ModelLibrary {
fn item_generator_model() -> CuboidModel {
CuboidModel {
parent: None,
loader: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
@@ -74,6 +109,8 @@ impl ModelLibrary {
)]),
elements: None,
display: None,
render_type: None,
visibility: BTreeMap::new(),
}
}
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
@@ -100,8 +137,15 @@ impl ModelLibrary {
Ok(Self {
models: decoded,
missing,
assets: None,
objs: Mutex::default(),
})
}
/// Where `neoforge:obj` models find their files.
pub fn with_assets(mut self, assets: Arc<dyn AssetReader>) -> Self {
self.assets = Some(assets);
self
}
/// This library plus `extra` models; an `extra` model replaces a
/// same-named one, but not the built-in missing and generated models.
/// Every existing model is shared, not copied.
@@ -122,13 +166,15 @@ impl ModelLibrary {
Ok(Self {
models,
missing: Arc::clone(&self.missing),
assets: self.assets.clone(),
objs: Mutex::default(),
})
}
/// Follow the complete parent chain. A missing resource uses the built-in
/// model; a cyclic chain is rejected, as native discovery excludes it.
/// The returned model can outlive this library without copying its elements.
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, None)
self.resolve_name(name, None)
}
/// Whether `name` and every parent it reaches are models of this library,
/// with no cycle: `resolve` puts the missing model in for any other.
@@ -163,28 +209,40 @@ impl ModelLibrary {
name: &str,
child: &BTreeMap<String, TextureSlot>,
) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, Some(child))
self.resolve_name(name, Some(child))
}
fn resolve_chain(
fn resolve_name(
&self,
name: &str,
child: Option<&BTreeMap<String, TextureSlot>>,
) -> Result<ResolvedCuboidModel> {
let mut name = identifier(name)?;
let next = Some(identifier(name)?);
self.resolve_chain(Vec::new(), Vec::new(), next, &BTreeMap::new(), child, 0)
}
/// `chain` and `names` start with models that are not in the library (a
/// composite child); `next` is the parent to follow. `fallback` slots lose
/// to any slot the chain defines.
fn resolve_chain(
&self,
mut chain: Vec<Arc<CuboidModel>>,
mut names: Vec<String>,
mut next: Option<String>,
fallback: &BTreeMap<String, TextureSlot>,
child: Option<&BTreeMap<String, TextureSlot>>,
depth: u8,
) -> Result<ResolvedCuboidModel> {
if depth > MAX_LOADER_DEPTH {
return Err(error("composite models nest too deeply"));
}
let mut visited = BTreeSet::new();
let mut chain = Vec::new();
let mut names = Vec::new();
loop {
while let Some(name) = next.take() {
if !visited.insert(name.clone()) {
return Err(error("cyclic model parent"));
}
let model = self.models.get(&name).unwrap_or(&self.missing);
chain.push(model);
names.push(name.clone());
match &model.parent {
Some(parent) => name = parent.clone(),
None => break,
}
chain.push(Arc::clone(model));
names.push(name);
next = model.parent.clone();
}
let ambient_occlusion = chain
.iter()
@@ -194,10 +252,8 @@ impl ModelLibrary {
.iter()
.find_map(|m| m.gui_light)
.unwrap_or(GuiLight::Side);
let geometry = chain
.iter()
.find(|m| m.elements.is_some())
.map(|m| Arc::clone(m));
let render_type = chain.iter().find_map(|m| m.render_type.clone());
let geometry = chain.iter().find(|m| m.elements.is_some()).map(Arc::clone);
let generated = chain
.iter()
.zip(&names)
@@ -216,13 +272,68 @@ impl ModelLibrary {
};
// Resolve aliases only after applying every child override. A reference
// inherited from a parent binds to the child's final slot definition.
let mut slots = BTreeMap::new();
for model in chain.into_iter().rev() {
let mut slots: BTreeMap<String, &TextureSlot> =
fallback.iter().map(|(k, v)| (k.clone(), v)).collect();
for model in chain.iter().rev() {
slots.extend(model.textures.iter().map(|(k, v)| (k.clone(), v)));
}
if let Some(child) = child {
slots.extend(child.iter().map(|(k, v)| (k.clone(), v)));
}
let loader = match chain.iter().find_map(|m| m.loader.as_ref()) {
Some(Loader::Composite {
children,
visibility,
}) => {
let inherited: BTreeMap<String, TextureSlot> = slots
.iter()
.map(|(k, v)| (k.clone(), (*v).clone()))
.collect();
// A model closer to the leaf overrides the loader's own map.
let mut visibility = visibility.clone();
for model in chain.iter().rev() {
visibility.extend(model.visibility.iter().map(|(k, v)| (k.clone(), *v)));
}
let mut resolved = Vec::new();
for (child_name, child_model) in children {
if visibility.get(child_name) == Some(&false) {
continue;
}
resolved.push(self.resolve_chain(
vec![Arc::clone(child_model)],
vec![format!("{}#{child_name}", names[0])],
child_model.parent.clone(),
&inherited,
None,
depth + 1,
)?);
}
Some(ResolvedLoader::Composite(resolved))
}
Some(Loader::Obj {
model,
flip_v,
shade_quads,
mtl_override,
}) => {
// Leaf wins: a model closer to the leaf overrides its parents' entries.
let mut visibility = BTreeMap::new();
for model in chain.iter().rev() {
visibility.extend(model.visibility.iter().map(|(k, v)| (k.clone(), *v)));
}
Some(ResolvedLoader::Obj {
model: self.load_obj(&names[0], model, mtl_override.as_deref()),
flip_v: *flip_v,
shade: shade_quads.unwrap_or(true),
hidden: visibility
.into_iter()
.filter(|(_, shown)| !shown)
.map(|(name, _)| name)
.collect(),
})
}
Some(Loader::Unknown(_)) | None => None,
};
let mut textures = BTreeMap::<String, Material>::new();
let mut unresolved_textures = BTreeSet::<String>::new();
for start in slots.keys() {
@@ -264,12 +375,61 @@ impl ModelLibrary {
display,
textures,
unresolved_textures,
render_type,
geometry,
generated,
loader,
})
}
/// Parse an obj and its materials once; a failure warns once and yields no geometry.
fn load_obj(&self, label: &str, model: &str, mtl: Option<&str>) -> Option<Arc<ObjModel>> {
let key = (model.to_owned(), mtl.map(str::to_owned));
let mut cache = self.objs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(found) = cache.get(&key) {
return found.clone();
}
let loaded = self.read_obj(model, mtl);
if let Err(message) = &loaded {
log::warn!("Horizon: obj model {label} ({model}): {message}");
}
let loaded = loaded.ok().map(Arc::new);
cache.insert(key, loaded.clone());
loaded
}
fn read_obj(&self, model: &str, mtl: Option<&str>) -> std::result::Result<ObjModel, String> {
let assets = self.assets.as_ref().ok_or("no asset source")?;
let text = assets.read_text(model).ok_or("file not found")?;
let parsed = parse_obj(&text)?;
let mtl = match (mtl, &parsed.mtllib) {
(Some(location), _) => Some(location.to_owned()),
(None, Some(file)) if file.contains(':') => Some(file.clone()),
(None, Some(file)) => {
let (namespace, path) = model.split_once(':').unwrap_or(("minecraft", model));
let directory = path.rsplit_once('/').map_or("", |(dir, _)| dir);
Some(if directory.is_empty() {
format!("{namespace}:{file}")
} else {
format!("{namespace}:{directory}/{file}")
})
}
(None, None) => None,
};
let materials = match mtl {
Some(location) => match assets.read_text(&location) {
Some(text) => parse_mtl(&text),
None => {
log::warn!("Horizon: obj material file {location} not found");
BTreeMap::new()
}
},
None => BTreeMap::new(),
};
Ok(ObjModel {
faces: parsed.faces,
materials,
})
}
}
/// The native missing-model cube uses slot names without a leading '#'.
pub fn missing_model() -> CuboidModel {
let faces = Direction::ALL
@@ -283,12 +443,15 @@ pub fn missing_model() -> CuboidModel {
texture: "missingno".to_owned(),
uv: Some([0.0, 0.0, 16.0, 16.0]),
rotation: 0,
light_emission: None,
neoforge_data: None,
}),
)
})
.collect();
CuboidModel {
parent: None,
loader: None,
ambient_occlusion: None,
gui_light: None,
elements: Some(vec![Some(Element {
@@ -298,10 +461,12 @@ pub fn missing_model() -> CuboidModel {
rotation: None,
shade_direction_override: None,
light_emission: 0,
neoforge_data: None,
})]),
display: Some(ItemTransforms {
contexts: std::array::from_fn(|_| DisplayTransform::Inherit),
}),
render_type: None,
textures: BTreeMap::from([
(
"particle".to_owned(),
@@ -315,6 +480,7 @@ pub fn missing_model() -> CuboidModel {
}),
),
]),
visibility: BTreeMap::new(),
}
}
@@ -568,4 +734,96 @@ mod tests {
assert!(!library.resolves("item/a"));
assert!(!library.resolves("Item/Stick"));
}
const LIGHTS: &str = r##"{"textures":{"a":"block/a"},"render_type":"minecraft:cutout","elements":[
{"from":[0,0,0],"to":[16,16,16],"light_emission":4,"neoforge_data":{"block_light":9,"color":-65536,"ambient_occlusion":false},
"faces":{"north":{"texture":"#a","neoforge_data":{"block_light":2,"sky_light":15,"unknown":1}},
"south":{"texture":"#a","light_emission":11},
"up":{"texture":"#a"}}}]}"##;
#[test]
fn neoforge_data_of_a_face_overrides_its_element_key_by_key() {
use crate::bake::bake_block_model;
let library = library(&[("block/lights", LIGHTS)]);
let model = library.resolve("block/lights").unwrap();
let quads =
bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false)).unwrap();
let by = |d| quads.iter().find(|q| q.facing == d).unwrap();
let north = by(Direction::North);
assert_eq!(north.light, [4, 15]);
assert_eq!(north.color, Some(-65536));
assert_eq!(north.ambient_occlusion, Some(false));
assert_eq!(by(Direction::South).light, [11, 11]);
assert_eq!(by(Direction::South).light_emission, 11);
assert_eq!(by(Direction::Up).light, [9, 4]);
assert!(
quads
.iter()
.all(|q| q.render_type == Some(crate::bake::RenderType::Cutout))
);
}
#[test]
fn vanilla_models_bake_without_extra_face_data() {
use crate::bake::bake_block_model;
let library = library(&[(
"block/plain",
r##"{"textures":{"a":"block/a"},"elements":[{"from":[0,0,0],"to":[16,16,16],"light_emission":6,"faces":{"up":{"texture":"#a"}}}]}"##,
)]);
let model = library.resolve("block/plain").unwrap();
let quad = &bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false))
.unwrap()[0];
assert_eq!((quad.light, quad.light_emission), ([6, 6], 6));
assert_eq!(
(quad.color, quad.ambient_occlusion, quad.render_type),
(None, None, None)
);
}
#[test]
fn render_type_comes_from_the_leaf_most_model_and_loses_its_prefix() {
let library = library(&[
("block/base", r#"{"render_type":"translucent"}"#),
("block/mid", r#"{"parent":"block/base"}"#),
(
"block/leaf",
r#"{"parent":"block/mid","render_type":"minecraft:cutout_mipped"}"#,
),
("block/bare", r#"{"parent":"block/mid"}"#),
]);
let render = |name| library.resolve(name).unwrap().render_type;
assert_eq!(render("block/leaf").as_deref(), Some("cutout_mipped"));
assert_eq!(render("block/bare").as_deref(), Some("translucent"));
assert_eq!(render("block/absent"), None);
assert_eq!(
crate::bake::RenderType::by_name("minecraft:tripwire"),
Some(crate::bake::RenderType::Tripwire)
);
assert_eq!(crate::bake::RenderType::by_name("mod:glow"), None);
}
#[test]
fn neoforge_data_colour_and_legacy_emission_spellings() {
let model = CuboidModel::from_json(
r##"{"elements":[{"from":[0,0,0],"to":[1,1,1],"faces":{
"up":{"texture":"#a","neoforge_data":{"color":"#80FF0000","light_emission":7}},
"down":{"texture":"#a","neoforge_data":{"color":4294967295,"block_light":99}}}}]}"##,
)
.unwrap();
let elements = model.elements.unwrap();
let faces = &elements[0].as_ref().unwrap().faces;
let up = faces[&Direction::Up]
.as_ref()
.unwrap()
.neoforge_data
.unwrap();
assert_eq!(
(up.color, up.block_light, up.sky_light),
(Some(0x80FF0000u32 as i32), Some(7), Some(7))
);
let down = faces[&Direction::Down]
.as_ref()
.unwrap()
.neoforge_data
.unwrap();
assert_eq!(
(down.color, down.block_light, down.sky_light),
(Some(-1), Some(15), None)
);
}
}
+5 -1
View File
@@ -125,7 +125,11 @@ impl Filter {
Some(w) => (w, true),
None => (word, false),
};
f.allowed.push((normalise::word(word), prefix));
// An allow entry with no letters would allow every word (an empty prefix).
let runs = normalise::word(word);
if !runs.is_empty() {
f.allowed.push((runs, prefix));
}
continue;
}
let (kind, word) = match line.as_bytes()[0] {
+8
View File
@@ -319,3 +319,11 @@ fn a_message_costs_little() {
}
assert!(start.elapsed().as_millis() < 2000, "{:?}", start.elapsed());
}
#[test]
fn an_allow_line_without_letters_allows_nothing() {
for line in ["!*", "!-*", "!", "!-"] {
let f = Filter::new(&format!("nigger\n{line}\n"));
assert!(f.matches("nigger"), "{line}");
}
}
+111 -5
View File
@@ -215,7 +215,7 @@ fn untrusted_http_url(url: &str) -> bool {
})
}
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
/// `ClickEvent.CODEC` plus the legacy `value` field TextComponentHoverAndClickEventFix
/// renames (`url` / `command` / `page`).
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
@@ -249,6 +249,13 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
Some("custom") => Some(crate::text::ClickEvent::Custom {
id: crate::identifier::parse_identifier(&text("id")?)?,
payload: e
.get("payload")
.filter(|value| !value.is_null())
.map(crate::scoreboard::json_to_tag),
}),
_ => None,
}
}
@@ -257,10 +264,7 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
let mut style = Style::default();
if let Some(c) = o.get("color").and_then(Value::as_str) {
style.color = named_color(c).or_else(|| {
c.strip_prefix('#')
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
style.color = named_color(c).or_else(|| hex_color(c));
}
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
style.bold = style_flag(o.get("bold"));
@@ -303,6 +307,13 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
style
}
/// `TextColor.parseColor` for `#...`: `Integer.parseInt(rest, 16)` (so a sign
/// and any digit count are accepted), kept only within `0..=0xFFFFFF`.
pub(crate) fn hex_color(c: &str) -> Option<u32> {
let value = i64::from_str_radix(c.strip_prefix('#')?, 16).ok()?;
(0..=0xFF_FFFF).contains(&value).then_some(value as u32)
}
/// `Style.shadow_color`: a packed ARGB int or normalized RGBA array.
pub(crate) fn shadow_color(value: &Value) -> Option<u32> {
if let Some(packed) = value.as_i64() {
@@ -485,6 +496,33 @@ pub fn name_text(tag: &Tag) -> Text {
mod tests {
use super::*;
#[test]
fn hex_color_follows_text_color_parse_color() {
for bad in [
"#1000000",
"#FFFFFFFF",
"#-FF",
"#",
"#zz",
"FFFFFF",
"#99999999999",
] {
assert_eq!(hex_color(bad), None, "{bad}");
}
for (good, want) in [
("#FFFFFF", 0xFFFFFF),
("#000000", 0),
("#F", 15),
("#+FF", 255),
("#-0", 0),
] {
assert_eq!(hex_color(good), Some(want), "{good}");
}
let out = serde_json::json!({"text": "A", "color": "#1000000"});
let text = to_text(&out, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.color, None);
}
#[test]
fn shadow_color_accepts_packed_and_rgba_styles() {
let packed = serde_json::json!({"text": "A", "shadow_color": -16711936});
@@ -585,6 +623,63 @@ mod tests {
assert_eq!(text.spans[0].style.underlined, Some(true));
}
#[test]
fn custom_click_events_keep_payloads_and_inherit_to_inline_objects() {
use crate::text::ClickEvent;
use horizon_world::nbt::Tag;
for (payload, expected) in [
(None, None),
(Some(serde_json::Value::Null), None),
(Some(serde_json::json!(42)), Some(Tag::Int(42))),
(
Some(serde_json::json!({"shop": "potions", "amount": 3})),
Some(Tag::Compound(
[
("amount".into(), Tag::Int(3)),
("shop".into(), Tag::String("potions".into())),
]
.into_iter()
.collect(),
)),
),
] {
let mut event = serde_json::json!({"action": "custom", "id": "test:shop"});
if let Some(payload) = payload {
event["payload"] = payload;
}
let component = serde_json::json!({
"text": "", "click_event": event,
"extra": [{"object": "atlas", "sprite": "minecraft:item/potion"}, "Shop"]
});
let text = to_text(&component, &mut |_| Text::empty());
for (_, style) in text.chars() {
assert_eq!(
style.click,
Some(ClickEvent::Custom {
id: "test:shop".into(),
payload: expected.clone(),
})
);
}
let encoded = text.component();
let decoded = to_text(&encoded, &mut |_| Text::empty());
assert_eq!(decoded, text);
if expected.is_none() {
assert!(!encoded.to_string().contains("\"payload\""));
}
}
for id in ["Bad ID", "Uppercase:shop"] {
let text = to_text(
&serde_json::json!({
"text": "Shop", "click_event": {"action": "custom", "id": id}
}),
&mut |_| Text::empty(),
);
assert_eq!(text.spans[0].style.click, None);
}
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
@@ -624,4 +719,15 @@ mod tests {
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
#[test]
fn name_text_preserves_legacy_and_decodes_components() {
let legacy = Tag::String("§6Golden Name".into());
let text = name_text(&legacy);
assert_eq!(text.plain(), "Golden Name");
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
let text = name_text(&comp);
assert_eq!(text.plain(), "Custom Name");
}
}
+51 -12
View File
@@ -125,19 +125,51 @@ pub const RULES: &[Rule] = &[
b("universal_anger", "mobs", false),
b("water_source_conversion", "updates", true),
];
/// GameRules.MAX_MINECART_SPEED (26.3), available with minecart improvements.
pub const MAX_MINECART_SPEED: Rule = i("max_minecart_speed", "misc", 8, 1, 1000);
pub const MINECART_IMPROVEMENTS: &str = "minecraft:minecart_improvements";
pub fn available_rules(minecart_improvements: bool) -> impl Iterator<Item = &'static Rule> {
RULES
.iter()
.chain(minecart_improvements.then_some(&MAX_MINECART_SPEED))
}
pub fn rule(name: &str) -> Option<&'static Rule> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
RULES.iter().find(|r| r.name == name)
available_rules(true).find(|r| r.name == name)
}
/// Current values (defaults unless set).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GameRules {
values: BTreeMap<&'static str, Value>,
minecart_improvements: bool,
}
impl GameRules {
pub fn with_features(features: &[String]) -> Self {
Self {
minecart_improvements: features.iter().any(|f| f == MINECART_IMPROVEMENTS),
..Self::default()
}
}
/// GameRules.copy(enabledFeatures): keep available values, add defaults for
/// newly enabled rules and discard rules absent from the target feature set.
pub fn copy_with_features(&self, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
for (&name, &value) in &self.values {
rules.set(name, value);
}
rules
}
pub fn available_rules(&self) -> impl Iterator<Item = &'static Rule> + use<> {
available_rules(self.minecart_improvements)
}
pub fn get(&self, name: &str) -> Option<Value> {
let r = rule(name)?;
if r.name == MAX_MINECART_SPEED.name && !self.minecart_improvements {
return None;
}
Some(self.values.get(r.name).copied().unwrap_or(r.default))
}
pub fn bool(&self, name: &str) -> bool {
@@ -160,7 +192,9 @@ impl GameRules {
}
/// Set a rule; false if the name or value type is wrong.
pub fn set(&mut self, name: &str, value: Value) -> bool {
let Some(r) = rule(name) else { return false };
let Some(r) = rule(name).filter(|r| self.get(r.name).is_some()) else {
return false;
};
let ok = match (r.default, value) {
(Value::Bool(_), Value::Bool(_)) => true,
(Value::Int(_), Value::Int(v)) => (r.min..=r.max).contains(&v),
@@ -176,8 +210,7 @@ impl GameRules {
/// Every rule's value by name without namespace, as `save::Level` holds
/// them (booleans as 0 or 1).
pub fn by_name(&self) -> BTreeMap<String, i32> {
RULES
.iter()
self.available_rules()
.map(|r| {
let v = match self.values.get(r.name).copied().unwrap_or(r.default) {
Value::Bool(b) => i32::from(b),
@@ -189,8 +222,8 @@ impl GameRules {
}
/// GameRuleMap codec: `{data: {"minecraft:<rule>": value...}, DataVersion}`.
pub fn to_nbt(&self) -> NamedTag {
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = RULES
.iter()
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = self
.available_rules()
.map(|r| {
let v = self.values.get(r.name).copied().unwrap_or(r.default);
let tag = match v {
@@ -212,11 +245,14 @@ impl GameRules {
}
}
pub fn from_nbt(root: &Tag) -> Self {
let mut rules = Self::default();
Self::from_nbt_with_features(root, &[])
}
pub fn from_nbt_with_features(root: &Tag, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
let Some(data) = root.get("data") else {
return rules;
};
for r in RULES {
for r in rules.available_rules() {
let key = format!("minecraft:{}", r.name);
let value = data.get(&key).or_else(|| data.get(r.name));
match (r.default, value) {
@@ -232,15 +268,18 @@ impl GameRules {
rules
}
pub fn load(world: &Path) -> Self {
Self::load_with_features(world, &[])
}
pub fn load_with_features(world: &Path, features: &[String]) -> Self {
let Ok(bytes) = fs::read(world.join("data/minecraft/game_rules.dat")) else {
return Self::default();
return Self::with_features(features);
};
let Ok(raw) = Compression::Gzip.decode(&bytes, 16 << 20) else {
return Self::default();
return Self::with_features(features);
};
match nbt::read_named(&raw, Limits::default()) {
Ok((root, _)) => Self::from_nbt(&root.tag),
Err(_) => Self::default(),
Ok((root, _)) => Self::from_nbt_with_features(&root.tag, features),
Err(_) => Self::with_features(features),
}
}
pub fn save(&self, world: &Path) -> crate::Result<()> {
+18
View File
@@ -79,9 +79,27 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
"assets/horizon/lang/hi_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hi_in.json")
}
"assets/horizon/lang/id_id.json" => {
include_str!("../../horizon-client/assets/horizon/lang/id_id.json")
}
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
}
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
}
"assets/horizon/lang/pt_pt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/pt_pt.json")
}
"assets/horizon/lang/ur_pk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ur_pk.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
+4 -7
View File
@@ -107,10 +107,7 @@ pub fn style_from_json(v: &Value) -> Style {
let mut s = Style::default();
let Some(o) = v.as_object() else { return s };
if let Some(c) = o.get("color").and_then(Value::as_str) {
s.color = text::named_color(c).or_else(|| {
c.strip_prefix('#')
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
s.color = text::named_color(c).or_else(|| component::hex_color(c));
}
s.shadow_color = o.get("shadow_color").and_then(component::shadow_color);
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
@@ -1341,9 +1338,9 @@ fn decompose(c: &Comp) -> (serde_json::Map<String, Value>, Vec<Comp>) {
}
"color" => {
let color = v.as_str().map(|s| match s.strip_prefix('#') {
Some(hex) => match u32::from_str_radix(hex, 16) {
Ok(rgb) => format!("#{rgb:06X}"),
Err(_) => s.to_owned(),
Some(_) => match component::hex_color(s) {
Some(rgb) => format!("#{rgb:06X}"),
None => s.to_owned(),
},
None => s.to_owned(),
});
+36 -2
View File
@@ -39,7 +39,7 @@ pub fn named_color(name: &str) -> Option<u32> {
COLORS.iter().find(|c| c.1 == name).map(|c| c.2)
}
/// Click actions carried by locally constructed text components.
/// Click actions carried by text components.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClickEvent {
/// `ClickEvent.OpenUrl`: an http or https link.
@@ -53,6 +53,11 @@ pub enum ClickEvent {
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
/// dialog itself as the component carried it.
ShowDialog(horizon_world::nbt::Tag),
/// `ClickEvent.Custom`: a server-defined action with an optional NBT payload.
Custom {
id: String,
payload: Option<horizon_world::nbt::Tag>,
},
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
@@ -241,6 +246,28 @@ impl Text {
t.source = Some(Value::Object(c).into());
t
}
/// Component.translatableWithFallback(key, fallback, args): `translate`, with `fallback` as
/// the template when the language has no `key`.
pub fn translate_with_fallback(key: &str, fallback: &str, args: &[Text]) -> Self {
let template = lang::current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| fallback.to_owned());
let mut t = format_translation(&template, args);
let mut c = Map::new();
c.insert("translate".into(), key.into());
c.insert("fallback".into(), fallback.into());
if !args.is_empty() {
c.insert("with".into(), args.iter().map(Text::argument).collect());
}
t.source = Some(Value::Object(c).into());
t
}
/// CommonComponents.optionNameValue: "Caption: value" joined as the language joins them
/// (`options.generic_value`: "%s : %s" in French, "%s:%s" in Chinese). English's "%s: %s"
/// when the language has no such key, as without prepared resources.
pub fn option_name_value(caption: Text, value: Text) -> Self {
Self::translate_with_fallback("options.generic_value", "%s: %s", &[caption, value])
}
/// A translatable message whose arguments are plain strings.
pub fn tr(key: &str, args: &[&dyn std::fmt::Display]) -> Self {
let args: Vec<Text> = args.iter().map(|a| Text::literal(a.to_string())).collect();
@@ -539,7 +566,7 @@ const STYLE_KEYS: [&str; 11] = [
"insertion",
"font",
];
/// Style.Serializer: the fields a style sets.
/// `Style.Serializer`: the fields a style sets.
fn style_json(style: &Style) -> Vec<(String, Value)> {
let mut out = Vec::new();
if let Some(c) = style.color {
@@ -581,6 +608,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
"action": "show_dialog",
"dialog": crate::scoreboard::tag_to_json(dialog),
}),
ClickEvent::Custom { id, payload } => {
let mut event = json!({ "action": "custom", "id": id });
if let Some(payload) = payload {
event["payload"] = crate::scoreboard::tag_to_json(payload);
}
event
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
+37 -2
View File
@@ -12,13 +12,48 @@ pub fn uuid_text(u: [i32; 4]) -> String {
)
}
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 {
let mut hex = [0u8; 32];
let mut len = 0;
for b in s.bytes() {
if b == b'-' {
continue;
}
if !b.is_ascii_hexdigit() || len >= 32 {
return None;
}
hex[len] = b;
len += 1;
}
if len != 32 {
return None;
}
let hex = std::str::from_utf8(&hex).ok()?;
let mut out = [0i32; 4];
for (i, v) in out.iter_mut().enumerate() {
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uuid_from_text_rejects_multibyte_and_non_hex() {
assert_eq!(uuid_from_text("€€€€€€€€€€aa"), None);
assert_eq!(uuid_from_text("€€€€€€€€€€aa----"), None);
assert_eq!(uuid_from_text("+0000001-0000-0000-0000-000000000000"), None);
assert_eq!(uuid_from_text("00000000-0000-0000-0000-00000000000g"), None);
assert_eq!(uuid_from_text(""), None);
}
#[test]
fn uuid_from_text_parses_hex_and_dashes() {
let u = [0x01234567, -0x76543211, 0x01234567, -0x76543211];
let text = uuid_text(u);
assert_eq!(uuid_from_text(&text), Some(u));
let undashed: String = text.chars().filter(|c| *c != '-').collect();
assert_eq!(uuid_from_text(&undashed), Some(u));
}
}
+203 -2
View File
@@ -182,13 +182,38 @@ impl LimiterClock for SystemClock {
i64::try_from(self.origin.elapsed().as_nanos()).unwrap_or(i64::MAX)
}
fn park(&mut self, nanos: i64) {
std::thread::sleep(std::time::Duration::from_nanos(nanos.max(0) as u64));
let end = self.nanos().saturating_add(nanos);
park_in_steps(
end,
|| self.nanos(),
|step| {
std::thread::sleep(std::time::Duration::from_nanos(step as u64));
},
);
}
fn spin(&mut self) {
std::hint::spin_loop();
}
}
/// The longest single sleep of [`SystemClock::park`].
const PARK_STEP_NS: i64 = 1_000_000;
/// Sleeps until `now()` reaches `end`, at most [`PARK_STEP_NS`] at a time. macOS lets
/// a sleep run late by a share of its length (timer coalescing: 1.8-3.5 ms on a 14 ms
/// sleep, 0.25 ms on 1 ms), more than the limiter's overshoot margin can learn (capped
/// at 2 ms): a 60 FPS cap gave frames of 17-20 ms. Short steps keep the lateness to
/// the last step's.
fn park_in_steps(end: i64, mut now: impl FnMut() -> i64, mut sleep: impl FnMut(i64)) {
loop {
let left = end - now();
if left <= 0 {
return;
}
sleep(left.min(PARK_STEP_NS));
}
}
/// The real clock without busy waiting: where vanilla spins on
/// `Thread.onSpinWait` for the last half millisecond, this sleeps in short
/// steps, so a capped frame rate leaves the core idle.
@@ -218,6 +243,18 @@ pub fn end_frame(
}
}
/// [`end_frame`] on a fixed grid of frame slots ([`FramerateLimiter::limit_on_grid`]):
/// Horizon's Efficient pacing.
pub fn end_frame_on_grid(
limiter: &mut FramerateLimiter,
framerate_limit: i32,
clock: &mut impl LimiterClock,
) {
if framerate_limit < 260 {
limiter.limit_on_grid(framerate_limit, clock);
}
}
impl FramerateLimiter {
pub fn new(now_ns: i64) -> Self {
Self::with_state(now_ns, 0, 0)
@@ -248,6 +285,37 @@ impl FramerateLimiter {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
self.wait_until(target_time_ns, clock);
self.last_frame_time = clock.nanos();
}
/// Horizon's Efficient pacing: [`Self::limit_display_fps`]'s wait, but each frame's
/// slot starts where the last one's ended (its deadline), not when the thread woke
/// up. Vanilla's way adds every late wake-up to the frame (on the Mac, 50 us sleeps
/// running 0.2 ms late made a 144 FPS cap 139); on a fixed grid the cap holds on
/// average. A frame that ends past its slot, or wakes up more than half a frame
/// late, starts the grid again from now: no frame is rushed out to catch up.
pub fn limit_on_grid(&mut self, framerate_limit: i32, clock: &mut impl LimiterClock) {
let period = 1_000_000_000 / i64::from(framerate_limit);
if framerate_limit != self.last_framerate_limit {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
let deadline = self.last_frame_time + period;
if clock.nanos() >= deadline {
self.last_frame_time = clock.nanos();
return;
}
self.wait_until(deadline, clock);
let woke = clock.nanos();
self.last_frame_time = if woke - deadline > period / 2 {
woke
} else {
deadline
};
}
/// `limitDisplayFPS`'s wait: sleep to within the learned overshoot and half a
/// millisecond of `target_time_ns`, then spin.
fn wait_until(&mut self, target_time_ns: i64, clock: &mut impl LimiterClock) {
loop {
let remaining = target_time_ns - clock.nanos();
if remaining <= 0 {
@@ -271,7 +339,6 @@ impl FramerateLimiter {
clock.spin();
}
}
self.last_frame_time = clock.nanos();
}
pub fn last_frame_time(&self) -> i64 {
self.last_frame_time
@@ -678,6 +745,140 @@ mod tests {
assert_eq!(calculate_scale([1920, 1080], 3, true), 4);
assert_eq!(gui_scaled_size([1921, 1081], 4), [481, 271]);
}
/// A clock whose sleeps run late by a quarter of their length, as macOS's do,
/// sleeping as [`SystemClock::park`] does.
struct Coalescing {
now: std::cell::Cell<i64>,
}
impl LimiterClock for Coalescing {
fn nanos(&mut self) -> i64 {
self.now.get()
}
fn park(&mut self, nanos: i64) {
let now = &self.now;
park_in_steps(
now.get() + nanos,
|| now.get(),
|step| {
now.set(now.get() + step + step / 4);
},
);
}
fn spin(&mut self) {
self.now.set(self.now.get() + 1000);
}
}
#[test]
fn a_frame_cap_holds_when_sleeps_run_late() {
let mut clock = Coalescing {
now: std::cell::Cell::new(0),
};
let mut limiter = FramerateLimiter::new(0);
let mut last = 0;
for frame in 0..120 {
// The frame's own work.
clock.now.set(clock.now.get() + 2_000_000);
limiter.limit_display_fps(60, &mut clock);
let period = clock.now.get() - last;
last = clock.now.get();
assert!(
(16_666_666..16_666_666 + 100_000).contains(&period),
"frame {frame}: {period} ns"
);
}
}
/// A clock whose sleeps and spins each run up to `late` ns late (a different
/// share each time), as a loaded machine's do.
struct Late {
now: i64,
late: i64,
seed: u64,
}
impl Late {
fn new(late: i64) -> Self {
Self {
now: 0,
late,
seed: 0x9e37_79b9_7f4a_7c15,
}
}
fn lateness(&mut self) -> i64 {
self.seed ^= self.seed << 13;
self.seed ^= self.seed >> 7;
self.seed ^= self.seed << 17;
(self.seed % 1000) as i64 * self.late / 1000
}
}
impl LimiterClock for Late {
fn nanos(&mut self) -> i64 {
self.now
}
fn park(&mut self, nanos: i64) {
self.now += nanos + self.lateness();
}
fn spin(&mut self) {
self.now += 50_000 + self.lateness();
}
}
/// Frame periods of 144 FPS frames whose work takes `work(frame)` ns.
fn grid_periods(late: i64, work: impl Fn(usize) -> i64) -> Vec<i64> {
let mut clock = Late::new(late);
let mut limiter = FramerateLimiter::new(0);
let mut ends = Vec::new();
for frame in 0..300 {
clock.now += work(frame);
limiter.limit_on_grid(144, &mut clock);
ends.push(clock.now);
}
ends.windows(2).map(|w| w[1] - w[0]).collect()
}
#[test]
fn the_grid_holds_the_cap_when_wake_ups_run_late() {
let period = 1_000_000_000 / 144;
for late in [0, 50_000, 200_000, 1_000_000] {
let periods = grid_periods(late, |_| 2_000_000);
let mean = periods[10..].iter().sum::<i64>() / (periods.len() - 10) as i64;
assert!(
(mean - period).abs() < 10_000,
"{late} ns late: mean period {mean} ns"
);
// Each slot waits at least half a frame: the grid never rushes one out.
assert!(
periods.iter().all(|&p| p > period / 2),
"{late} ns late: {periods:?}"
);
}
// Vanilla's way adds the lateness to the frames: the cap isn't held.
let mut clock = Late::new(400_000);
let mut limiter = FramerateLimiter::new(0);
for _ in 0..300 {
clock.now += 2_000_000;
limiter.limit_display_fps(144, &mut clock);
}
assert!(
clock.now / 300 > period + 50_000,
"vanilla: {} ns",
clock.now / 300
);
}
#[test]
fn a_long_frame_starts_the_grid_again_without_a_burst() {
let period = 1_000_000_000 / 144;
// Frame 100 works 30 ms: four slots go by.
let periods = grid_periods(
50_000,
|frame| if frame == 100 { 30_000_000 } else { 2_000_000 },
);
assert!(periods[99] >= 30_000_000);
for (i, &p) in periods.iter().enumerate().skip(100) {
// On the grid a slot is a late wake-up (50 us at most) short or long.
assert!(
(period - 100_000..period + 100_000).contains(&p),
"frame {}: {p} ns",
i + 1
);
}
}
#[test]
fn limiter_paces_frames_at_the_target() {
let mut limiter = FramerateLimiter::new(0);
+4
View File
@@ -225,3 +225,7 @@ path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
[[example]]
name = "chunk_encoding_bench"
path = "../horizon-client/examples/chunk_encoding_bench.rs"
+8
View File
@@ -7,6 +7,7 @@ rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
@@ -42,6 +43,10 @@ miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
half = "2"
png = "0.18"
# Project icons served by Modrinth are often WebP or JPEG rather than PNG, and
# the pack browser showed nothing for those. Both are small pure-Rust decoders.
image-webp = { version = "0.2", default-features = false }
zune-jpeg = { version = "0.4", default-features = false, features = ["std"] }
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
@@ -70,6 +75,9 @@ audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
[lints]
workspace = true
+7
View File
@@ -0,0 +1,7 @@
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
.expect("generating native render mod API");
}
@@ -0,0 +1,220 @@
//! Original synthetic capability regression; no game resources or player world.
//! Run with fresh HORIZON_HOME, HORIZON_LAUNCHER_HOME, XDG_DATA_HOME and XDG_CONFIG_HOME,
//! HORIZON_NO_AUDIO=1 and HORIZON_LAUNCHER_NO_SELF_UPDATE=1.
use horizon_client_render::{gfx::Renderer, graphics::GraphicsApi};
use std::{io::Write, sync::Arc};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
const VERTEX: &str = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
const COLOUR: &str = "#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n";
// Reading the actual shadow sampler activates the shadow pass, rather than just
// placing a shadow program in the archive and assuming the pass runs.
const SHADOW_READ: &str = "#version 120\nuniform sampler2D shadowtex0;\n\
void main() { gl_FragColor = vec4(vec3(texture2D(shadowtex0, vec2(0.5)).r), 1.0); }\n";
fn pack(properties: &str, shadow: Option<&str>, reads_shadow: bool) -> horizon_shaders::Pack {
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut write = |name: &str, text: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(text.as_bytes()).unwrap();
};
write("shaders.properties", properties);
write("final.vsh", VERTEX);
write("final.fsh", if reads_shadow { SHADOW_READ } else { COLOUR });
write("gbuffers_entities.vsh", VERTEX);
write("gbuffers_entities.fsh", COLOUR);
if let Some(shadow) = shadow {
write("shadow.vsh", VERTEX);
write("shadow.fsh", shadow);
}
horizon_shaders::Pack::from_zip_bytes(
"original-shadow-ownership",
zip.finish().unwrap().into_inner(),
)
.unwrap()
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let white = [255; 4];
let panorama = Arc::new(horizon_client_render::gfx::panorama::Faces::colour([0; 4]));
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
1,
1,
1,
&[],
&[],
(&white, 1, 1),
(&white, 1, 1),
(&white, 1, 1),
(&white, 1),
panorama,
None,
None,
GraphicsApi::Vulkan,
false,
true,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]);
assert!(
!renderer.pack_entity_shadows(),
"selected pack without a compiled post"
);
let cases = [
("full", "", Some(COLOUR), true, false, true),
(
"player-only",
"shadowEntities=false\nshadowPlayer=true",
Some(COLOUR),
true,
false,
false,
),
// Block-entity shadows alone must not suppress a mob's blob.
(
"blocks-only",
"shadowEntities=false",
Some(COLOUR),
true,
false,
false,
),
(
"disabled",
"shadow.enabled=false",
Some(COLOUR),
true,
false,
false,
),
("missing-program", "", None, true, false, false),
(
"failed-translation",
"",
Some("#version 120\nvoid main() { gl_FragColor = deliberately_undefined; }"),
true,
false,
false,
),
("failed-pipeline", "", Some(COLOUR), true, true, false),
(
"unused-shadow-program",
"",
Some(COLOUR),
false,
false,
false,
),
(
"forced-shadow-program",
"shadow.enabled=true",
Some(COLOUR),
false,
false,
true,
),
];
for dimension in [0, -1, 1] {
for (name, properties, shadow, reads_shadow, break_pipeline, expected) in cases {
let pack = pack(properties, shadow, reads_shadow);
let mut macros = horizon_shaders::Macros::standard();
renderer.define_pack_macros(&mut macros);
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let mut post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&renderer.pack_inputs(),
);
if name == "failed-translation" {
assert!(post.shadow_entities.is_none());
assert!(post.notes.iter().any(|note| note.contains("skipped")));
} else if name == "missing-program" {
assert!(post.shadow_entities.is_none());
assert!(post.notes.iter().any(|note| note.contains("no program")));
} else if break_pipeline {
// A translated stage can still fail device pipeline creation. Inject a
// missing entry point into only the entity caster; terrain/block stages
// and the settings stay valid. PackPost must report the compiled result.
post.shadow_entities
.as_mut()
.unwrap()
.program
.as_mut()
.unwrap()
.vertex
.entry_points[0]
.name = "deliberately_missing_main".into();
} else {
assert!(post.notes.is_empty(), "{name}: {:?}", post.notes);
}
let (_, notes) = renderer
.set_shader_pack(dimension, Some(post), &[])
.unwrap();
if break_pipeline {
assert!(
notes.iter().any(|note| note.contains("skipped")),
"{notes:?}"
);
} else if name != "missing-program" && name != "failed-translation" {
assert!(
!notes.iter().any(|note| note.contains("skipped")),
"{name}: {notes:?}"
);
}
assert_eq!(
renderer.pack_entity_shadows(),
expected,
"{name}, dimension {dimension}: {notes:?}"
);
assert!(!renderer.native_shadow());
println!("{name}, dimension {dimension}: replaces_mob_blob={expected}");
}
}
renderer.drop_shader_packs();
assert!(
!renderer.pack_entity_shadows(),
"dropped pack restores fallback"
);
println!("ENTITY_SHADOW_OWNERSHIP_PASSED: 27 compiled controls and no/pending/dropped pack");
Ok(())
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(
Window::default_attributes()
.with_visible(false)
.with_inner_size(winit::dpi::LogicalSize::new(32., 32.)),
)
.unwrap(),
);
if let Err(error) = fixture(window) {
eprintln!("Shadow ownership fixture failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
@@ -0,0 +1,397 @@
//! Original asset-free standard-pack entity fixture. Run with throwaway HORIZON_HOME,
//! HORIZON_LAUNCHER_HOME, XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md).
//! `cargo run -p horizon-client-render --example standard_pack_gpu_entities -- <output-dir>`
use client::{
gfx::{
Frame, Renderer, ScreenshotRequest,
entity_skin::{SkinFrame, SkinJob, SkinPart, SkinVertex},
native_render,
pack_draw::EntitySpan,
},
graphics::GraphicsApi,
hud::HudBatch,
};
use horizon_client_render as client;
use std::{
path::Path,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
static NEEDS_SPANS: AtomicBool = AtomicBool::new(false);
static OBSERVED: AtomicUsize = AtomicUsize::new(0);
struct Observer;
impl native_render::RenderHooks for Observer {
fn render_needs(&self, event: &mut native_render::RenderNeedsEvent<'_>) {
event.needs.entity_spans |= NEEDS_SPANS.load(Ordering::Relaxed);
}
fn world_drawn(&self, event: &mut native_render::WorldDrawnEvent<'_, '_>) {
if NEEDS_SPANS.load(Ordering::Relaxed) {
assert_eq!(event.world.entities.len(), 12);
assert_eq!(
event.world.spans.iter().map(|s| s.id).collect::<Vec<_>>(),
[77, 88, 99]
);
OBSERVED.fetch_add(1, Ordering::Relaxed);
}
}
}
fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
let shaders = folder.join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
let vertex = "#version 120\nvarying vec2 uv; void main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}\n";
let fragment = "#version 120\nuniform int entityId; uniform int blockEntityId; uniform vec4 entityColor; void main(){gl_FragData[0]=vec4(float(entityId)/255.0,entityColor.a,float(blockEntityId)/255.0,1.0);}\n";
for program in [
"gbuffers_entities",
"gbuffers_block",
"gbuffers_entities_translucent",
] {
std::fs::write(shaders.join(format!("{program}.vsh")), vertex).unwrap();
std::fs::write(
shaders.join(format!("{program}.fsh")),
if valid {
fragment
} else {
"#version 120\nvoid main(){ invalid shader; }"
},
)
.unwrap();
}
std::fs::write(shaders.join("final.vsh"), vertex).unwrap();
std::fs::write(shaders.join("final.fsh"), "#version 120\nvarying vec2 uv; uniform sampler2D colortex0; void main(){gl_FragColor=texture2D(colortex0,uv);}\n").unwrap();
std::fs::write(
shaders.join("shaders.properties"),
"oldLighting=false\nshadow.enabled=false\n",
)
.unwrap();
let pack = horizon_shaders::Pack::open(folder).unwrap();
let mut macros = horizon_shaders::Macros::standard();
renderer.define_pack_macros(&mut macros);
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &renderer.pack_inputs());
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
assert!(
!valid || notes.iter().all(|n| !n.contains("skipped")),
"{notes:?}"
);
}
fn read(path: &Path) -> Vec<u8> {
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut bytes).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
bytes.truncate(info.buffer_size());
bytes
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let out = std::env::args().nth(1).ok_or("output directory required")?;
let out = Path::new(&out);
std::fs::create_dir_all(out)?;
native_render::install(Box::new([&Observer])).map_err(|_| "observer already installed")?;
let white = [255; 16 * 16 * 4];
let zero = [0; 4];
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
16,
16,
1,
&[],
&[],
(&white, 16, 16),
(&zero, 1, 1),
(&zero, 1, 1),
(&zero, 1),
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
None,
None,
GraphicsApi::Vulkan,
false,
true,
))?;
renderer.resize(320, 180);
assert!(
renderer.skins_entities(),
"fixture requires compute skinning"
);
let unmodified_tagged = renderer.skins_tagged();
pack(&mut renderer, &out.join("valid-pack"), true);
assert!(
!renderer.skins_tagged(),
"eye/tagged layers keep the CPU path with a pack"
);
let spans = [77, 88, 99]
.into_iter()
.enumerate()
.map(|(i, id)| EntitySpan {
start: i as u32 * 4,
stage: horizon_shaders::render_stage("ENTITIES"),
id,
hidden: i == 2,
..Default::default()
})
.collect::<Vec<_>>();
let mut skin = SkinFrame {
reset: true,
vertices: 12,
spans: spans.clone(),
parts: vec![SkinPart {
matrix: glam::Mat4::IDENTITY.to_cols_array(),
normal: glam::Mat3::IDENTITY.to_cols_array(),
}],
..Default::default()
};
for (i, x) in [-1.8, 0.3, -0.7].into_iter().enumerate() {
for (pos, uv) in [
[x, -0.8, 5.],
[x, 0.8, 5.],
[x + 1.4, 0.8, 5.],
[x + 1.4, -0.8, 5.],
]
.into_iter()
.zip([[0., 0.], [0., 1.], [1., 1.], [1., 0.]])
{
skin.new_model.push(SkinVertex {
pos,
uv,
axis: 5,
part: 0,
});
}
skin.jobs.push(SkinJob {
first: i as u32 * 4,
dst: i as u32 * 4,
count: 4,
rect: [0., 0., 1., 1.],
color: u32::MAX,
light: 0xf0f0,
overlay: if i == 0 { 1 } else { 5 },
..Default::default()
});
}
let cpu = client::gfx::entity_skin::expand(&skin.new_model, &skin);
let hud = HudBatch::new([320, 180]);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.camera = glam::DVec3::ZERO;
frame.eye = glam::DVec3::ZERO;
frame.pitch = 0.;
frame.entities = &cpu;
frame.entity_spans = &spans;
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("cpu.png"),
size: None,
scaled: false,
});
renderer.render(&frame)?;
let expected = read(&out.join("cpu.png"));
let matches = expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[..3] == [77, 77, 0])
.count();
assert!(
matches > 100,
"original entity-ID/hurt output, got {matches} pixels"
);
let white = expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[..3] == [88, 192, 0])
.count();
assert!(
white > 100,
"original entity-ID/white-flash output, got {white} pixels"
);
assert_eq!(
expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[0] == 99)
.count(),
0,
"hidden caster is not camera-visible"
);
assert!(
renderer.skins_entities(),
"compiled standard-pack stages must accept GPU posing"
);
frame.entities = &[];
frame.entity_spans = &[];
frame.skin = Some(&skin);
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("gpu.png"),
size: None,
scaled: false,
});
renderer.render(&frame)?;
assert_eq!(
read(&out.join("gpu.png")),
expected,
"CPU and GPU standard-pack pixels"
);
NEEDS_SPANS.store(true, Ordering::Relaxed);
assert!(
!renderer.skins_entities(),
"native CPU-stream observer keeps its fallback"
);
frame.skin = None;
frame.entities = &cpu;
frame.entity_spans = &spans;
renderer.render(&frame)?;
assert_eq!(OBSERVED.load(Ordering::Relaxed), 1);
NEEDS_SPANS.store(false, Ordering::Relaxed);
assert!(renderer.skins_entities());
pack(&mut renderer, &out.join("failed-pack"), false);
assert!(
!renderer.skins_entities(),
"failed entity programs retain CPU posing"
);
renderer.drop_shader_packs();
assert!(
renderer.skins_entities(),
"ordinary path returns when the pack is dropped"
);
assert_eq!(renderer.skins_tagged(), unmodified_tagged);
std::fs::write(
out.join("result.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"cpu_gpu_pixels_equal": true, "entity_id_hurt_pixels": matches, "hidden_subject_pixels": 0,
"entity_id_white_flash_pixels": white,
"graphics_adapter": client::crash::system_details("fixture").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
"native_observer_frames": OBSERVED.load(Ordering::Relaxed), "player_game_started": false,
"fixture_only_not_gameplay_or_fps_proof": true
}))?,
)?;
Ok(())
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = fixture(window) {
eprintln!("fixture failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
for key in [
"HORIZON_HOME",
"HORIZON_LAUNCHER_HOME",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
] {
assert!(
std::env::var_os(key).is_some(),
"set throwaway {key} (AGENTS.md)"
);
}
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
weather: &[],
weather_time: client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
fov_degrees: 85.0,
eye: glam::DVec3::new(0.5, 100.0, 0.5),
eye_rotation: [0.0, 10.0],
first_person: true,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: client::environment::Sky::default(),
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
water_plane: None,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
chunk_fade: 0.0,
post_effects: client::post_chain::Request::none(),
menu_blur: None,
}
}
+16 -3
View File
@@ -105,7 +105,14 @@ pub struct RenderAssets {
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
/// Fusion connected textures (empty without Fusion's connecting textures and models).
pub fusion: crate::fusion::FusionTextures,
/// Counts up each time resources load, from 1: a native mod asks again where blocks'
/// sprites are when it changes (`native_block_sprites_version`).
pub generation: u64,
}
/// The last `RenderAssets::generation` handed out.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
@@ -117,8 +124,7 @@ impl Assets {
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
return Err(invalid("resources do not match Horizon's pinned data version").into());
}
let (registry, flags) =
BlockRegistry::from_facts_json_with_flags(File::open(root.join("facts.json"))?)?;
let (registry, flags) = horizon_world::level::data::load_registry_with_flags(root)?;
let bundle: Value =
serde_json::from_reader(BufReader::new(File::open(root.join("block-tags.json"))?))?;
let mut definitions = TagDefinitions::default();
@@ -224,7 +230,7 @@ impl RenderAssets {
} else {
crate::model_library::ParsedModels::default()
};
let models = if draws {
let mut models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
@@ -244,6 +250,11 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
let fusion = if draws {
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
} else {
Default::default()
};
let mut emissive = Vec::new();
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
@@ -304,6 +315,8 @@ impl RenderAssets {
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
fusion,
generation: GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1,
})
}
}
@@ -180,6 +180,16 @@ pub fn list_textures(root: &Path) -> Vec<String> {
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let base = ns.path().join("textures");
// A prepared root's: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(&base) {
out.extend(
files
.iter()
.filter_map(|file| file.strip_suffix(".png"))
.map(|rel| format!("{namespace}:{rel}")),
);
continue;
}
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
+15 -4
View File
@@ -183,6 +183,7 @@ struct Decode {
key: String,
file: PathBuf,
channel: ChannelId,
looping: bool,
}
/// Give a channel waiting on its sound the decoded samples and start it, as
/// the buffer future attaching and playing the source. A source stopped or
@@ -420,7 +421,7 @@ impl Backend for MixerBackend {
let key = format!("{namespace}:{path}");
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
if let Some(samples) = cached {
attach(&self.mixer, channel, samples, false);
attach(&self.mixer, channel, samples, looping);
return;
}
let decoder = self.decoder.get_or_insert_with(|| {
@@ -439,13 +440,18 @@ impl Backend for MixerBackend {
}
samples
});
attach(&mixer, job.channel, samples, false);
attach(&mixer, job.channel, samples, job.looping);
}
})
.expect("sound decoder thread");
send
});
let _ = decoder.send(Decode { key, file, channel });
let _ = decoder.send(Decode {
key,
file,
channel,
looping,
});
}
/// `alSourceStop`: any state becomes stopped, an initial source too (a
/// sound still decoding then never starts).
@@ -1206,8 +1212,13 @@ impl Audio {
/// `SoundManager.reload` (resource packs changed): stop every sound, then
/// read sound events and sound files from the new resource root.
pub fn reload(&mut self, root: &Path) {
self.reload_with(root, SoundRegistry::load(root));
}
/// [`Self::reload`] with the root's sound events already read
/// (`SoundRegistry::load`, which a pack reload runs off the render thread).
pub fn reload_with(&mut self, root: &Path, registry: SoundRegistry) {
self.stop_all();
self.engine.registry = SoundRegistry::load(root);
self.engine.registry = registry;
self.backend.root = root.to_owned();
let mut cache = self.backend.cache.lock().unwrap();
cache.entries.clear();
+37 -16
View File
@@ -5,7 +5,6 @@ use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
sync::Arc,
};
@@ -171,7 +170,9 @@ fn color(v: &Value) -> Option<u32> {
}
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_core::prepared_files::read(
&path,
)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
@@ -277,21 +278,22 @@ impl Biomes {
dry: colormap(root, "dry_foliage")?,
};
let mut list = Vec::new();
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
// The folder's own files (not its subfolders'), by file name: through `json_folder`,
// from the prepared data file when there is one.
let folder = horizon_world::json_folder::read(&root.join("data/minecraft/worldgen/biome"))?;
let mut files: Vec<_> = folder
.files
.iter()
.filter(|f| !f.name.contains('/'))
.collect();
names.sort();
for path in names {
let name = format!(
"minecraft:{}",
path.file_stem()
.and_then(|s| s.to_str())
.ok_or("bad biome")?
);
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
list.push(BiomeInfo::from_json(name, &v, &maps));
files.sort_by_cached_key(|f| format!("{}.json", f.name));
for file in files {
let v: Value = serde_json::from_slice(file.bytes()?)?;
list.push(BiomeInfo::from_json(
format!("minecraft:{}", file.name),
&v,
&maps,
));
}
let index = list
.iter()
@@ -432,6 +434,25 @@ impl Biomes {
}
changed.then_some(out)
}
/// This table (the biome files') with its colours passed through `f`, when
/// they differ from `current`'s: what a refresh of a table already
/// coloured by `f` once must not compound.
pub fn recolored_from(
&self,
current: &Self,
f: impl FnMut(&str, &mut BiomeColors),
) -> Option<Self> {
let out = self.with_colors(f).unwrap_or_else(|| self.clone());
let tints = |b: &BiomeInfo| (b.grass, b.foliage, b.dry_foliage, b.water);
let same = out.list.len() == current.list.len()
&& out
.list
.iter()
.zip(&current.list)
.all(|(a, b)| tints(a) == tints(b));
(!same).then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
@@ -292,6 +292,10 @@ impl BlockEntities {
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
/// `LevelChunk.getBlockEntity`: the block entity at `pos`, if any.
pub fn get(&self, pos: &Pos) -> Option<&BlockEntity> {
self.map.get(pos)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.map.get_mut(pos)
}
@@ -573,6 +577,7 @@ mod tests {
default: || 0,
codec: None,
copy_on_death: false,
sync: false,
};
let p = (1, 64, 2);
let mut entities = level();
@@ -383,7 +383,7 @@ impl Shulker {
out
}
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
/// (`level.noCollision` of the lid box at half progress, deflated by 1e-6).
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
let bottom = [
f64::from(pos[0]) + 0.5,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,432 @@
//! Where a block's textures are in the block sprite arrays, side by side, for drawing the block
//! somewhere the mesher does not: a native mod's own pass drawing blocks as voxels
//! (`native_block_sprites`, `WorldDrawn::block_sprites`; docs/native-mods.md). The answer is the
//! model's own faces, as the mesher bakes them: each face's sprite (its array and layer), where on
//! the block's side it lies and how its texture runs across it, and its tint source.
//!
//! Each sprite is a whole layer of a square texture array (`sprites::Sprites`), so a face's UVs run
//! 0 to 1 within its layer; the renderer's split (`Renderer::sprite_split`) layers to an array.
use crate::{mesher::TintSource, models::catalog::BakedFace, streaming::WorkerContext};
use horizon_assets::model::Direction;
use horizon_core::position::BlockPos;
use horizon_world::registry::{BlockRegistry, BlockStateId};
/// A sprite: its array (0 for every vanilla texture on most GPUs; past the first array's layers in
/// 1 to 7) and its layer in that array.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SpriteRef {
pub array: u32,
pub layer: u32,
/// It animates (water, lava, fire, sea lanterns...): the arrays always hold the frame being shown.
pub animated: bool,
/// Some of its pixels are clear (alpha 0): leaves, glass, flowers. Draw those as holes.
pub cutout: bool,
}
/// One face of a block's model on one of its sides.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FaceSprite {
pub sprite: SpriteRef,
/// Where the face lies on the side, in the side's two axes ([`side_axes`]), from 0 to 1 across the block:
/// `[a0, b0, a1, b1]`. `[0, 0, 1, 1]` for a whole face.
pub rect: [f32; 4],
/// How deep into the block the face lies from the side's own plane (0 for a cube's face, 0.5 for a slab's top).
pub inset: f32,
/// The texture across the side: the point (a, b) on it shows `uv = [u0, v0] + a * [du_u, du_v] + b * [dv_u,
/// dv_v]` of the sprite, `[u0, v0, du_u, du_v, dv_u, dv_v]`. Rotations and mirrors (a log on its side, a
/// model's `rotation` and `uvlock`) are in it.
pub uv: [f32; 6],
/// Its colour source, none for an untinted face: multiply the texture by the source's colour.
pub tint: Option<TintSource>,
/// It has partly clear pixels: drawn sorted and blended.
pub translucent: bool,
}
/// What a block draws on each side, in [`Direction::ALL`]'s order (down, up, north, south, west, east).
#[derive(Clone, Debug, PartialEq)]
pub struct BlockSprites {
/// The faces on each side, in the model's order: the one drawn first first (a grass block's side: its dirt
/// and grass, then the grass overlay tinted over it). A side with none is empty. Faces that do not lie flat
/// on a side (a flower's crossed planes, a rotated element) are left out.
pub sides: [Vec<FaceSprite>; 6],
/// The model's particle sprite (what breaking it scatters, a flower's whole picture), if it has one.
pub particle: Option<SpriteRef>,
/// The model is a plain cube: one whole face on each side, at its plane.
pub cube: bool,
}
/// The two axes a side's `rect` and `uv` count along, as indices into (x, y, z): down and up across x then z; north
/// and south across x then y; west and east across z then y. (The block's corner at 0, 0, 0 is 0 on each.)
pub fn side_axes(side: Direction) -> (usize, usize) {
match side {
Direction::Down | Direction::Up => (0, 2),
Direction::North | Direction::South => (0, 1),
Direction::West | Direction::East => (2, 1),
}
}
/// The axis a side faces along, and where its plane is (0 or 1).
fn side_plane(side: Direction) -> (usize, f32) {
match side {
Direction::Down => (1, 0.0),
Direction::Up => (1, 1.0),
Direction::North => (2, 0.0),
Direction::South => (2, 1.0),
Direction::West => (0, 0.0),
Direction::East => (0, 1.0),
}
}
/// A block state from `name[property=value,...]`, the namespace `minecraft:` when it has none; properties left
/// out take the block's default. None for a block or a property the game does not know.
pub fn parse_state(registry: &BlockRegistry, text: &str) -> Option<BlockStateId> {
let (name, props) = match text.trim().split_once('[') {
Some((n, rest)) => (n, rest.strip_suffix(']')?),
None => (text.trim(), ""),
};
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let default = registry.default_state(&name)?;
let mut state = registry.state(default)?.clone();
for pair in props.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=')?;
let slot = state.properties.get_mut(k.trim())?;
*slot = v.trim().to_owned();
}
registry.resolve(&state).ok()
}
/// The sprites `context`'s resources hold, `split` layers to an array (`Renderer::sprite_split`).
pub struct Lookup<'a> {
context: &'a WorkerContext,
split: u32,
}
impl<'a> Lookup<'a> {
pub fn new(context: &'a WorkerContext, split: u32) -> Self {
Self {
context,
split: split.max(1),
}
}
/// The sprite at `layer` of all the layers.
pub fn sprite(&self, layer: u32) -> SpriteRef {
let render = &self.context.render;
SpriteRef {
array: layer / self.split,
layer: layer % self.split,
animated: render.animations.iter().any(|a| a.layer == layer),
cutout: render
.sprite_mips
.get(layer as usize)
.is_some_and(|m| m.has_transparent),
}
}
/// A texture by name (`minecraft:block/stone`, `block/stone`); none for one no model uses (the arrays hold only
/// the textures models and the game's own drawing name).
pub fn texture(&self, name: &str) -> Option<SpriteRef> {
let layers = &self.context.render.sprite_layers;
let layer = layers
.get(name)
.or_else(|| layers.get(&format!("minecraft:{name}")))?;
Some(self.sprite(*layer))
}
/// What block `name` (`minecraft:oak_log[axis=x]`; properties left out take the default) draws on each side;
/// none for a block the game does not know.
pub fn block(&self, name: &str) -> Option<BlockSprites> {
let state = parse_state(self.context.assets.registry, name)?;
Some(self.state(state))
}
/// What `state` draws on each side. A fluid has its still texture on all six; a block drawn only as a block
/// entity (a chest, a sign) or not at all (air) has empty sides, and its particle sprite if any.
pub fn state(&self, state: BlockStateId) -> BlockSprites {
let render = &self.context.render;
let catalog = &render.model_catalog;
let mut sides: [Vec<FaceSprite>; 6] = Default::default();
let block = self
.context
.assets
.registry
.state(state)
.map_or("", |s| s.name.as_str());
let fluid = match block {
"minecraft:water" | "minecraft:bubble_column" => {
Some(("block/water_still", Some(TintSource::Water), true))
}
"minecraft:lava" => Some(("block/lava_still", None, false)),
_ => None,
};
if let Some((texture, tint, translucent)) = fluid {
let sprite = self.texture(texture);
if let Some(sprite) = sprite {
for side in &mut sides {
side.push(FaceSprite {
sprite,
rect: [0.0, 0.0, 1.0, 1.0],
inset: 0.0,
uv: [0.0, 0.0, 1.0, 0.0, 0.0, 1.0],
tint,
translucent,
});
}
}
return BlockSprites {
sides,
particle: sprite,
cube: sprite.is_some(),
};
}
let models = catalog
.collect(state, BlockPos::new(0, 0, 0))
.unwrap_or_default();
let mut all = 0;
for face in models.iter().flat_map(|(_, m)| &m.faces) {
all += 1;
let Some((side, sprite)) = self.face(state, face) else {
continue;
};
sides[side as usize].push(sprite);
}
let particle = catalog
.particle(state)
.or_else(|| {
models
.iter()
.flat_map(|(_, m)| &m.faces)
.map(|f| f.layer)
.next()
})
.map(|layer| self.sprite(layer));
// a cube: every side has a whole face at its plane (and perhaps overlays laid on it), and the model has no
// other faces
let whole = |f: &FaceSprite| f.rect == [0.0, 0.0, 1.0, 1.0] && f.inset == 0.0;
let cube = all == sides.iter().map(Vec::len).sum::<usize>()
&& sides.iter().all(|s| !s.is_empty() && s.iter().all(whole));
BlockSprites {
sides,
particle,
cube,
}
}
/// A model face as it lies on its side; none when it does not lie flat on one.
fn face(&self, state: BlockStateId, face: &BakedFace) -> Option<(Direction, FaceSprite)> {
let q = &face.quad;
let side = q.facing;
let (normal, plane) = side_plane(side);
let depth = q.positions[0][normal];
if q.positions.iter().any(|p| (p[normal] - depth).abs() > 1e-4) {
return None;
}
let (a, b) = side_axes(side);
let ab: [[f32; 2]; 4] = q.positions.map(|p| [p[a], p[b]]);
let uv = affine(&ab, &q.uv)?;
let (mut a0, mut b0, mut a1, mut b1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for [x, y] in ab {
a0 = a0.min(x);
b0 = b0.min(y);
a1 = a1.max(x);
b1 = b1.max(y);
}
let snap = |v: f32| {
let r = (v * 4096.0).round() / 4096.0;
if r == 0.0 { 0.0 } else { r }
};
Some((
side,
FaceSprite {
sprite: self.sprite(face.layer),
rect: [a0, b0, a1, b1].map(snap),
inset: snap((depth - plane).abs()),
uv: uv.map(snap),
tint: self.context.tables.tint_source(state, q.tint_index),
translucent: face.translucent,
},
))
}
}
/// The affine map from a face's points on its side to its UVs, `[u0, v0, du_u, du_v, dv_u, dv_v]`, from three of
/// its corners that are not in a line (a model's faces are parallelograms on their side).
fn affine(ab: &[[f32; 2]; 4], uv: &[[f32; 2]; 4]) -> Option<[f32; 6]> {
for [i, j, k] in [[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]] {
let d1 = [ab[j][0] - ab[i][0], ab[j][1] - ab[i][1]];
let d2 = [ab[k][0] - ab[i][0], ab[k][1] - ab[i][1]];
let det = d1[0] * d2[1] - d1[1] * d2[0];
if det.abs() < 1e-6 {
continue;
}
let e1 = [uv[j][0] - uv[i][0], uv[j][1] - uv[i][1]];
let e2 = [uv[k][0] - uv[i][0], uv[k][1] - uv[i][1]];
// e1 = d1.a * du + d1.b * dv, e2 = d2.a * du + d2.b * dv
let du = [
(e1[0] * d2[1] - e2[0] * d1[1]) / det,
(e1[1] * d2[1] - e2[1] * d1[1]) / det,
];
let dv = [
(e2[0] * d1[0] - e1[0] * d2[0]) / det,
(e2[1] * d1[0] - e1[1] * d2[0]) / det,
];
let o = [
uv[i][0] - ab[i][0] * du[0] - ab[i][1] * dv[0],
uv[i][1] - ab[i][0] * du[1] - ab[i][1] * dv[1],
];
return Some([o[0], o[1], du[0], du[1], dv[0], dv[1]]);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_faces_texture_runs_across_its_side() {
// a whole face, the texture upright: u along a, v along b
let ab = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
assert_eq!(
affine(&ab, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]),
Some([0.0, 0.0, 1.0, 0.0, 0.0, 1.0])
);
// turned a quarter (a log on its side): u runs down b, v back along a
let turned = affine(&ab, &[[0.0, 1.0], [0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]).unwrap();
assert_eq!(turned, [0.0, 1.0, 0.0, -1.0, 1.0, 0.0]);
// a slab's side: the lower half of the block, the lower half of the texture (v counts down)
let half = [[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5]];
let m = affine(&half, &[[0.0, 1.0], [1.0, 1.0], [1.0, 0.5], [0.0, 0.5]]).unwrap();
assert_eq!(m, [0.0, 1.0, 1.0, 0.0, 0.0, -1.0]);
// a face seen edge on has no map
assert_eq!(affine(&[[0.0, 0.0]; 4], &[[0.0, 0.0]; 4]), None);
}
/// The game's own blocks from real prepared resources: a cube, a grass block's tinted top and overlaid sides, a
/// log on its side (its texture turned), leaves (cut out, foliage), water, a slab and a flower.
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn real_blocks_name_their_sprites() -> crate::Result<()> {
use crate::{
assets::Assets, biome::Biomes, blocks::BlockFacts, light::LightTables,
mesher::MeshTables,
};
use std::sync::Arc;
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let tables = Arc::new(MeshTables::new(
&assets,
&render,
&facts,
crate::models::load_visibility(&root, assets.registry)?,
)?);
let context = WorkerContext {
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
tables,
assets: assets.clone(),
render,
biomes: Arc::new(Biomes::load(&root)?),
};
let lookup = Lookup::new(&context, 2048);
let whole = [0.0, 0.0, 1.0, 1.0];
let upright = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
let stone = lookup.block("minecraft:stone").unwrap();
assert!(stone.cube);
let top = stone.sides[Direction::Up as usize][0];
assert_eq!(
(top.rect, top.inset, top.tint, top.sprite.array),
(whole, 0.0, None, 0)
);
assert_eq!(lookup.texture("minecraft:block/stone"), Some(top.sprite));
assert_eq!(lookup.texture("block/stone"), Some(top.sprite));
assert!(!top.sprite.animated && !top.sprite.cutout);
let grass = lookup.block("grass_block").unwrap();
assert!(grass.cube);
assert_eq!(
grass.sides[Direction::Up as usize][0].tint,
Some(TintSource::Grass)
);
assert_eq!(grass.sides[Direction::Down as usize][0].tint, None);
let side = &grass.sides[Direction::North as usize];
assert_eq!(side.len(), 2, "the side and its overlay");
assert_eq!(
(side[0].tint, side[1].tint),
(None, Some(TintSource::Grass))
);
assert_eq!(
lookup.texture("block/grass_block_side_overlay"),
Some(side[1].sprite)
);
// seen from outside, a side's texture runs from its top left: on the north side (looking south, east on
// the left) u falls with x and v with y; on top, u and v run with x and z (stone's first variant is turned)
let planks = lookup.block("oak_planks").unwrap();
let uv = |b: &BlockSprites, d: Direction| b.sides[d as usize][0].uv;
let north = uv(&planks, Direction::North);
assert_eq!(north, [1.0, 1.0, -1.0, 0.0, 0.0, -1.0]);
assert_eq!(uv(&planks, Direction::Up), upright);
assert_eq!(
uv(&planks, Direction::South),
[0.0, 1.0, 1.0, 0.0, 0.0, -1.0]
);
assert_eq!(
uv(&planks, Direction::Down),
[0.0, 1.0, 1.0, 0.0, 0.0, -1.0]
);
// an upright log's side runs as stone's; on its side (axis x) the texture is turned a quarter
let log = lookup.block("oak_log").unwrap();
let log_x = lookup.block("minecraft:oak_log[axis=x]").unwrap();
assert_eq!(log.sides[Direction::North as usize][0].uv, north);
let turned = log_x.sides[Direction::North as usize][0].uv;
assert_eq!(
(turned[2], turned[5]),
(0.0, 0.0),
"u runs along y, v along x: {turned:?}"
);
assert_eq!(
log_x.sides[Direction::West as usize][0].sprite,
log.sides[Direction::Up as usize][0].sprite,
"its end faces west"
);
let leaves = lookup.block("oak_leaves").unwrap();
let leaf = leaves.sides[Direction::Up as usize][0];
assert!(leaf.sprite.cutout);
assert_eq!(leaf.tint, Some(TintSource::Foliage));
assert!(matches!(
lookup.block("spruce_leaves").unwrap().sides[0][0].tint,
Some(TintSource::Fixed(0x61_9961))
));
let water = lookup.block("water").unwrap();
let w = water.sides[Direction::Up as usize][0];
assert!(w.sprite.animated && w.translucent);
assert_eq!(w.tint, Some(TintSource::Water));
let slab = lookup.block("stone_slab").unwrap();
assert!(!slab.cube);
assert_eq!(slab.sides[Direction::Up as usize][0].inset, 0.5);
assert_eq!(
slab.sides[Direction::North as usize][0].rect,
[0.0, 0.0, 1.0, 0.5]
);
let flower = lookup.block("dandelion").unwrap();
assert!(!flower.cube && flower.sides.iter().all(Vec::is_empty));
assert!(flower.particle.is_some_and(|p| p.cutout));
assert_eq!(lookup.block("minecraft:no_such_block"), None);
assert_eq!(lookup.block("stone[no_such=1]"), None);
assert!(lookup.block("air").unwrap().sides.iter().all(Vec::is_empty));
Ok(())
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,452 @@
//! Finding a server's BlueMap without the player naming it
//! (`blueMapAutoDetect`, off by default): the BlueMap worker tries a few
//! likely web addresses (`candidates`), taking only one whose `settings.json`
//! is BlueMap's web-app settings, and the answer is kept per server in
//! `server-terrain/<server>/BlueMap/detected.txt` so a rejoin asks nothing.
//! A `blueMap:` line the player wrote always wins.
use crate::bluemap::Settings;
use std::{
net::IpAddr,
path::{Path, PathBuf},
sync::Mutex,
time::{Duration, SystemTime},
};
/// The answer kept per server, beside its BlueMap tiles.
pub const FILE: &str = "detected.txt";
/// A server found without a map is asked again after this long.
pub const RETRY_NONE: Duration = Duration::from_secs(24 * 3600);
/// A map found is checked again (its address first) after this long.
pub const RETRY_FOUND: Duration = Duration::from_secs(7 * 24 * 3600);
/// Each probe gets this long, connecting included.
pub const TIMEOUT: Duration = Duration::from_secs(4);
/// Probes start this far apart at least.
pub const GAP: Duration = Duration::from_millis(500);
/// Where a level's BlueMap comes from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Source {
/// The player's `blueMap:` line, or a map found before.
Url {
url: String,
maps: Vec<(String, String)>,
},
/// To be looked for at these addresses, in order.
Detect(Vec<String>),
}
/// What the server at `address` draws far terrain from, its BlueMap folder
/// being `dir`: `None` when the player named no map and either auto-detect
/// is off or the server was found without one recently.
pub fn source(settings: &Settings, address: &str, dir: &Path, now: SystemTime) -> Option<Source> {
if let Some(server) = settings.server(address) {
return Some(Source::Url {
url: server.url.clone(),
maps: server.maps.clone(),
});
}
if !settings.auto_detect {
return None;
}
let mut probes = Vec::new();
// Only an address this server's would be looked for at: a remembered
// one from elsewhere (a copied folder) names someone else's.
let ours = candidates(address);
match remembered(dir, now) {
Remembered::Found(url) | Remembered::Stale(Some(url)) if !ours.contains(&url) => {}
Remembered::Found(url) => {
return Some(Source::Url {
url,
maps: Vec::new(),
});
}
Remembered::Nothing => return None,
Remembered::Stale(Some(url)) => probes.push(url),
Remembered::Stale(None) => {}
}
for candidate in ours {
if !probes.contains(&candidate) {
probes.push(candidate);
}
}
(!probes.is_empty()).then_some(Source::Detect(probes))
}
/// The first labels a game server's host name takes beside its site's.
const GAME_LABELS: &[&str] = &["play", "mc", "minecraft", "mine", "join", "server"];
/// Domains whose names belong to many customers (dynamic DNS, server
/// hosts): `map.` of them is someone else's.
const SHARED: &[&str] = &[
"aternos.me",
"ddns.net",
"duckdns.org",
"hopto.org",
"minehut.gg",
"no-ip.org",
"playit.gg",
"serveminecraft.net",
"zapto.org",
];
/// The web addresses a server's BlueMap likely answers at: BlueMap's own
/// web server (port 8100), the server's host, and its `map.` subdomain, also
/// under the parent domain of a `play.`/`mc.` style host (`GAME_LABELS`).
pub fn candidates(address: &str) -> Vec<String> {
let address = address.trim().to_ascii_lowercase();
let host = if let Some(rest) = address.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else if address.matches(':').count() > 1 {
&address[..]
} else {
address.split(':').next().unwrap_or(&address)
};
let host = host.trim_end_matches('.');
if host.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
if let Ok(ip) = host.parse::<IpAddr>() {
// Certificates are for names: plain HTTP only.
let host = match ip {
IpAddr::V6(_) => format!("[{host}]"),
IpAddr::V4(_) => host.to_owned(),
};
out.push(format!("http://{host}:8100"));
out.push(format!("http://{host}"));
return out;
}
if !host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return Vec::new();
}
out.push(format!("http://{host}:8100"));
let labels = host.split('.').count();
let map = |host: &str| (!host.starts_with("map.")).then(|| format!("map.{host}"));
if labels >= 2
&& let Some(map) = map(host)
{
out.push(format!("https://{map}"));
}
out.push(format!("https://{host}"));
if labels >= 2
&& let Some(map) = map(host)
{
out.push(format!("http://{map}"));
}
out.push(format!("http://{host}"));
// `play.example.net` → `map.example.net`, only for a game server's
// usual names: the parent of `name.hosting.example` is another party's.
if labels >= 3
&& let Some((first, parent)) = host.split_once('.')
&& GAME_LABELS.contains(&first)
&& !SHARED.contains(&parent)
&& let Some(map) = map(parent)
{
out.push(format!("https://{map}"));
out.push(format!("http://{map}"));
}
let mut seen = Vec::new();
out.retain(|url| {
let new = !seen.contains(url);
seen.push(url.clone());
new
});
out
}
/// Whether `json` is a BlueMap web app's `settings.json`: a list of map ids
/// and where their data lies, not any page a web server returns.
pub fn is_web_app_settings(json: &[u8]) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(json) else {
return false;
};
let maps = v.get("maps").and_then(|m| m.as_array());
maps.is_some_and(|maps| !maps.is_empty() && maps.iter().all(|m| m.is_string()))
&& v.get("mapDataRoot").is_some_and(|r| r.is_string())
}
/// Whether a BlueMap web app answers at `base`.
pub fn probe(agent: &ureq::Agent, base: &str) -> bool {
use std::io::Read;
let Ok(response) = agent
.get(&format!("{base}/settings.json"))
.timeout(TIMEOUT)
.call()
else {
return false;
};
let mut body = Vec::new();
response
.into_reader()
.take(256 << 10)
.read_to_end(&mut body)
.is_ok()
&& is_web_app_settings(&body)
}
/// What an earlier look found.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Remembered {
Found(String),
/// Nothing, recently.
Nothing,
/// Never looked, or long enough ago to look again (first at the map
/// found then, if any).
Stale(Option<String>),
}
pub fn remembered(dir: &Path, now: SystemTime) -> Remembered {
let Ok(text) = std::fs::read_to_string(dir.join(FILE)) else {
return Remembered::Stale(None);
};
let mut words = text.split_whitespace();
let (kind, at) = (words.next(), words.next().and_then(|t| t.parse().ok()));
let Some(at) = at.map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) else {
return Remembered::Stale(None);
};
// A time ahead of the clock (a clock set wrong, a copied file) is stale.
let Ok(age) = now.duration_since(at) else {
return Remembered::Stale(None);
};
match (kind, words.next()) {
(Some("found"), Some(url)) if age < RETRY_FOUND => Remembered::Found(url.to_owned()),
(Some("found"), Some(url)) => Remembered::Stale(Some(url.to_owned())),
(Some("none"), _) if age < RETRY_NONE => Remembered::Nothing,
_ => Remembered::Stale(None),
}
}
/// Keeps what a look found (`None`: no map).
pub fn remember(dir: &Path, found: Option<&str>, now: SystemTime) {
let secs = now
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let text = match found {
Some(url) => format!("found {secs} {url}\n"),
None => format!("none {secs}\n"),
};
let _ = std::fs::create_dir_all(dir);
let _ = std::fs::write(dir.join(FILE), text);
}
/// The servers being looked at now, by BlueMap folder: one look a server at
/// a time, however many levels open meanwhile.
static LOOKING: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
/// The right to look for `dir`'s server's map, until dropped.
pub struct Claim(PathBuf);
impl Claim {
/// `None` while another look at the same server runs.
pub fn take(dir: &Path) -> Option<Self> {
let mut looking = LOOKING.lock().unwrap();
if looking.iter().any(|d| d == dir) {
return None;
}
looking.push(dir.to_owned());
Some(Self(dir.to_owned()))
}
}
impl Drop for Claim {
fn drop(&mut self) {
LOOKING.lock().unwrap().retain(|d| *d != self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candidates_cover_the_web_server_host_and_map_subdomains() {
assert_eq!(
candidates("Play.Example.net:25566"),
[
"http://play.example.net:8100",
"https://map.play.example.net",
"https://play.example.net",
"http://map.play.example.net",
"http://play.example.net",
"https://map.example.net",
"http://map.example.net",
]
);
assert_eq!(
candidates("example.net"),
[
"http://example.net:8100",
"https://map.example.net",
"https://example.net",
"http://map.example.net",
"http://example.net",
]
);
// Already the map's host: no `map.map.`.
assert_eq!(
candidates("map.example.net"),
[
"http://map.example.net:8100",
"https://map.example.net",
"http://map.example.net",
]
);
assert_eq!(
candidates("192.0.2.7:25565"),
["http://192.0.2.7:8100", "http://192.0.2.7"]
);
assert_eq!(
candidates("[2001:db8::7]:25565"),
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
);
assert_eq!(
candidates("2001:db8::7"),
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
);
assert_eq!(
candidates("localhost"),
[
"http://localhost:8100",
"https://localhost",
"http://localhost"
]
);
// A hosted name's parent is the host's, not the server's.
assert!(
!candidates("play.ddns.net")
.iter()
.any(|url| url.ends_with("//map.ddns.net"))
);
assert!(
!candidates("name.host.example")
.iter()
.any(|url| url.ends_with("//map.host.example"))
);
assert!(candidates("").is_empty());
assert!(candidates("evil.example.net/path?x").is_empty());
}
#[test]
fn only_blue_maps_settings_count() {
let web_app = br#"{"version":"5.4","useCookies":true,"maps":["world","world_nether"],
"mapDataRoot":"maps","liveDataRoot":"maps","scripts":[],"styles":[]}"#;
assert!(is_web_app_settings(web_app));
assert!(!is_web_app_settings(b"<!doctype html><html></html>"));
assert!(
!is_web_app_settings(br#"{"maps":["world"]}"#),
"no data root"
);
assert!(!is_web_app_settings(br#"{"maps":[],"mapDataRoot":"maps"}"#));
assert!(!is_web_app_settings(
br#"{"maps":[1],"mapDataRoot":"maps"}"#
));
assert!(!is_web_app_settings(
br#"{"maps":"world","mapDataRoot":"maps"}"#
));
assert!(!is_web_app_settings(
br#"[{"maps":["world"],"mapDataRoot":"maps"}]"#
));
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-bluemap-detect-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn answers_are_kept_and_looked_at_again_later() {
let dir = scratch("remember");
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let hour = Duration::from_secs(3600);
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
remember(&dir, None, t0);
assert_eq!(remembered(&dir, t0 + hour), Remembered::Nothing);
// Written ahead of the clock: not trusted.
assert_eq!(remembered(&dir, t0 - hour), Remembered::Stale(None));
assert_eq!(remembered(&dir, t0 + RETRY_NONE), Remembered::Stale(None));
remember(&dir, Some("https://map.example.net"), t0);
assert_eq!(
remembered(&dir, t0 + 2 * RETRY_NONE),
Remembered::Found("https://map.example.net".into())
);
assert_eq!(
remembered(&dir, t0 + RETRY_FOUND),
Remembered::Stale(Some("https://map.example.net".into()))
);
std::fs::write(dir.join(FILE), "garbage").unwrap();
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_players_map_wins_and_detection_waits_for_the_setting() {
let dir = scratch("source");
let now = SystemTime::now();
let mut settings = Settings::default();
let address = "play.example.net";
assert_eq!(
source(&settings, address, &dir, now),
None,
"off by default"
);
settings.auto_detect = true;
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
panic!("looked for");
};
assert_eq!(probes, candidates(address));
remember(&dir, None, now);
assert_eq!(
source(&settings, address, &dir, now),
None,
"none, recently"
);
remember(&dir, Some("https://map.example.net"), now);
assert_eq!(
source(&settings, address, &dir, now),
Some(Source::Url {
url: "https://map.example.net".into(),
maps: Vec::new()
})
);
// A remembered map this server's addresses don't include (a copied
// folder's) is no map.
remember(&dir, Some("https://tracker.example"), now);
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
panic!("looked for, the remembered one ignored");
};
assert_eq!(probes, candidates(address));
remember(&dir, Some("https://map.example.net"), now);
// Past a week the found map is tried first, then the rest.
let later = now + RETRY_FOUND;
let Some(Source::Detect(probes)) = source(&settings, address, &dir, later) else {
panic!("looked for again");
};
assert_eq!(probes[0], "https://map.example.net");
assert_eq!(probes.len(), candidates(address).len());
settings.servers.push(
Settings::parse_server("play.example.net http://example.net:8123 nether=n").unwrap(),
);
assert_eq!(
source(&settings, address, &dir, now),
Some(Source::Url {
url: "http://example.net:8123".into(),
maps: vec![("minecraft:the_nether".into(), "n".into())]
})
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn one_look_a_server_at_a_time() {
let dir = scratch("claim");
let claim = Claim::take(&dir).unwrap();
assert!(Claim::take(&dir).is_none());
drop(claim);
assert!(Claim::take(&dir).is_some());
}
}
@@ -118,6 +118,7 @@ mod tests {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
quad: ModelQuad {
positions: [
[0.0, 0.5, 0.0],
@@ -135,6 +136,11 @@ mod tests {
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type: None,
shade: true,
},
}],
}
@@ -544,7 +544,7 @@ fn the_chat_option_filters_the_lines_both_ways() {
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
/// The in-frame LAN host's chat: a guest's line is a player message, so the
/// A server's own chat: a guest's line is a player message, so the
/// Chat option's player-message permission hides it at Commands Only and
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
/// at Commands Only.
+19 -9
View File
@@ -14,12 +14,14 @@ pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
/// A level's build range and skylight, from its dimension type (`min_y`,
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
/// `SerializableChunkData.write` read them.
/// A level's build range, skylight and ceiling, from its dimension type
/// (`min_y`, `height`, `logical_height`, `has_skylight`, `has_ceiling`), as
/// `LevelHeightAccessor` and `SerializableChunkData.write` read them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShape {
pub sky_light: bool,
/// `DimensionType.hasCeiling`: weather, spawning and maps read it.
pub has_ceiling: bool,
pub min_y: i32,
pub height: i32,
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
@@ -28,18 +30,21 @@ pub struct LevelShape {
impl LevelShape {
pub const OVERWORLD: Self = Self {
sky_light: true,
has_ceiling: false,
min_y: MIN_Y,
height: HEIGHT,
logical_height: HEIGHT,
};
pub const NETHER: Self = Self {
sky_light: false,
has_ceiling: true,
min_y: 0,
height: 256,
logical_height: 128,
};
pub const END: Self = Self {
sky_light: true,
has_ceiling: false,
min_y: 0,
height: 256,
logical_height: 256,
@@ -50,6 +55,7 @@ impl LevelShape {
height: i32,
logical_height: i32,
sky_light: bool,
has_ceiling: bool,
) -> Result<Self, String> {
ColumnLayout::new(min_y, height)?;
if !(0..=height).contains(&logical_height) {
@@ -59,6 +65,7 @@ impl LevelShape {
}
Ok(Self {
sky_light,
has_ceiling,
min_y,
height,
logical_height,
@@ -1019,12 +1026,15 @@ mod tests {
assert_eq!(tall.section_index(2032), None);
assert_eq!(tall.section_y(130), 2016);
assert_eq!(
LevelShape::new(-64, 384, 384, true),
LevelShape::new(-64, 384, 384, true, false),
Ok(LevelShape::OVERWORLD)
);
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
assert!(LevelShape::new(0, 256, 257, true).is_err());
assert!(LevelShape::new(-60, 384, 384, true).is_err());
assert_eq!(
LevelShape::new(0, 256, 128, false, true),
Ok(LevelShape::NETHER)
);
assert!(LevelShape::new(0, 256, 257, true, false).is_err());
assert!(LevelShape::new(-60, 384, 384, true, false).is_err());
}
#[test]
@@ -1032,9 +1042,9 @@ mod tests {
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
}
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
let tall = LevelShape::new(-64, 2096, 2096, true, false).unwrap();
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
let deep = LevelShape::new(-512, 256, 256, false, true).unwrap();
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
assert!(!LevelShape::NETHER.has_section(-1));
+199 -81
View File
@@ -62,18 +62,65 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
}
#[derive(Clone)]
pub struct CellIter {
radius: i32,
ring: i32,
x: i32,
pending_z: Option<i32>,
}
impl CellIter {
pub fn new(radius: i32) -> Self {
Self {
radius,
ring: 0,
x: 0,
pending_z: None,
}
}
}
impl Iterator for CellIter {
type Item = (i32, i32);
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(z) = self.pending_z.take() {
let x = self.x;
self.x += 1;
return Some((x, z));
}
let r2 = self.radius * self.radius;
while self.ring <= 2 * self.radius {
let min_abs = self.ring - self.radius;
while self.x <= self.ring {
if min_abs > 0 && self.x > -min_abs && self.x < min_abs {
self.x = min_abs;
}
let x = self.x;
let z = self.ring - x.abs();
if z <= self.radius && x * x + z * z <= r2 {
if z != 0 {
self.pending_z = Some(z);
return Some((x, -z));
} else {
self.x += 1;
return Some((x, 0));
}
}
self.x += 1;
}
self.ring += 1;
self.x = -self.ring;
}
None
}
}
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
(0..=2 * radius).flat_map(move |ring| {
(-ring..=ring).flat_map(move |x| {
let z = ring - x.abs();
let visible = z <= radius && x * x + z * z <= radius * radius;
(visible && z != 0)
.then_some((x, -z))
.into_iter()
.chain(visible.then_some((x, z)))
})
})
pub fn cells(radius: i32) -> CellIter {
CellIter::new(radius)
}
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
@@ -107,10 +154,16 @@ pub(crate) struct Uniform {
colors: [[f32; 4]; 4],
}
/// How far the clouds have drifted, in ticks: the game time within one trip across the cloud
/// texture (`width` cells of 400 ticks), plus the partial tick (`CloudRenderer.prepare`).
pub fn drift(time: i64, partial_tick: f64, width: u32) -> f64 {
time.rem_euclid(i64::from(width) * 400) as f64 + partial_tick
}
impl Input<'_> {
fn state(&self) -> (MeshKey, Uniform) {
let (w, h, _) = self.texture;
let offset = self.time.rem_euclid(i64::from(*w) * 400) as f64 + self.partial_tick;
let offset = drift(self.time, self.partial_tick, *w);
let mut x = self.camera.x + offset * 0.030_000_001;
let mut z = self.camera.z + 3.96;
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
@@ -150,9 +203,121 @@ impl Input<'_> {
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
cells: Vec<(i32, i32)>,
cached_radius: Option<i32>,
pub vertices: Vec<crate::mesher::Vertex>,
}
#[inline(always)]
fn push_quad(
vertices: &mut Vec<crate::mesher::Vertex>,
p: [[f32; 3]; 4],
shade: u32,
normal: [i8; 4],
uv: [f32; 2],
) {
for pos in p {
vertices.push(crate::mesher::Vertex {
normal,
pos,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
}
#[inline(always)]
fn emit_face(
vertices: &mut Vec<crate::mesher::Vertex>,
direction: usize,
inside: bool,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
const Y0: f32 = 0.0;
const Y1: f32 = 4.0;
let (p, shade, normal) = match direction {
0 => (
[[x1, Y0, z0], [x1, Y0, z1], [x0, Y0, z1], [x0, Y0, z0]],
1,
[0, -127, 0, 0],
),
1 => (
[[x0, Y1, z0], [x0, Y1, z1], [x1, Y1, z1], [x1, Y1, z0]],
0,
[0, 127, 0, 0],
),
2 => (
[[x0, Y0, z0], [x0, Y1, z0], [x1, Y1, z0], [x1, Y0, z0]],
2,
[0, 0, -127, 0],
),
3 => (
[[x1, Y0, z1], [x1, Y1, z1], [x0, Y1, z1], [x0, Y0, z1]],
2,
[0, 0, 127, 0],
),
4 => (
[[x0, Y0, z1], [x0, Y1, z1], [x0, Y1, z0], [x0, Y0, z0]],
3,
[-127, 0, 0, 0],
),
_ => (
[[x1, Y0, z0], [x1, Y1, z0], [x1, Y1, z1], [x1, Y0, z1]],
3,
[127, 0, 0, 0],
),
};
if inside {
push_quad(
vertices,
[p[3], p[2], p[1], p[0]],
shade,
normal.map(|c| -c),
uv,
);
} else {
push_quad(vertices, p, shade, normal, uv);
}
}
#[inline(always)]
fn emit_faces(
vertices: &mut Vec<crate::mesher::Vertex>,
mask: u16,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
for d in [1, 0, 2, 3, 4, 5] {
if mask & (1 << d) != 0 {
emit_face(vertices, d, false, x0, z0, uv);
}
}
if mask >> 6 != 0 {
for d in 0..6 {
if mask & (1 << (d + 6)) != 0 {
emit_face(vertices, d, true, x0, z0, uv);
}
}
}
}
#[inline(always)]
fn emit_flat_cell(vertices: &mut Vec<crate::mesher::Vertex>, x0: f32, z0: f32, uv: [f32; 2]) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
let p = [[x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]];
push_quad(vertices, p, 0, [0; 4], uv);
push_quad(vertices, [p[3], p[2], p[1], p[0]], 0, [0; 4], uv);
}
impl Mesh {
/// Returns true only when vertices must be uploaded. Sub-cell motion and
/// color changes update only the uniform, as in CloudRenderer.prepare.
@@ -161,80 +326,39 @@ impl Mesh {
if self.key.as_ref() == Some(&key) {
return (false, uniform);
}
if self.cached_radius != Some(key.radius) {
self.cells = self::cells(key.radius).collect();
self.cached_radius = Some(key.radius);
}
self.vertices.clear();
let (w, h, cells) = input.texture;
let (w, h) = (i64::from(*w), i64::from(*h));
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
let (w, h, tex_cells) = input.texture;
let (cell_x, cell_z) = (key.cell_x, key.cell_z);
let bottom = uniform.offset[1];
let uv = input.uv;
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
for (dx, dz) in self::cells(radius) {
let (dx, dz) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx, cell_z + dz);
let (w_i64, h_i64) = (i64::from(*w), i64::from(*h));
let filled = |cx: i64, cz: i64| {
tex_cells[(cz.rem_euclid(h_i64) * w_i64 + cx.rem_euclid(w_i64)) as usize]
};
for &(dx, dz) in &self.cells {
let (dx_i64, dz_i64) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx_i64, cell_z + dz_i64);
if !filled(cx, cz) {
continue;
}
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal,
pos: v,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0, [0; 4]);
}
emit_flat_cell(&mut self.vertices, x0, z0, uv);
continue;
}
let quads = [
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
];
let mask = face_mask(
dx as i32,
dz as i32,
dx,
dz,
bottom,
[
!filled(cx, cz - 1),
@@ -243,15 +367,9 @@ impl Mesh {
!filled(cx + 1, cz),
],
);
for (direction, inside) in faces(mask) {
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
}
emit_faces(&mut self.vertices, mask, x0, z0, uv);
}
self.key = Some(key);
(true, uniform)
}
+219 -8
View File
@@ -182,12 +182,24 @@ where
movement: Vec3,
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = CollisionContext::position(bounds.min_position().y);
self.resolve_with_context(bounds, movement, max_step, on_ground, context)
}
/// [`Self::resolve`] with the entity's captured collision context.
pub fn resolve_with_context(
&mut self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
context: CollisionContext<'_>,
) -> Result<Vec3> {
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
context,
bounds,
movement,
max_step,
@@ -211,6 +223,25 @@ impl SceneCollisions<'_, '_> {
) -> Result<Vec3> {
self.resolve_with_context(bounds, movement, max_step, on_ground, self.context(bounds))
}
/// [`Self::resolve`], also stopped by the world border's walls when one is
/// given with the moving entity's x and z: `Entity.collectColliders` adds
/// `WorldBorder.getCollisionShape` for any entity inside and close to it.
pub fn resolve_in_border(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
let context = self.context(bounds);
EntityCollisions {
blocks: self,
entities: &[],
border,
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
}
/// Resolve movement with the entity's captured collision context. Most
/// callers only need the position context; entities such as falling blocks
/// also affect contextual block shapes (for example powder snow).
@@ -296,6 +327,88 @@ where
)
.unwrap_or(false)
}
impl SceneCollisions<'_, '_> {
/// Whether a `BlockCollisionScan` of `bounds` would find no shape, told without
/// building one: no block that can collide in its inner cells, and no block that
/// reaches out of its cell in the sections of its outer ones (where unsure, false:
/// the scan decides). The same cells, blocks and section flags the scan reads.
pub fn cells_without_collision(&self, bounds: Aabb) -> bool {
let (min, max) = (bounds.min_position(), bounds.max_position());
if !min.is_finite() || !max.is_finite() {
return false;
}
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
let lo = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
.offset(-1, -1, -1);
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
let mut section: Option<((i32, i32), i32, bool)> = None;
for z in lo.z..=hi.z {
for y in lo.y..=hi.y {
for x in lo.x..=hi.x {
let outer = [
x == lo.x || x == hi.x,
y == lo.y || y == hi.y,
z == lo.z || z == hi.z,
];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
let at = (x >> 4, z >> 4);
let chunk = match column {
Some((c, chunk)) if c == at => chunk,
_ => {
let chunk = self.world.chunks.get(&at).map(|c| &**c);
column = Some((at, chunk));
chunk
}
};
// The scan passes the cells of a column that is not resident.
let Some(chunk) = chunk else {
continue;
};
if kind != 0 {
let reaches = match section {
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
_ => {
let reaches = self.section_reaches_out(chunk, y >> 4);
section = Some((at, y >> 4, reaches));
reaches
}
};
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
}
true
}
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
/// its cell; a global palette, or a section outside the column, may.
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
blocks.is_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
})
}
}
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
type Error = io::Error;
/// The resident column, looked up once per chunk the scan enters.
@@ -309,13 +422,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette, without reading its blocks; a palette
/// of the whole registry reads every cell.
fn may_reach_out(&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_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
}))
Ok(self.section_reaches_out(chunk, section_y))
}
fn is_suffocating(
&mut self,
@@ -617,6 +724,25 @@ mod border_movement_tests {
);
}
/// A minecart (0.98 wide, 0.7 tall) rolling east at its top speed of 0.4 a tick, 0.11
/// blocks short of the wall: the wall takes all but the 0.11 (`move_cart` hands the
/// cart's x and z to `with_border`, as every entity does in `Entity.move`).
#[test]
fn a_minecart_sized_body_stops_at_the_border_wall() {
// Centre x = 4.4, half width 0.49: the cart's east face is at 4.89, the wall at 5.0.
let bounds = Aabb::new(Vec3::new(3.91, 0.0, -0.49), Vec3::new(4.89, 0.7, 0.49));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.4, 0.0]);
let moved = collisions
.resolve(bounds, Vec3::new(0.4, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
assert!(
(moved.x - 0.11).abs() < 1.0e-9,
"the cart was not stopped by the border: {moved:?}"
);
assert_eq!(moved.z, 0.0);
}
/// Away from the border, the same body moves the whole way.
#[test]
fn a_body_away_from_the_border_moves_whole() {
@@ -769,3 +895,88 @@ mod shulker_collision_tests {
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
}
}
#[cfg(test)]
mod section_tests {
use super::*;
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
/// reaching into the cell above) and the scene's stone: wherever the cells say no
/// block can collide, the full resolution returns the movement, to the bit; the rest
/// is left to it. Needs the prepared resources.
#[test]
fn cells_without_collision_leave_the_movement_as_resolving_does() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("cached-collision.nbt").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let state = |name: &str| assets.registry.default_state(name).unwrap();
// Torches, and a fence at the bottom of a section above air.
for x in 17..30 {
world.set(x, 20, 20, state("minecraft:torch")).unwrap();
}
world.set(5, 32, 5, state("minecraft:oak_fence")).unwrap();
let scene = SceneCollisions {
world: &world,
binding: &binding,
subject: None,
};
// Under the fence: its section's outer cells may hold a shape reaching in.
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
// A torch's cell, and the air just over the scene's stone floor (y 0..4).
let torches = Aabb::new(Vec3::new(20.4, 20.1, 20.4), Vec3::new(20.6, 20.3, 20.6));
assert!(scene.cells_without_collision(torches));
let over_floor = Aabb::new(Vec3::new(30.4, 5.2, 30.4), Vec3::new(30.6, 5.4, 30.6));
assert!(scene.cells_without_collision(over_floor));
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let (mut skipped, mut resolved) = (0, 0);
for i in 0..4000 {
let size = if i % 2 == 0 { 0.2 } else { 0.6 };
// Near the floor (y 0..4) and the stepped pyramid (y 4..11) for most, higher for the rest.
let height = if i % 4 == 0 { 40.0 } else { 14.0 };
let at = Vec3::new(next() * 80.0 - 40.0, next() * height, next() * 80.0 - 40.0);
let bb = Aabb::new(at, Vec3::new(at.x + size, at.y + size, at.z + size));
let reach = if i % 3 == 0 { 3.0 } else { 0.3 };
let d = Vec3::new(
(next() - 0.5) * reach,
(next() - 0.5) * reach,
(next() - 0.5) * reach,
);
let fast = scene.cells_without_collision(bb.expand_towards(d));
let mut full = SceneCollisions {
world: &world,
binding: &binding,
subject: None,
};
let moved = full.resolve(bb, d, 0.0, false).unwrap();
if fast {
skipped += 1;
assert_eq!(
moved.components().map(f64::to_bits),
d.components().map(f64::to_bits),
"{bb:?} by {d:?}"
);
} else {
resolved += 1;
}
}
assert!(
skipped > 500 && resolved > 500,
"{skipped} skipped, {resolved} resolved"
);
}
}
@@ -39,6 +39,8 @@ pub enum Pick {
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
/// bottom-right).
Quarters([TileLayer; 4]),
/// Nothing but its overlays: a Fusion face whose tile is empty, or of the overlay layout.
Nothing,
}
/// A tile drawn over a face, after its own (`overlay*` methods).
+78 -9
View File
@@ -257,6 +257,17 @@ impl SkullSkin {
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
/// `*_skull` and `*_head` but the piston's head.
/// The item slots of the container `name`'s block carries, if any.
fn container_size(name: &str) -> Option<usize> {
match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => Some(27),
n if n.ends_with("copper_chest") => Some(27),
"minecraft:hopper" => Some(HOPPER_SLOTS),
"minecraft:dispenser" | "minecraft:dropper" => Some(9),
n if n.ends_with("shulker_box") => Some(27),
_ => None,
}
}
fn is_skull(name: &str) -> bool {
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
@@ -389,6 +400,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:jukebox" {
return Some(Self::Jukebox(Jukebox::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
@@ -419,15 +433,56 @@ impl BlockEntity {
if name == "minecraft:potent_sulfur" {
return Some(Self::PotentSulfur(PotentSulfur::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
"minecraft:hopper" => HOPPER_SLOTS,
"minecraft:dispenser" | "minecraft:dropper" => 9,
n if n.ends_with("shulker_box") => 27,
_ => return None,
};
Some(Self::Chest(vec![None; size]))
Some(Self::Chest(vec![None; container_size(name)?]))
}
/// `BlockEntity.isValidBlockState`: whether this kind of entity belongs
/// under `name`'s block — vanilla keeps a set block's existing entity
/// only when its type is in the block's set, and drops it for the
/// block's own otherwise. Each kind fits the blocks `for_block` maps to
/// it (a furnace is its kind's, a sign only its standing or hanging
/// kind); a mod block's own can't tell its block here, so a non-vanilla
/// name is trusted — a name change drops it either way.
pub fn fits(&self, name: &str) -> bool {
match self {
Self::Chest(items) => container_size(name) == Some(items.len()),
Self::Furnace(f) => FurnaceKind::from_block(name) == Some(f.kind),
Self::Sign(s) => crate::signs::sign_kind(name) == Some(s.hanging),
Self::Sculk(s) => {
crate::sculk::SculkEntity::for_block(name).is_some_and(|n| n.id() == s.id())
}
Self::CommandBlock(_) => crate::command_block::Mode::of_block(name).is_some(),
Self::Skull(_) => is_skull(name),
Self::Banner(_) => name.ends_with("_banner"),
Self::Shelf(_) => name == "minecraft:shelf" || name.ends_with("_shelf"),
Self::Mod(_) => !name.starts_with("minecraft:"),
Self::Beehive(_) => matches!(name, "minecraft:beehive" | "minecraft:bee_nest"),
Self::Campfire(_) => {
matches!(name, "minecraft:campfire" | "minecraft:soul_campfire")
}
Self::Brushable(_) => matches!(
name,
"minecraft:suspicious_sand"
| "minecraft:suspicious_gravel"
| "minecraft:brushable_block"
),
Self::Brewing(_) => name == "minecraft:brewing_stand",
Self::Beacon(_) => name == "minecraft:beacon",
Self::Jukebox(_) => name == "minecraft:jukebox",
Self::Lectern(_) => name == "minecraft:lectern",
Self::CreakingHeart(_) => name == "minecraft:creaking_heart",
Self::Spawner(_) => name == "minecraft:spawner",
Self::TrialSpawner(_) => name == "minecraft:trial_spawner",
Self::Vault(_) => name == "minecraft:vault",
Self::Crafter(_) => name == "minecraft:crafter",
Self::EndGateway(_) => name == "minecraft:end_gateway",
Self::DaylightDetector => name == "minecraft:daylight_detector",
Self::StructureBlock(_) => name == "minecraft:structure_block",
Self::TestBlock(_) => name == "minecraft:test_block",
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
Self::JigsawBlock(_) => name == "minecraft:jigsaw",
Self::PotentSulfur(_) => name == "minecraft:potent_sulfur",
Self::ChiseledBookshelf(_) => name == "minecraft:chiseled_bookshelf",
}
}
pub fn id(&self) -> &str {
match self {
@@ -729,6 +784,20 @@ mod tests {
assert_eq!(entity.contents().len(), 1);
}
/// A placed jukebox gets its block entity (`JukeboxBlock.newBlockEntity`),
/// so the item's block entity data lands somewhere and the box can hold a
/// disc without a player putting one in.
#[test]
fn a_placed_jukebox_starts_as_an_empty_jukebox_entity() {
let entity = BlockEntity::for_block("minecraft:jukebox").expect("a jukebox");
assert_eq!(entity.id(), "minecraft:jukebox");
assert!(matches!(entity, BlockEntity::Jukebox(_)));
assert_eq!(entity.slots().len(), 1);
assert!(entity.slots()[0].is_none());
// Another block's name is not a jukebox.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+93 -6
View File
@@ -20,6 +20,53 @@ thread_local! {
static CONTAINED: Cell<u32> = const { Cell::new(0) };
/// The message of the last panic `contain` caught on this thread, from the hook.
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
/// The native mod hook this thread is inside, for the panic hook to name.
static IN_NATIVE_MOD: Cell<Option<NativeModCall>> = const { Cell::new(None) };
}
/// A call into one hook of a native mod.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NativeModCall {
/// The mod's `[mod] id`, None in a build that didn't name its mods.
pub id: Option<&'static str>,
/// Its place in dispatch order, which names it then.
pub index: usize,
pub hook: &'static str,
}
/// Marks this thread as inside `call` until the guard drops, which also happens while a panic
/// unwinds. A hook called from a hook is named by the inner call.
pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
NativeModGuard {
outer: IN_NATIVE_MOD.with(|c| c.replace(Some(call))),
}
}
/// Puts back the call that `enter_native_mod` found.
#[must_use]
pub struct NativeModGuard {
outer: Option<NativeModCall>,
}
impl Drop for NativeModGuard {
fn drop(&mut self) {
IN_NATIVE_MOD.with(|c| c.set(self.outer));
}
}
/// `message`, with the native mod hook this thread is inside, if any.
fn attributed(message: String) -> String {
match IN_NATIVE_MOD.with(Cell::get) {
Some(NativeModCall {
id: Some(id), hook, ..
}) => format!("{message}\nin native mod {id}, hook {hook}"),
Some(NativeModCall {
id: None,
index,
hook,
}) => format!("{message}\nin native mod #{index}, hook {hook}"),
None => message,
}
}
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
@@ -418,10 +465,10 @@ fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
let text = payload_text(info.payload());
match info.location() {
attributed(match info.location() {
Some(at) => format!("panicked at {at}:\n{text}"),
None => format!("panicked:\n{text}"),
}
})
}
/// A `std::backtrace` as one line a frame, `function (file:line:col)`. Captured in a panic hook
@@ -537,7 +584,8 @@ pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str)
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
/// does. The default hook still prints the panic. A panic inside `contain` is only logged. A
/// panic inside a native mod's hook names the mod and the hook.
pub fn install(home: PathBuf, side: &'static str) {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
@@ -676,6 +724,25 @@ mod tests {
}
}
#[test]
fn a_panic_in_a_native_mods_hook_names_it() {
let outer = enter_native_mod(NativeModCall {
id: None,
index: 1,
hook: "render",
});
let inner = enter_native_mod(NativeModCall {
id: Some("b"),
index: 0,
hook: "tick",
});
assert!(attributed("boom".into()).ends_with("\nin native mod b, hook tick"));
drop(inner);
assert!(attributed("boom".into()).ends_with("\nin native mod #1, hook render"));
drop(outer);
assert_eq!(attributed("boom".into()), "boom");
}
fn report() -> Report {
Report {
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
@@ -787,17 +854,37 @@ mod tests {
#[test]
fn a_panic_becomes_a_report() {
// A real panic through a hook, as the game's would be.
let caught = std::sync::Arc::new(Mutex::new(None));
let caught = std::sync::Arc::new(Mutex::new(Vec::new()));
let sink = caught.clone();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if panic_message(info).contains("the test crash") {
*sink.lock().unwrap() = Some(panic_report(info, "client"));
sink.lock().unwrap().push(panic_report(info, "client"));
}
}));
let _ = std::panic::catch_unwind(|| panic!("the test crash"));
// The same panic inside a native mod's hook, which the hook sees before the unwind.
let _ = std::panic::catch_unwind(|| {
let _guard = enter_native_mod(NativeModCall {
id: Some("a"),
index: 0,
hook: "tick",
});
panic!("the test crash")
});
std::panic::set_hook(previous);
let report = caught.lock().unwrap().take().expect("the hook ran");
// A caught panic leaves the thread as it found it.
assert_eq!(attributed("boom".into()), "boom");
let mut reports = std::mem::take(&mut *caught.lock().unwrap());
assert_eq!(reports.len(), 2, "the hook ran for both panics");
let in_mod = reports.pop().unwrap();
assert!(
in_mod.error.ends_with("\nin native mod a, hook tick"),
"{}",
in_mod.error
);
let report = reports.pop().unwrap();
assert!(!report.error.contains("native mod"));
assert!(report.error.contains("the test crash"));
assert!(report.error.contains("crash.rs"));
// The first frame is the test's own code, not the panic machinery.
+5 -1
View File
@@ -121,7 +121,11 @@ impl DataPack {
}
/// The packs in `<world>/datapacks`, sorted by name.
pub fn discover(world: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else {
discover_folder(&world.join("datapacks"))
}
/// Packs in Create World's temporary pack folder, before a world exists.
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut packs = Vec::new();
+7 -5
View File
@@ -421,31 +421,33 @@ impl StatusFlags {
/// The attribute modifier an effect adds at amplifier 0
/// (`MobEffect.addAttributeModifier`): (attribute, id, amount, operation).
/// Each level multiplies the amount (`amplifier + 1`).
/// Each level multiplies the amount (`amplifier + 1`). Vanilla's amounts are
/// `float` literals widened to the modifier's `double`, so `f64::from` the
/// `f32` to keep the low digits.
pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64, &'static str)> {
match effect {
"speed" => Some((
"minecraft:movement_speed",
"minecraft:effect.speed",
0.2,
f64::from(0.2f32),
"add_multiplied_total",
)),
"slowness" => Some((
"minecraft:movement_speed",
"minecraft:effect.slowness",
-0.15,
f64::from(-0.15f32),
"add_multiplied_total",
)),
"haste" => Some((
"minecraft:attack_speed",
"minecraft:effect.haste",
0.1,
f64::from(0.1f32),
"add_multiplied_total",
)),
"mining_fatigue" => Some((
"minecraft:attack_speed",
"minecraft:effect.mining_fatigue",
-0.1,
f64::from(-0.1f32),
"add_multiplied_total",
)),
"strength" => Some((
+10 -1
View File
@@ -147,7 +147,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
@@ -375,6 +376,14 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
}
// HDR lighting on clouds, drawn again after them with only alpha written: they're lit by the
// sky, so they take its value, which their blending would otherwise cover.
@fragment fn fs_hdr_cloud_mark(in: Out) -> @location(0) vec4<f32> {
let a = in.color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y));
if a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(globals.hdr_light.y));
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
+190 -9
View File
@@ -60,6 +60,10 @@ impl Corner {
#[derive(Clone, Debug, PartialEq)]
pub struct Extras {
/// The Select Resource Packs screen's Add button, which browses
/// Modrinth. Off by default: it is Horizon's own and the screen is
/// otherwise vanilla's (tools/test_pack_screen_parity.py).
pub pack_browser: bool,
pub fullbright: bool,
/// Held, dropped and burning light sources light their surroundings (`dynamic_light`).
pub dynamic_lights: bool,
@@ -135,10 +139,29 @@ pub struct Extras {
pub splitscreen_three: u8,
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
pub splitscreen_names: bool,
/// The wheel over a cycle button in the settings changes it (`CycleButton.mouseScrolled`,
/// on by default as vanilla); off, the wheel only scrolls the list.
pub wheel_cycles_settings: bool,
/// The built-in music tracks the Music page turned off (sound ids): they
/// leave every pool they play in (`musicOff`, empty by default: vanilla's
/// random selection).
pub music_off: Vec<String>,
/// The Music page: the Music... button on Music & Sounds, playing a
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (all by default), and their auras and
/// trails (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: horizon_cosmetics::prefs::Others,
pub cosmetics_auras: bool,
}
impl Default for Extras {
fn default() -> Self {
Self {
pack_browser: false,
fullbright: false,
dynamic_lights: false,
zoom: false,
@@ -177,6 +200,12 @@ impl Default for Extras {
splitscreen_two: 0,
splitscreen_three: 0,
splitscreen_names: true,
wheel_cycles_settings: true,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: horizon_cosmetics::prefs::Others::Shown,
cosmetics_auras: false,
}
}
}
@@ -202,6 +231,7 @@ impl Extras {
_ => d,
};
match key.trim() {
"packBrowser" => e.pack_browser = flag(e.pack_browser),
"fullbright" => e.fullbright = flag(e.fullbright),
"dynamicLights" => e.dynamic_lights = flag(e.dynamic_lights),
"zoom" => e.zoom = flag(e.zoom),
@@ -261,6 +291,22 @@ impl Extras {
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
"wheelCyclesSettings" => e.wheel_cycles_settings = flag(e.wheel_cycles_settings),
"musicOff" => {
e.music_off = value
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned)
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).unwrap_or(e.cosmetics_others)
}
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
_ => {}
}
}
@@ -268,7 +314,8 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n",
bool_text(self.pack_browser),
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -312,6 +359,12 @@ impl Extras {
self.splitscreen_two,
self.splitscreen_three,
bool_text(self.splitscreen_names),
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
self.cosmetics_others.name(),
bool_text(self.cosmetics_auras),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -439,6 +492,8 @@ pub enum Item {
HideBlockParticles,
AutoReconnect,
UiSoundReserve,
/// The Music... page on Music & Sounds (`Music page`).
MusicPage,
MouseTweaks,
MouseTweaksWheel,
Sort,
@@ -456,11 +511,18 @@ pub enum Item {
ReproKeys,
/// On the Splitscreen settings page.
Splitscreen,
WheelCyclesSettings,
/// The Select Resource Packs screen's Add button (Modrinth).
PackBrowser,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsAuras,
}
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
let mut rows = vec![
Err("horizon.extras.header.lighting"),
Ok(Fullbright),
Ok(DynamicLights),
@@ -480,7 +542,20 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(AutoReconnect),
Err("horizon.extras.header.sound"),
Ok(UiSoundReserve),
]
Ok(MusicPage),
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
Ok(PackBrowser),
];
if horizon_cosmetics::enabled() {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsOthers),
Ok(CosmeticsAuras),
]);
}
rows
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
@@ -492,6 +567,7 @@ impl Item {
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
Item::AutoReconnect => "horizon.extras.auto_reconnect",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
Item::MusicPage => "horizon.extras.music_page",
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
Item::Sort => "horizon.extras.sort",
@@ -503,6 +579,11 @@ impl Item {
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::PackBrowser => "horizon.extras.pack_browser",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras",
}
}
/// The language key of the description shown as the row's tooltip.
@@ -514,6 +595,7 @@ impl Item {
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
Item::MusicPage => "horizon.extras.music_page.tooltip",
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
Item::Sort => "horizon.extras.sort.tooltip",
@@ -525,6 +607,11 @@ impl Item {
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
@@ -545,6 +632,7 @@ impl Item {
Item::HideBlockParticles => "Hide block-break particles",
Item::AutoReconnect => "Auto-reconnect",
Item::UiSoundReserve => "Keep channels for UI sounds",
Item::MusicPage => "Music page",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
@@ -556,6 +644,11 @@ impl Item {
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsAuras => "Others' auras and trails",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -568,7 +661,8 @@ impl Item {
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation => {
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsAuras => {
return None;
}
})
@@ -592,6 +686,9 @@ impl Item {
Item::UiSoundReserve => {
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
}
Item::MusicPage => {
"Music & Sounds gets a Music... page: play any built-in track now, or turn one off so the game stops picking it. Nothing changes until you turn a track off."
}
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -623,6 +720,19 @@ impl Item {
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::CosmeticsMine => {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden, the game never asks what they wear."
}
Item::CosmeticsAuras => "Auras and trails other players wear. Off by default.",
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
}
Item::PackBrowser => {
"Select Resource Packs gets an Add button that searches Modrinth and installs a pack into your packs folder. Off, the game never contacts Modrinth."
}
}
}
/// The frame-time cost the main switch of an extra shows.
@@ -631,6 +741,7 @@ impl Item {
match self {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::MusicPage => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
@@ -638,7 +749,10 @@ impl Item {
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen => Some(Cost::None),
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine | Item::CosmeticsOthers | Item::CosmeticsAuras => Some(Cost::Low),
_ => None,
}
}
@@ -651,6 +765,7 @@ impl Item {
Item::HideBlockParticles => e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve,
Item::MusicPage => e.music_page,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
@@ -661,6 +776,9 @@ impl Item {
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::CosmeticsAuras => e.cosmetics_auras,
_ => return None,
})
}
@@ -668,15 +786,31 @@ impl Item {
pub fn value(self, e: &Extras) -> String {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.cosmetics_others {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
}
Others::Hidden => {
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
}
}
}
other => match other.switch(e) {
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
/// A click (`_back` for the right button: every item left is a switch,
/// so both flip it). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, _back: bool) {
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
@@ -684,6 +818,7 @@ impl Item {
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
Item::MusicPage => e.music_page = !e.music_page,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
@@ -694,6 +829,14 @@ impl Item {
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
let o = e.cosmetics_others;
e.cosmetics_others = if back { o.next().next() } else { o.next() };
}
Item::CosmeticsAuras => e.cosmetics_auras = !e.cosmetics_auras,
Item::SortKey => {}
}
}
@@ -710,6 +853,34 @@ impl Item {
mod tests {
use super::*;
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
assert_eq!(
(e.cosmetics_others, e.cosmetics_auras, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Others::NameTags);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Others::Hidden);
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Others::NameTags);
assert_eq!(Extras::parse(&e.serialize()), e);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Others::NameTags
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Others::Hidden
);
}
#[test]
fn everything_is_off_by_default_and_round_trips() {
let d = Extras::default();
@@ -717,7 +888,8 @@ mod tests {
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(d.discord_presence);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -758,6 +930,15 @@ mod tests {
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
Item::WheelCyclesSettings.cycle(&mut e, false);
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(false));
e.music_off = vec![
"minecraft:music/game/calm1".into(),
"minecraft:music.menu".into(),
];
assert!(!e.music_page);
Item::MusicPage.cycle(&mut e, false);
assert_eq!(Item::MusicPage.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+71 -15
View File
@@ -258,6 +258,19 @@ fn used_width(pixels: &[u8], stride: u32, (cx, cy, cw, ch): (u32, u32, u32, u32)
})
.map_or(0, |x| x + 1)
}
/// `FontSet.hasFishyAdvance`: a provider glyph whose advance is under 0 or
/// over 32 is not used. `FontSet.computeGlyphInfo` moves on to the next
/// provider that has the character, so a pack's absurd advance cannot widen
/// a line; Horizon keeps one advance per glyph and tests it here.
fn fishy_advance(advance: f32) -> bool {
!(0.0..=32.0).contains(&advance)
}
/// A Unihex glyph's advance (`UnihexProvider.Glyph.advance`): half its width
/// plus one, the width being the bits from its left to its right column.
fn hex_advance(h: &HexGlyph) -> f32 {
let width = h.right.saturating_sub(h.left) + 1;
(width / 2 + 1) as f32
}
/// `BitmapProvider.Definition.load`: each character's glyph, from the atlas
/// when the sheet is in it (`pixels` its RGBA), else from the sheet's own
/// pixels, copied into the atlas when first drawn.
@@ -739,7 +752,9 @@ impl Font {
for provider in &mut set.providers {
match provider {
Provider::Space(advances) => {
if let Some(a) = advances.get(&c) {
if let Some(a) = advances.get(&c)
&& !fishy_advance(*a)
{
return Some(Glyph {
rect: None,
left: 0.0,
@@ -753,8 +768,10 @@ impl Font {
}
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
match glyphs.get(&c) {
Some(BitmapGlyph::Atlas(g)) => return Some(*g),
Some(BitmapGlyph::Sheet(cell)) => {
Some(BitmapGlyph::Atlas(g)) if !fishy_advance(g.advance) => {
return Some(*g);
}
Some(BitmapGlyph::Sheet(cell)) if !fishy_advance(cell.glyph.advance) => {
let (stride, pixels) = (&cell.sheet.0, &cell.sheet.1);
let mut rgba = Vec::with_capacity((cell.w * cell.h * 4) as usize);
for y in cell.y..cell.y + cell.h {
@@ -768,17 +785,21 @@ impl Font {
..cell.glyph
});
}
None => {}
_ => {}
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c)).copied() {
if let Some(h) = glyphs.get(&u32::from(c)).copied()
&& !fishy_advance(hex_advance(&h))
{
return self.rasterize_hex(region, h);
}
}
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
if let Some(id) = ttf.ids.get(&c) {
if let Some(id) = ttf.ids.get(&c)
&& !fishy_advance(ttf.advance(*id))
{
let (glyph, bitmap) = ttf.glyph(*id);
return match bitmap {
Some((w, h, rgba)) => {
@@ -813,7 +834,7 @@ impl Font {
top: 0.0,
right: width as f32 / 2.0,
bottom: 8.0,
advance: (width / 2 + 1) as f32,
advance: hex_advance(&h),
offset: 0.5,
})
}
@@ -887,26 +908,35 @@ impl Font {
for provider in &mut set.providers {
match provider {
Provider::Space(advances) => {
if let Some(a) = advances.get(&c) {
if let Some(a) = advances.get(&c)
&& !fishy_advance(*a)
{
return Some(*a);
}
}
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
match glyphs.get(&c) {
Some(BitmapGlyph::Atlas(g)) => return Some(g.advance),
Some(BitmapGlyph::Sheet(cell)) => return Some(cell.glyph.advance),
None => {}
Some(BitmapGlyph::Atlas(g)) if !fishy_advance(g.advance) => {
return Some(g.advance);
}
Some(BitmapGlyph::Sheet(cell)) if !fishy_advance(cell.glyph.advance) => {
return Some(cell.glyph.advance);
}
_ => {}
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c)) {
let width = h.right.saturating_sub(h.left) + 1;
return Some((width / 2 + 1) as f32);
if let Some(h) = glyphs.get(&u32::from(c))
&& !fishy_advance(hex_advance(h))
{
return Some(hex_advance(h));
}
}
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
if let Some(id) = ttf.ids.get(&c) {
if let Some(id) = ttf.ids.get(&c)
&& !fishy_advance(ttf.advance(*id))
{
return Some(ttf.advance(*id));
}
}
@@ -1376,6 +1406,32 @@ mod tests {
assert!(!font.take_patches().is_empty());
}
/// `FontSet.computeGlyphInfo`: a provider glyph with an advance outside
/// 0..32 is skipped, so the next provider that has the character is used.
#[test]
fn a_fishy_advance_falls_through_to_the_next_provider() {
// 64 opaque columns: the first provider's 'a' would advance 65, past
// the 32 vanilla allows, so the narrow provider's 'a' is the one used.
let wide = png(64, 8, |_, _| true);
let narrow = png(16, 8, |x, _| x < 2);
let def = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/wide.png","ascent":7,"chars":["a"]},{"type":"bitmap","file":"minecraft:font/narrow.png","ascent":7,"chars":["a"]}]}"#;
let root = root(
"fishy",
&[
("assets/minecraft/font/default.json", def),
("assets/minecraft/textures/font/wide.png", &wide),
("assets/minecraft/textures/font/narrow.png", &narrow),
],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
assert_eq!(font.glyph('a').advance, 3.0, "the narrow provider wins");
assert_eq!(font.advance('a', &Style::default()), 3.0);
// Obfuscated text picks a glyph by its own advance: nothing is 65 wide.
let obfuscated = font.random_glyph(&Style::default(), 65.0);
assert_eq!(obfuscated.advance, 6.0, "the missing glyph");
}
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
/// part only; the next part is still measured and can break.
#[test]
+687
View File
@@ -0,0 +1,687 @@
//! Fusion connected textures at mesh time. A pack's connecting textures (a `fusion` section in
//! their `.mcmeta`) are cut into a sprite per tile at load, and every baked face of a
//! `fusion:connecting` model that shows one is given the rule it joins by: the sheet and its
//! predicates, with their blocks resolved to state ids. Meshing such a face evaluates them in
//! the face's eight directions and draws the layout's tiles through [`crate::connected`]'s
//! [`Pick`] and [`Overlay`]. Without Fusion files nothing is loaded and nothing changes.
//!
//! Directions follow a cube's default UVs ([`Axes::cube`]): the top and bottom of a model
//! turned about x, or turned with `uvlock`, join a quarter turn off.
//!
//! The format is `horizon_optifine::fusion`'s.
use crate::connected::{Overlay, Pick, TileLayer};
use crate::models::StateModels;
use crate::sprites::Sprites;
use horizon_optifine::cem::json::Json;
use horizon_optifine::ctm::connect::{self, Axes, Face};
use horizon_optifine::fusion::{
Connection, Layout, Tiles,
model::{Connections, ModelType},
predicate::Query,
texture::{Kind, Meta},
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{
collections::HashMap,
fs,
num::NonZeroU16,
path::{Path, PathBuf},
};
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
struct Sheet {
layout: Layout,
tiles: Vec<Option<TileLayer>>,
}
/// A sheet and what it joins by, blocks as sorted state ids.
struct Rule {
sheet: usize,
connection: Connection<Vec<u32>>,
}
#[derive(Default)]
pub struct FusionTextures {
sheets: Vec<Sheet>,
/// By [`crate::models::BakedFace::fusion`].
rules: Vec<Rule>,
/// Each state's block, for `is_same_block` (`u32::MAX` for an id the registry skips).
blocks: Vec<u32>,
}
/// A model's reference to a texture or another model (`block/glass`), namespaced.
fn namespaced(reference: &str) -> String {
horizon_optifine::matchers::namespaced(reference)
}
fn read_model(root: &Path, name: &str) -> Option<Json> {
let (ns, path) = name.split_once(':')?;
let text = horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{ns}/models/{path}.json")),
)
.ok()?;
Json::parse(&text).ok()
}
/// What a model's face draws and joins by: its sprite, its side in the model, and its
/// predicates (`None`: the texture's own).
type ModelFace = (String, Face, Option<Connection>);
/// Of a `fusion:connecting` model: each face's [`ModelFace`]. Empty for any other model.
fn connecting_model(root: &Path, name: &str) -> Vec<ModelFace> {
let Some(model) = read_model(root, name) else {
return Vec::new();
};
if ModelType::of(&model) != Some(ModelType::Connecting) {
return Vec::new();
}
// Its parents, up to one without a file (`builtin/generated`); deeper than vanilla's few
// levels is a loop.
let mut chain = vec![model];
while chain.len() < 16
&& let Some(parent) = chain.last().and_then(|m| m.get("parent")?.as_str())
&& let Some(parent) = read_model(root, &namespaced(parent))
{
chain.push(parent);
}
// Parents first, so a child's keys replace theirs.
let mut connections = Connections::Textures;
let mut textures: HashMap<&str, &str> = HashMap::new();
for model in chain.iter().rev() {
match Connections::of(model) {
Ok(own) => connections = own.inherit(&connections),
Err(e) => log::warn!("Horizon: Fusion model {name}: {e}"),
}
for (key, value) in model
.get("textures")
.and_then(Json::as_object)
.unwrap_or(&[])
{
// A sprite, or an object naming one (`{"sprite": ..., "force_translucent": true}`).
let value = value.as_str().or_else(|| value.get("sprite")?.as_str());
if let Some(value) = value {
textures.insert(key.as_str(), value);
}
}
}
// A `#key` as vanilla resolves it.
let resolve = |reference: &str| {
let mut value = reference;
for _ in 0..16 {
match value.strip_prefix('#') {
Some(key) => value = textures.get(key)?,
None => return Some(namespaced(value)),
}
}
None
};
// The elements are the nearest model's that has some.
let elements = chain
.iter()
.find_map(|m| m.get("elements")?.as_array())
.unwrap_or(&[]);
let mut out: Vec<ModelFace> = Vec::new();
let faces = elements
.iter()
.filter_map(|e| e.get("faces")?.as_object())
.flatten();
for (side, face) in faces {
let side = match side.as_str() {
"down" => Face::Down,
"up" => Face::Up,
"north" => Face::North,
"south" => Face::South,
"west" => Face::West,
"east" => Face::East,
_ => continue,
};
let Some(texture) = face.get("texture").and_then(Json::as_str) else {
continue;
};
let Some(sprite) = resolve(texture) else {
continue;
};
if !out.iter().any(|(s, f, _)| *s == sprite && *f == side) {
let key = face.get("connections").and_then(Json::as_str);
out.push((sprite, side, connections.for_face(key, texture).cloned()));
}
}
out
}
/// Where a model's `face` points once a blockstate turns it `x`, then `y` quarter turns.
fn turned(face: Face, [x, y, _]: [u8; 3]) -> Face {
let mut n = face.normal();
for _ in 0..x % 4 {
// x=90 takes up to north.
n = [n[0], n[2], -n[1]];
}
for _ in 0..y % 4 {
// y=90 takes north to east.
n = [-n[2], n[1], n[0]];
}
Face::ALL
.into_iter()
.find(|f| f.normal() == n)
.expect("a quarter turn maps faces onto faces")
}
/// The connecting textures under `root`, by sprite name.
fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
let mut out = HashMap::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let ns_name = ns.file_name().to_string_lossy().into_owned();
let textures = ns.path().join("textures");
for path in mcmeta_files(&textures) {
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
continue;
};
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
continue;
};
if !text.contains("fusion") {
continue;
}
let Some(rel) = Path::new(png)
.strip_prefix(&textures)
.ok()
.and_then(|p| p.to_str())
else {
continue;
};
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
match Meta::from_mcmeta(&text) {
Ok(Some(meta)) => {
out.insert(name, meta);
}
Ok(None) => {}
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
}
}
}
out
}
/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others
/// from the folders (linked folders too: mods' content links into the packs' tree).
fn mcmeta_files(textures: &Path) -> Vec<PathBuf> {
if let Some(files) = horizon_core::prepared_files::files_under(textures) {
return files
.iter()
.filter(|file| file.ends_with(".png.mcmeta"))
.map(|file| textures.join(file))
.collect();
}
let mut out = Vec::new();
let mut dirs = vec![textures.to_owned()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
let dir = match entry.file_type() {
Ok(t) if t.is_symlink() => path.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => path.is_dir(),
};
if dir {
dirs.push(path);
} else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) {
out.push(path);
}
}
}
out
}
/// `sprites`' tiles of the connecting texture `name`, and the predicates it joins by itself.
fn sheet(
root: &Path,
sprites: &mut Sprites,
name: &str,
meta: Meta,
) -> Option<(Sheet, Connection)> {
let Kind::Connecting {
layout,
connections,
per_tile_animation,
..
} = meta.kind
else {
return None;
};
let tiles = sprites
.insert_tiles(root, name, layout.grid(), per_tile_animation)
.map_err(|e| log::warn!("Horizon: Fusion texture {name}: {e}"))
.ok()?;
// A layer past the mesher's `u16` leaves the sheet to vanilla.
let tiles = tiles
.into_iter()
.map(|t| match t {
None => Some(None),
Some(l) => Some(Some(TileLayer::Layer {
layer: u16::try_from(l).ok()?,
translucent: sprites.translucent[l as usize],
opaque: sprites.opaque[l as usize],
})),
})
.collect::<Option<Vec<_>>>()?;
Some((Sheet { layout, tiles }, connections))
}
impl FusionTextures {
/// The connecting textures under `root`: the tiles of those `fusion:connecting` models
/// draw are inserted into `sprites` (before it finishes), and each face in `catalog` that
/// shows one gets its rule.
pub fn load(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
catalog: &mut StateModels,
) -> Self {
let mut textures = connecting_textures(root);
if textures.is_empty() {
return Self::default();
}
// The layers models show the sheets as: only their models are read.
let shown: std::collections::HashSet<u32> = textures
.keys()
.filter_map(|name| sprites.layers.get(name).copied())
.collect();
let mut models: HashMap<String, Vec<ModelFace>> = HashMap::new();
let mut sheets: Vec<(Sheet, Connection)> = Vec::new();
let mut by_name: HashMap<String, Option<usize>> = HashMap::new();
let mut rules: Vec<(usize, Connection)> = Vec::new();
for (variant, model) in catalog.models_mut() {
if !model.faces.iter().any(|f| shown.contains(&f.layer)) {
continue;
}
let found = models
.entry(namespaced(&variant.model))
.or_insert_with_key(|name| connecting_model(root, name));
for face in &mut model.faces {
let sprite = &face.quad.material.sprite;
// The model's face on the side this one turned to, else any with its sprite.
let side = Face::ALL[face.quad.facing as usize];
let mut drawing = found.iter().filter(|(s, _, _)| s == sprite);
let Some((_, _, connection)) = drawing
.clone()
.find(|(_, f, _)| turned(*f, variant.rotations) == side)
.or_else(|| drawing.next())
else {
continue;
};
// The sheet, cut into tiles on first use.
let sheet = *by_name.entry(sprite.clone()).or_insert_with(|| {
let meta = textures.remove(sprite)?;
sheets.push(sheet(root, sprites, sprite, meta)?);
Some(sheets.len() - 1)
});
let Some(sheet) = sheet else {
continue;
};
let connection = connection
.clone()
.unwrap_or_else(|| sheets[sheet].1.clone());
let rule = match rules.iter().position(|r| r.0 == sheet && r.1 == connection) {
Some(rule) => rule,
None => {
rules.push((sheet, connection));
rules.len() - 1
}
};
face.fusion = u16::try_from(rule + 1).ok().and_then(NonZeroU16::new);
}
}
let states = (0..registry.len() as u32).map(|id| {
registry
.state(BlockStateId(id))
.map(|s| (s.name.as_str(), &s.properties))
});
let sheets = sheets.into_iter().map(|(sheet, _)| sheet).collect();
Self::build(sheets, rules, states)
}
/// The lookups for `rules` over every block state (`None` for an id the registry skips).
fn build<'a>(
sheets: Vec<Sheet>,
rules: Vec<(usize, Connection)>,
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
) -> Self {
let states: Vec<_> = states.collect();
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
let mut names = HashMap::new();
let blocks = (0..states.len() as u32)
.map(|id| match states[id as usize] {
Some((name, _)) => {
block_states.entry(name).or_default().push(id);
let next = names.len() as u32;
*names.entry(name).or_insert(next)
}
None => u32::MAX,
})
.collect();
// Block lists to the states they match.
let mut resolve = |list: Vec<horizon_optifine::BlockMatch>| {
let mut ids: Vec<u32> = list
.iter()
.flat_map(|m| {
let candidates = block_states
.get(m.id.as_str())
.map_or(&[][..], Vec::as_slice);
candidates.iter().copied().filter(|&id| {
states[id as usize].is_some_and(|(name, props)| {
m.matches(name, |k| props.get(k).map(String::as_str))
})
})
})
.collect();
ids.sort_unstable();
ids.dedup();
ids
};
let rules = rules
.into_iter()
.map(|(sheet, connection)| Rule {
sheet,
connection: connection.map_blocks(&mut resolve),
})
.collect();
Self {
sheets,
rules,
blocks,
}
}
/// What the `face` of the block `state` draws for a quad whose rule is `rule` and whose
/// tint index is `tint`. `neighbour` answers the state at an offset from the block,
/// `covers` whether a state hides the face of a block beside it; the overlay layout's
/// pieces, tinted as the quad, go to `overlay`.
#[allow(clippy::too_many_arguments)]
pub fn pick(
&self,
rule: NonZeroU16,
state: u32,
face: Face,
tint: i32,
neighbour: impl Fn([i32; 3]) -> u32,
covers: impl Fn(u32) -> bool,
mut overlay: impl FnMut(Overlay),
) -> Option<Pick> {
let rule = self.rules.get(usize::from(rule.get() - 1))?;
let normal = face.normal();
let offsets = connect::offsets(Axes::cube(face));
let block = self.blocks.get(state as usize).filter(|&&b| b != u32::MAX);
let mask = rule.connection.mask(|bit, query| {
let &(_, [x, y, z]) = offsets.iter().find(|o| o.0 == bit).expect("a mask bit");
let in_front = || neighbour([x + normal[0], y + normal[1], z + normal[2]]);
match query {
Query::SameBlock => {
block.is_some() && self.blocks.get(neighbour([x, y, z]) as usize) == block
}
Query::SameState => neighbour([x, y, z]) == state,
Query::Neighbour(ids) => ids.binary_search(&neighbour([x, y, z])).is_ok(),
Query::InFront(ids) => ids.binary_search(&in_front()).is_ok(),
Query::FaceVisible => !covers(in_front()),
}
});
let sheet = &self.sheets[rule.sheet];
let tile = |i: u8| sheet.tiles.get(usize::from(i)).copied().flatten();
// An empty tile draws nothing: a pack leaves empty only the tiles its layout's uses
// never need.
Some(match sheet.layout.tiles(mask) {
Tiles::One(t) => tile(t).map_or(Pick::Nothing, Pick::Tile),
Tiles::Quarters(q) => match q.map(tile) {
[Some(a), Some(b), Some(c), Some(d)] => Pick::Quarters([a, b, c, d]),
_ => Pick::Nothing,
},
Tiles::Overlay(mask) => {
for t in Layout::overlay_tiles(mask) {
if let Some(TileLayer::Layer {
layer, translucent, ..
}) = tile(t)
{
overlay(Overlay {
layer,
translucent,
tint: (tint >= 0).then_some((tint, None)),
});
}
}
Pick::Nothing
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Tile `i` of a sheet is layer `base + i`; `empty` tiles have none.
fn sheet(layout: Layout, base: u16, empty: &[u16]) -> Sheet {
let (w, h) = layout.grid();
Sheet {
layout,
tiles: (0..(w * h) as u16)
.map(|i| (!empty.contains(&i)).then_some(layer(base + i)))
.collect(),
}
}
fn predicate(json: &str) -> Connection {
Connection::parse(&Json::parse(json).unwrap()).unwrap()
}
fn layer(layer: u16) -> TileLayer {
TileLayer::Layer {
layer,
translucent: false,
opaque: false,
}
}
/// The lookups over blocks named `names` (a state each).
fn world(
names: &[&str],
sheets: Vec<Sheet>,
rules: Vec<(usize, Connection)>,
) -> FusionTextures {
let none = BlockProperties::default();
let states = names.iter().map(|n| Some((*n, &none)));
FusionTextures::build(sheets, rules, states)
}
/// `pick` for the block at the origin among `blocks` (offset, state), the opaque ones in
/// `opaque`; with the overlays it hands out.
fn pick_in(
fusion: &FusionTextures,
rule: u16,
state: u32,
face: Face,
blocks: &[([i32; 3], u32)],
opaque: &[u32],
) -> (Option<Pick>, Vec<u16>) {
// A face's rule counts from 1.
let rule = NonZeroU16::new(rule + 1).unwrap();
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
let mut overlays = Vec::new();
let pick = fusion.pick(
rule,
state,
face,
-1,
at,
|s| opaque.contains(&s),
|o| overlays.push(o.layer),
);
(pick, overlays)
}
#[test]
fn glass_pieces_join_the_same_block() {
// Glass twice (two states of one block), then stone.
let fusion = world(
&[
"minecraft:air",
"minecraft:glass",
"minecraft:glass",
"minecraft:stone",
],
vec![sheet(Layout::Pieced, 100, &[])],
vec![(0, Connection::SameBlock)],
);
let at = |blocks: &[([i32; 3], u32)]| pick_in(&fusion, 0, 1, Face::North, blocks, &[]).0;
// Alone: every quarter the lone tile.
assert_eq!(at(&[]), Some(Pick::Quarters([layer(100); 4])));
// A north face's right is west: glass there (in its other state) joins the right
// quarters, the horizontal piece. Stone doesn't.
let right = Pick::Quarters([layer(100), layer(103), layer(100), layer(103)]);
assert_eq!(at(&[([-1, 0, 0], 2)]), Some(right));
assert_eq!(
at(&[([-1, 0, 0], 3)]),
Some(Pick::Quarters([layer(100); 4]))
);
// A rule that doesn't exist draws as vanilla.
assert_eq!(pick_in(&fusion, 1, 1, Face::North, &[], &[]).0, None);
}
#[test]
fn overlays_reach_toward_visible_sand() {
let sand = predicate(
r#"{"type": "and", "predicates": [{"type": "match_block", "block": "sand"}, {"type": "is_face_visible"}]}"#,
);
let fusion = world(
&["minecraft:air", "minecraft:stone", "minecraft:sand"],
vec![sheet(Layout::Overlay, 200, &[7])],
vec![(0, sand)],
);
// Sand to the north of stone's top face, its own top open: the top edge piece, (1, 2),
// over a face that draws nothing itself.
let north = [([0, 0, -1], 2)];
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &north, &[]),
(Some(Pick::Nothing), vec![213])
);
// Stone over that sand hides its top: no edge.
let covered = [([0, 0, -1], 2), ([0, 1, -1], 1)];
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &covered, &[1]),
(Some(Pick::Nothing), vec![])
);
// Sand all round: the ring, (4, 1).
let ring: Vec<_> = [-1, 0, 1]
.into_iter()
.flat_map(|x| [-1, 0, 1].map(|z| ([x, 0, z], 2)))
.filter(|(o, _)| *o != [0, 0, 0])
.collect();
assert_eq!(pick_in(&fusion, 0, 1, Face::Up, &ring, &[]).1, [210]);
// Its pieces take the quad's tint index.
let mut tints = Vec::new();
let at = |o: [i32; 3]| if o == [0, 0, -1] { 2 } else { 0 };
let rule = NonZeroU16::new(1).unwrap();
fusion.pick(rule, 1, Face::Up, 0, at, |_| false, |o| tints.push(o.tint));
assert_eq!(tints, [Some((0, None))]);
}
#[test]
fn in_front_and_empty_tiles() {
// Dirt's top joins where sand sits on the block beside it; its full sheet leaves the
// lone tile empty.
let front = predicate(r#"{"type": "match_block_in_front", "block": "sand"}"#);
let fusion = world(
&["minecraft:air", "minecraft:dirt", "minecraft:sand"],
vec![sheet(Layout::Full, 300, &[0])],
vec![(0, front)],
);
// Sand on the block east of the top face: joined right, the full template's (1, 0).
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &[([1, 1, 0], 2)], &[]).0,
Some(Pick::Tile(layer(301)))
);
// Sand beside it, not in front of anything: alone, and the lone tile is empty.
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &[([1, 0, 0], 2)], &[]).0,
Some(Pick::Nothing)
);
}
#[test]
fn models_name_the_predicates_their_faces_draw() {
let root =
std::env::temp_dir().join(format!("horizon-fusion-models-{}", std::process::id()));
let models = root.join("assets/demo/models/block");
fs::create_dir_all(&models).unwrap();
// A pane arm over a template whose `particle` names the pane sheet but draws no face
// with it (as Fusion Connected Glass's posts do): only the faces count.
fs::write(
models.join("template.json"),
r##"{"textures": {"particle": "#pane"}, "elements": [{"from": [7, 0, 0], "to": [9, 16, 7],
"faces": {"up": {"texture": "#edge"}, "west": {"texture": "#pane"}}}]}"##,
)
.unwrap();
fs::write(
models.join("arm.json"),
r#"{"loader": "fusion:model", "type": "fusion:connecting", "parent": "demo:block/template",
"connections": {"edge": {"type": "true"}},
"textures": {"pane": "demo:block/glass", "edge": {"sprite": "demo:block/edge"}}}"#,
)
.unwrap();
fs::write(
models.join("plain.json"),
r#"{"parent": "demo:block/template", "textures": {"pane": "demo:block/glass"}}"#,
)
.unwrap();
let arm = connecting_model(&root, "demo:block/arm");
let plain = connecting_model(&root, "demo:block/plain");
fs::remove_dir_all(&root).unwrap();
// `edge` has the model's predicate; `pane` none, so the texture's own.
assert_eq!(
arm,
[
(
"demo:block/edge".to_owned(),
Face::Up,
Some(Connection::Const(true))
),
("demo:block/glass".to_owned(), Face::West, None),
]
);
// Not a Fusion model: vanilla.
assert!(plain.is_empty());
}
#[test]
fn faces_of_one_sprite_join_by_their_own_keys() {
let root = std::env::temp_dir().join(format!("horizon-fusion-keys-{}", std::process::id()));
let models = root.join("assets/demo/models/block");
fs::create_dir_all(&models).unwrap();
// The wiki's example: four keys on one texture, two of them joining by `blue`.
fs::write(
models.join("tiles.json"),
r##"{"loader": "fusion:model", "type": "connecting",
"textures": {"ice": "block/oak_tiles", "dirt": "block/oak_tiles", "stone": "block/oak_tiles"},
"connections": {"blue": {"type": "match_block", "block": "ice"},
"dirt": {"type": "is_same_block"}, "default": {"type": "is_same_state"}},
"elements": [{"from": [0, 0, 0], "to": [16, 16, 16], "faces": {
"up": {"texture": "#ice", "connections": "#blue"},
"north": {"texture": "#dirt"}, "south": {"texture": "#stone"}}}]}"##,
)
.unwrap();
let faces = connecting_model(&root, "demo:block/tiles");
fs::remove_dir_all(&root).unwrap();
let joins = |side| faces.iter().find(|f| f.1 == side).and_then(|f| f.2.clone());
assert!(matches!(joins(Face::Up), Some(Connection::Neighbour(_))));
assert_eq!(joins(Face::North), Some(Connection::SameBlock));
assert_eq!(joins(Face::South), Some(Connection::SameState));
// A blockstate's turns move them: x=90 takes the top to the north, y=90 the north to
// the east.
assert_eq!(turned(Face::Up, [1, 0, 0]), Face::North);
assert_eq!(turned(Face::North, [0, 1, 0]), Face::East);
assert_eq!(turned(Face::Up, [1, 1, 0]), Face::East);
}
}
File diff suppressed because it is too large. Load diff
+216 -32
View File
@@ -157,21 +157,24 @@ impl Arena {
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
}
/// The sorted-index arena and, when `split`, the translucent arena
/// (vertices of `stride`), both with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena, and the indices
/// in pages flat draws (`flat`) bind as storage.
/// (vertices of `stride`) with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena. The indices go in
/// large pages too, unless flat draws (`flat`) bind their pages as storage.
pub fn translucent_arenas(
device: &wgpu::Device,
stride: u64,
flat: bool,
split: bool,
) -> (Self, Option<Self>) {
if split {
let indices = Self::translucent_indices(device, 1 << 16);
(indices, Some(Self::translucent(device, stride, 1 << 18)))
let indices = if split && !flat {
Self::translucent_indices(device, 1 << 16)
} else {
(Self::indices(device, 1 << 16, flat), None)
}
Self::indices(device, 1 << 16, flat)
};
(
indices,
split.then(|| Self::translucent(device, stride, 1 << 18)),
)
}
fn with_usage(
device: &wgpu::Device,
@@ -212,12 +215,13 @@ impl Arena {
spill: bool,
) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
let (label, bytes) = ("Section buffer arena", capacity * self.stride);
#[cfg(test)]
let local = (!self.video_memory_full)
.then(|| try_create(device, capacity * self.stride, usage))
.then(|| try_create(device, label, bytes, usage))
.flatten();
#[cfg(not(test))]
let local = try_create(device, capacity * self.stride, usage);
let local = try_create(device, label, bytes, usage);
let local = local.map(|buffer| (buffer, false));
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
@@ -229,11 +233,7 @@ impl Arena {
{
return None;
}
let buffer = try_create(
device,
capacity * self.stride,
usage | wgpu::BufferUsages::MAP_WRITE,
)?;
let buffer = try_create(device, label, bytes, usage | wgpu::BufferUsages::MAP_WRITE)?;
if !std::mem::replace(&mut self.spilled, true) {
log::warn!(
"Horizon: video memory is full; more terrain is kept in system memory \
@@ -503,12 +503,17 @@ impl Arena {
by_page[page].push((start, Self::span(vertices).min(self.page_limit)));
}
for (page, ranges) in by_page.into_iter().enumerate() {
if !ranges.is_empty() {
self.pages[page]
.as_mut()
.expect("live arena page")
.free
.give_all(ranges);
if ranges.is_empty() {
continue;
}
let page = self.pages[page].as_mut().expect("live arena page");
// Every range of the page given back (a far teleport empties the arena): one
// free range, without sorting tens of thousands into the list.
let given: u64 = ranges.iter().map(|r| r.1).sum();
if page.free_vertices() + given == page.capacity {
page.free = FreeList::new(page.capacity);
} else {
page.free.give_all(ranges);
}
}
}
@@ -628,10 +633,15 @@ pub fn free_later<T: Send + 'static>(items: Vec<T>) {
/// `create_buffer` with an out-of-memory failure returned instead of raised
/// (raised errors are fatal).
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
pub(super) fn try_create(
device: &wgpu::Device,
label: &str,
size: u64,
usage: wgpu::BufferUsages,
) -> Option<wgpu::Buffer> {
let scope = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Section buffer arena"),
label: Some(label),
size,
usage,
mapped_at_creation: false,
@@ -743,6 +753,41 @@ mod tests {
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
}
/// Released together, every range of a page leaves it one free range (a far teleport
/// empties the arena at once), while a page keeping some ranges frees only the others.
#[test]
fn ranges_released_at_once_free_exactly_their_vertices() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN, false);
arena.page_limit = 4 * GRAIN;
let ranges: Vec<(u32, u64)> = (0..8)
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
.collect();
let (first, second) = (ranges[0].0, ranges[7].0);
assert_ne!(first, second);
// All four of the first page, the middle two of the second.
let released: Vec<(u32, u64, u64)> = ranges
.iter()
.enumerate()
.filter(|&(i, _)| i < 4 || i == 5 || i == 6)
.map(|(_, &(page, start))| (page, start, GRAIN))
.collect();
arena.release_all(released);
let free = |page: u32| -> Vec<(u64, u64)> {
let free = &arena.pages[page as usize].as_ref().unwrap().free.0;
free.iter().map(|(&start, &len)| (start, len)).collect()
};
assert_eq!(free(first), vec![(0, 4 * GRAIN)]);
assert_eq!(free(second), vec![(ranges[5].1, 2 * GRAIN)]);
// The emptied page takes a whole page's range again, without growing.
let pages = arena.pages.iter().flatten().count();
assert_eq!(arena.alloc_existing(4 * GRAIN), Some((first, 0)));
assert_eq!(arena.pages.iter().flatten().count(), pages);
}
#[test]
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
let Some((device, queue)) = device() else {
@@ -817,10 +862,11 @@ mod tests {
/// section holds in the section arena, data and all. Split, that is its
/// solid quads alone, and nothing of a section without any, whose (0, 0)
/// names no range of page 0; else solid and translucent quads together,
/// `translucent_at` along.
/// `translucent_at` along. GPU culling's records of the sections with solid
/// quads move with them.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{Part, SectionGpu, move_off_page};
use super::super::{Part, SectionGpu, gpu_culling, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
@@ -836,6 +882,7 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
gpu_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -883,6 +930,15 @@ mod tests {
.iter()
.map(|s| (s.page, s.start, s.translucent_at))
.collect();
// The sections with solid quads have records, on page 0.
let mut records = gpu_culling::Records::default();
for (i, s) in sections.iter_mut().enumerate() {
if s.solid > 0 {
s.gpu_id = Some(records.insert((i as i32, 0, 0), s.page, s.start, s.solid));
}
}
let recorded = records.live() as u32;
assert_eq!(records.page_sections(), [recorded], "split {split}");
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
@@ -891,6 +947,7 @@ mod tests {
&mut arena,
&mut encoder,
None,
Some(&mut records),
meshes,
0,
Part::Section { split },
@@ -900,6 +957,11 @@ mod tests {
let word =
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in sections.iter().enumerate() {
// Its record names its solid quads where they are now.
let record = (s.solid > 0)
.then(|| gpu_culling::Record::new((i as i32, 0, 0), s.page, s.start, s.solid));
let held = s.gpu_id.and_then(|id| records.at(id));
assert_eq!(held, record.as_ref(), "split {split}, section {i}");
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
assert_eq!(
(s.page, s.start, s.translucent_at),
@@ -918,6 +980,8 @@ mod tests {
}
}
}
// Page 0's count went to page 1 with them.
assert_eq!(records.page_sections(), [0, recorded], "split {split}");
// Page 0 empties without a range given twice, and page 1 took a grain
// per section moved: one is left.
arena.trim();
@@ -927,6 +991,123 @@ mod tests {
}
}
/// Flat draws find a section's origin by the id its blocks name, in the section arena's
/// tables and, split, in the translucent arena's (`flat::TRANSLUCENT_KEYS`). Translucent
/// quads a shrink moves (`move_off_page`) keep naming their section, or flat draws would
/// put them at another section's origin.
#[test]
fn moved_translucent_quads_keep_their_section_s_flat_id() {
use super::super::{Part, SectionGpu, flat, move_off_page, upload::Uploads};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }"
.into(),
),
});
let terrain_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[],
});
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
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::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
flat.set_translucent_page_vertices(translucent.page_vertices());
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
// Page 0: another section's grain, then this one's two; page 1: a grain taken.
let (_, other) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
let (_, at) = alloc(&mut translucent, &device, &queue, 2 * GRAIN).unwrap();
let (_, rest) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
assert_eq!(
alloc(&mut translucent, &device, &queue, GRAIN),
Some((1, 0))
);
translucent.release(0, rest, GRAIN);
let key = |page: u32| page + flat::TRANSLUCENT_KEYS;
let other_id = flat.add(&device, &mut encoder, &mut uploads, key(0), other, GRAIN);
// Its solid quads in the section arena's page 0, its translucent ones here.
let id = flat.add(&device, &mut encoder, &mut uploads, 0, 0, GRAIN);
flat.place(
&device,
&mut encoder,
&mut uploads,
id,
key(0),
at,
2 * GRAIN,
);
let mut section = SectionGpu {
page: 0,
start: 0,
translucent_at: (0, at),
solid: 64,
sides: crate::mesher::Sides::unsorted(64),
translucent: 128,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: Some(id),
gpu_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
translucent.close(0);
let mut budget = u64::MAX;
assert!(move_off_page(
&mut translucent,
&mut encoder,
Some((&mut flat, &device, &mut uploads)),
None,
std::iter::once(&mut section),
0,
Part::Translucent,
&mut budget
));
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
let (page, start) = section.translucent_at;
assert_eq!(page, 1);
assert_eq!(section.flat_id, Some(id));
let named =
|page: u32, grain: u64| read(&device, &queue, flat.blocks(key(page)), grain * 4);
for grain in start / GRAIN..(start / GRAIN + 2) {
assert_eq!(named(1, grain), id, "grain {grain} of its new range");
}
// The section arena's table and the other section's grain are untouched.
assert_eq!(read(&device, &queue, flat.blocks(0), 0), id);
assert_eq!(named(0, other / GRAIN), other_id);
}
/// Draws bind a large page up to the end of its last range in use, a grain at
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
@@ -1028,9 +1209,9 @@ mod tests {
}
/// The translucent arena comes with the split layout alone, and only then
/// are the index pages large. Otherwise, as while a shader pack's terrain
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
/// section arena, and flat draws (`flat`) bind the index pages as storage.
/// are the index pages large, unless flat draws (`flat`) bind them as
/// storage. Otherwise, as while a shader pack's terrain draws flat (gfx's
/// `translucent_split`), translucent quads keep to the section arena.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
@@ -1050,9 +1231,9 @@ mod tests {
assert_eq!(translucent.is_some(), split, "{case}");
let bytes = indices.page_vertices() * 4;
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
assert_eq!(storage, flat && !split, "{case}");
assert_eq!(storage, flat, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
assert_eq!(bytes > PAGE_BYTES, split && !flat && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
@@ -1119,6 +1300,7 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
gpu_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1171,7 +1353,9 @@ mod tests {
meshes: &mut [(u32, SectionGpu)],
budget: &mut u64| {
let held = meshes.iter_mut().map(|(_, s)| s);
assert!(move_off_page(arena, encoder, None, held, 0, part, budget));
assert!(move_off_page(
arena, encoder, None, None, held, 0, part, budget
));
let on = |s: &SectionGpu| part.of(s).is_some_and(|r| r.0 == 1);
meshes.iter().filter(|(_, s)| on(s)).count()
};
@@ -1231,7 +1415,7 @@ mod tests {
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let (limit, step) = (arena.page_limit, PAGE_BYTES / 24);
// `oversized` looks at the counts only: a small buffer stands in.
let buffer = try_create(&device, 4, wgpu::BufferUsages::VERTEX).unwrap();
let buffer = try_create(&device, "Stand-in page", 4, wgpu::BufferUsages::VERTEX).unwrap();
let mut shrinks = |capacity: u64, used: u64, host: bool| {
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
let buffer = buffer.clone();
@@ -29,6 +29,8 @@ pub(super) struct EntityOutline {
vertices: wgpu::Buffer,
capacity: u64,
quads: u32,
/// The silhouettes posed on the GPU: first vertex in the entity buffer, quads.
posed: (u32, u32),
targets: Option<Targets>,
}
@@ -159,6 +161,7 @@ impl EntityOutline {
vertices: Self::vertex_buffer(device, 256),
capacity: 256,
quads: 0,
posed: (0, 0),
targets: None,
}
}
@@ -175,16 +178,20 @@ impl EntityOutline {
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window. A frame with
/// none gives the targets' video memory back until an entity glows again.
/// `posed`: where the silhouettes posed on the GPU start in the entity
/// buffer (`entity_skin`'s outline jobs), and how many quads they are.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
silhouettes: &[Vertex],
posed: (u32, u32),
size: [u32; 2],
) {
self.quads = (silhouettes.len() / 4) as u32;
if self.quads == 0 {
self.posed = posed;
if self.quads == 0 && posed.1 == 0 {
self.targets = None;
return;
}
@@ -193,12 +200,14 @@ impl EntityOutline {
self.capacity = vertices.next_power_of_two();
self.vertices = Self::vertex_buffer(device, self.capacity);
}
uploads.buffer(
encoder,
&self.vertices,
0,
bytemuck::cast_slice(silhouettes),
);
if !silhouettes.is_empty() {
uploads.buffer(
encoder,
&self.vertices,
0,
bytemuck::cast_slice(silhouettes),
);
}
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
@@ -260,9 +269,14 @@ impl EntityOutline {
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
entities: &wgpu::Buffer,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
let Some(t) = self
.targets
.as_ref()
.filter(|_| self.quads > 0 || self.posed.1 > 0)
else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
@@ -277,8 +291,15 @@ impl EntityOutline {
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
if self.quads > 0 {
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
}
let (first, quads) = self.posed;
if quads > 0 {
pass.set_vertex_buffer(0, entities.slice(..));
pass.draw_indexed(0..quads * 6, first as i32, 0..1);
}
}
// post_effect/entity_outline.json; its last pass only copies swap back to the
// outline target, so the blit reads swap.
@@ -10,7 +10,8 @@
//!
//! The game decides which draws come here (`SkinFrame`): models whose
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
//! The rest stays on the CPU path.
//! The rest stays on the CPU path. Item models come too ([`SkinJob::item`]):
//! their quads all face an axis, and their shading is baked into the colour.
use super::upload::Uploads;
use crate::mesher::Vertex;
@@ -35,6 +36,20 @@ pub struct SkinPart {
pub normal: [f32; 9],
}
impl SkinPart {
/// An item layer's part: its pose, and its quads' colours by axis.
pub fn item(matrix: glam::Mat4, colors: [u32; 6]) -> Self {
let mut normal = [0.0; 9];
for (word, color) in normal.iter_mut().zip(colors) {
*word = f32::from_bits(color);
}
Self {
matrix: matrix.to_cols_array(),
normal,
}
}
}
/// One run of a model's vertices drawn with one texture rect, colour, light
/// and overlay. `dst` counts from the frame's first posed vertex.
#[repr(C)]
@@ -51,7 +66,26 @@ pub struct SkinJob {
pub overlay: u32,
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
pub glint: u32,
pub spare: [u32; 3],
/// How the vertices are finished: [`SkinJob::MODEL`], [`SkinJob::ITEM`] or
/// [`SkinJob::FLAT`].
pub mode: u32,
/// 1 for a model whose render type culls back faces (`mobs::Polygon::cull_back`):
/// the normal's w is `mesher::SHADED_CULL_W` instead of -127.
pub cull: u32,
/// Always 0: the shader reads it as a zero it cannot fold away.
pub spare: u32,
}
impl SkinJob {
/// A model's polygons: the face normal posed, the job's colour.
pub const MODEL: u32 = 0;
/// An item model's quads (`item_render::WorldSink`): the colour by the face's
/// axis rides in the part's first 6 normal words ([`SkinPart::item`]), shaded on
/// the CPU, and the normal is `mesher::CULL_BACK`.
pub const ITEM: u32 = 1;
/// A model's polygons with no normal at all (all four bytes 0): the eyes render
/// type's layers, which entity.wgsl leaves unshaded.
pub const FLAT: u32 = 2;
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
@@ -71,8 +105,15 @@ pub struct SkinFrame {
pub new_model: Vec<SkinVertex>,
pub parts: Vec<SkinPart>,
pub jobs: Vec<SkinJob>,
/// Shader identities in the GPU output vertex space; empty without a pack.
pub spans: Vec<super::pack_draw::EntitySpan>,
/// Vertices the jobs write: a whole number of quads.
pub vertices: u32,
/// Glowing entities' silhouettes posed here (`OutlineBufferSource`'s copy of
/// their draws, [`SkinFrame::outline_since`]): `dst` counts from the first.
pub outline_jobs: Vec<SkinJob>,
/// Vertices the outline jobs write.
pub outline_vertices: u32,
}
impl SkinFrame {
@@ -80,7 +121,26 @@ impl SkinFrame {
pub fn clear_draws(&mut self) {
self.parts.clear();
self.jobs.clear();
self.spans.clear();
self.vertices = 0;
self.outline_jobs.clear();
self.outline_vertices = 0;
}
/// Copies the draws since job `first` into the glowing entities' outline
/// (`Frame::glowing`'s, on the GPU), as `glowing.rs`'s `mark_outline` copies
/// vertices: the same ones, in `color` (`Vertex::color`) with no normal.
pub fn outline_since(&mut self, first: usize, color: u32) {
for i in first..self.jobs.len() {
let job = self.jobs[i];
self.outline_jobs.push(SkinJob {
dst: self.outline_vertices,
color,
mode: SkinJob::FLAT,
..job
});
self.outline_vertices += job.count;
}
}
/// The renderer took the new models.
@@ -100,11 +160,15 @@ impl SkinFrame {
self.jobs.truncate(jobs);
self.parts.truncate(parts);
self.vertices = vertices;
self.spans.retain(|s| s.start <= vertices);
}
/// Whether the renderer has anything to take.
pub fn pending(&self) -> bool {
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
self.reset
|| !self.new_model.is_empty()
|| !self.jobs.is_empty()
|| !self.outline_jobs.is_empty()
}
}
@@ -259,7 +323,8 @@ impl EntitySkin {
}
/// Records the frame's jobs: they write `frame.vertices` vertices into
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
/// `out` from vertex `base` on, and the outline jobs `frame.outline_vertices`
/// from vertex `outline_base` on. Call after the CPU vertices' upload.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch(
&mut self,
@@ -269,39 +334,104 @@ impl EntitySkin {
frame: &SkinFrame,
out: &wgpu::Buffer,
base: u32,
outline_base: u32,
) {
if frame.jobs.is_empty() {
self.dispatch_jobs(
device,
uploads,
encoder,
&frame.parts,
frame
.jobs
.iter()
.map(|job| SkinJob {
dst: job.dst + base,
..*job
})
.chain(frame.outline_jobs.iter().map(|job| SkinJob {
dst: job.dst + outline_base,
..*job
})),
out,
);
}
/// A standard pack owns the body output; glowing silhouettes keep the ordinary
/// entity buffer. Both routes use the same model/normal/back-face decoder.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch_pack(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
out: &wgpu::Buffer,
outline_out: &wgpu::Buffer,
outline_base: u32,
) {
self.dispatch_jobs(
device,
uploads,
encoder,
&frame.parts,
frame.jobs.iter().copied(),
out,
);
self.dispatch_jobs(
device,
uploads,
encoder,
&frame.parts,
frame.outline_jobs.iter().map(|job| SkinJob {
dst: job.dst + outline_base,
..*job
}),
outline_out,
);
}
#[allow(clippy::too_many_arguments)]
fn dispatch_jobs(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
parts: &[SkinPart],
jobs: impl Iterator<Item = SkinJob>,
out: &wgpu::Buffer,
) {
self.staged.clear();
self.staged.extend(jobs);
if self.staged.is_empty() {
return;
}
let Some(model) = &self.model_buffer else {
return;
};
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
let count = frame.jobs.len() as u32;
let count = self.staged.len() as u32;
let rows = count.div_ceil(ROW);
let padded = if rows > 1 {
(rows * ROW) as usize
} else {
frame.jobs.len()
count as usize
};
self.staged.clear();
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
dst: job.dst + base,
..*job
}));
self.staged.resize(padded, SkinJob::default());
self.parts.ensure(
device,
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
);
self.parts
.ensure(device, std::mem::size_of_val(parts) as u64);
self.jobs
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
let (Some(parts_buffer), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
return;
};
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
uploads.buffer(encoder, parts_buffer, 0, bytemuck::cast_slice(parts));
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
let key = [
model.clone(),
parts_buffer.clone(),
jobs.clone(),
out.clone(),
];
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
let entries: Vec<_> = key
.iter()
@@ -335,8 +465,22 @@ impl EntitySkin {
/// the same way: the reference its tests hold the shader and the game's walk
/// (`mobs::skin`) to. `model` is the whole store.
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
for job in &frame.jobs {
expand_jobs(model, frame, &frame.jobs, frame.vertices)
}
/// [`expand`] of the frame's outline jobs: the silhouettes they write.
pub fn expand_outline(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
expand_jobs(model, frame, &frame.outline_jobs, frame.outline_vertices)
}
fn expand_jobs(
model: &[SkinVertex],
frame: &SkinFrame,
jobs: &[SkinJob],
vertices: u32,
) -> Vec<Vertex> {
let mut out = vec![bytemuck::Zeroable::zeroed(); vertices as usize];
for job in jobs {
for k in 0..job.count {
let v = model[(job.first + k) as usize];
let part = &frame.parts[(job.part + v.part) as usize];
@@ -352,13 +496,26 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
(v.uv[0] + job.scroll) % 1.0
};
let r = job.rect;
let (color, normal) = if job.mode == SkinJob::ITEM {
let color = part.normal[v.axis as usize].to_bits();
(color, crate::mesher::CULL_BACK)
} else if job.mode == SkinJob::FLAT {
(job.color, [0; 4])
} else {
let w = if job.cull != 0 {
crate::mesher::SHADED_CULL_W
} else {
-127
};
(job.color, [packed[0], packed[1], packed[2], w])
};
out[(job.dst + k) as usize] = Vertex {
pos: pos.to_array(),
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
layer: job.overlay,
color: job.color,
color,
light: job.light,
normal: [packed[0], packed[1], packed[2], -127],
normal,
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
};
}
@@ -406,8 +563,8 @@ mod tests {
}
/// Two draws of a two-part model with turned, scaled parts, a scrolled
/// texture and the armour glint: the shader writes what `expand` does,
/// to the bit, after the vertices before them.
/// texture and the armour glint, and an item's: the shader writes what
/// `expand` does, to the bit, after the vertices before them.
#[test]
fn the_shader_writes_what_the_cpu_reference_computes() {
let Some((device, queue)) = device() else {
@@ -432,6 +589,7 @@ mod tests {
normals.z_axis.normalize_or_zero(),
);
let frame = SkinFrame {
spans: Vec::new(),
reset: false,
base: 0,
new_model: model.clone(),
@@ -440,6 +598,18 @@ mod tests {
part(child, normals),
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
part(child * turn, glam::Mat3::IDENTITY),
// Colours whose bits as floats are NaNs and denormals: copied, never computed.
SkinPart::item(
child * turn,
[
0xff80_4020,
0x7fc0_0001,
0x0000_0001,
0xffff_ffff,
0x7f80_0001,
0x1234_5678,
],
),
],
jobs: vec![
SkinJob {
@@ -452,6 +622,8 @@ mod tests {
color: 0xff80_40ff,
light: 0xf0_70,
overlay: 1,
// The bat's encoding: the shader must write -126 like `expand`.
cull: 1,
..Default::default()
},
SkinJob {
@@ -467,15 +639,59 @@ mod tests {
glint: 1,
..Default::default()
},
// An item's quads: colour by axis from its part, `CULL_BACK` normals.
SkinJob {
first: 0,
count: 24,
part: 4,
dst: 72,
rect: [0.5, 0.25, 0.5625, 0.3125],
light: 0xa0_30,
overlay: 2 << 16,
mode: SkinJob::ITEM,
..Default::default()
},
// An eyes layer: no normal, even over a culled model's encoding; the
// tier and OIT tag ride in the overlay word.
SkinJob {
first: 24,
count: 24,
part: 1,
dst: 96,
rect: [0.125, 0.75, 0.25, 0.875],
color: 0x80ff_ffff,
light: 0xf0f0,
overlay: crate::gfx::OIT_ENTITY | 3 << 16 | 7,
mode: SkinJob::FLAT,
cull: 1,
..Default::default()
},
],
vertices: 72,
vertices: 120,
outline_jobs: Vec::new(),
outline_vertices: 0,
};
// A glowing entity's silhouette: the item job and the first job again,
// in its team colour with no normal.
let mut frame = frame;
frame.jobs.push(frame.jobs[2]);
frame.jobs.last_mut().expect("pushed").dst = 120;
frame.jobs.push(SkinJob {
dst: 144,
..frame.jobs[0]
});
frame.vertices = 192;
frame.outline_since(4, 0xff20_c0e0);
assert_eq!(frame.outline_vertices, 72);
let expected = expand(&model, &frame);
// The posed vertices land after 5 others, which they leave alone.
let outline = expand_outline(&model, &frame);
// The posed vertices land after 5 others, which they leave alone, and the
// silhouettes 3 past them.
let before = 5u32;
let outline_base = before + frame.vertices + 3;
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(before + frame.vertices) * 44,
size: u64::from(outline_base + frame.outline_vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
@@ -484,7 +700,15 @@ mod tests {
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
skin.dispatch(
&device,
&mut uploads,
&mut encoder,
&frame,
&out,
before,
outline_base,
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: out.size(),
@@ -507,5 +731,81 @@ mod tests {
"vertex {i}: {got:?} != {want:?}"
);
}
let silhouettes = &got[(outline_base - before) as usize..];
assert_eq!(silhouettes.len(), outline.len());
for (i, (got, want)) in silhouettes.iter().zip(&outline).enumerate() {
assert_eq!(
bytemuck::bytes_of(got),
bytemuck::bytes_of(want),
"silhouette {i}: {got:?} != {want:?}"
);
}
// A silhouette is its draw's vertex with the colour and no normal
// (`mark_outline`).
for (copy, of) in outline.iter().zip(&expected[120..]) {
let marked = Vertex {
color: 0xff20_c0e0,
normal: [0; 4],
..*of
};
assert_eq!(bytemuck::bytes_of(copy), bytemuck::bytes_of(&marked));
}
// Standard packs own the model buffer, while outlines follow unrelated CPU
// geometry in the ordinary buffer. Neither output may overwrite the other's gap.
let buffers = [frame.vertices + 3, before + frame.outline_vertices + 2].map(|vertices| {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
queue.write_buffer(&buffer, 0, &vec![0xab; buffer.size() as usize]);
buffer
});
let reads = buffers.each_ref().map(|buffer| {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: buffer.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
});
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
skin.dispatch_pack(
&device,
&mut uploads,
&mut encoder,
&frame,
&buffers[0],
&buffers[1],
before,
);
for (buffer, read) in buffers.iter().zip(&reads) {
encoder.copy_buffer_to_buffer(buffer, 0, read, 0, buffer.size());
}
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
for read in &reads {
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
}
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
for (i, (expected, start)) in [(&expected, 0usize), (&outline, before as usize)]
.into_iter()
.enumerate()
{
let data = reads[i].slice(..).get_mapped_range().unwrap();
let first = start * 44;
let last = first + expected.len() * 44;
assert!(data[..first].iter().all(|b| *b == 0xab), "prefix {i}");
assert_eq!(
&data[first..last],
bytemuck::cast_slice::<_, u8>(expected),
"output {i}"
);
assert!(data[last..].iter().all(|b| *b == 0xab), "suffix {i}");
}
}
}
@@ -15,7 +15,9 @@ const MODEL_WORDS: u32 = 7u;
const PART_WORDS: u32 = 25u;
// A job (16 words): first model vertex, vertex count, first part, first output
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
// whether the overlay is the armour glint (its model UV in `glint_uv`), the mode
// (`SkinJob::mode`: 0 a model, 1 an item's quads, 2 a model with no normal),
// whether its quads cull back faces (`SkinJob::cull`), 1 spare (0: `keep`'s).
const JOB_WORDS: u32 = 16u;
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
// (4 x i8), glint_uv (2).
@@ -30,6 +32,20 @@ fn f(words: u32) -> f32 {
return bitcast<f32>(words);
}
// A zero the compiler cannot see (the job's last word). Metal compiles shaders
// with fast math: it would fuse `a * b + c` into one rounding and reorder sums,
// a bit off the CPU's separate operations. A value xored with `keep` is opaque
// to it, so each operation below rounds on its own, in the CPU's order.
var<private> keep: u32;
fn r(v: f32) -> f32 {
return bitcast<f32>(bitcast<u32>(v) ^ keep);
}
fn r3(v: vec3<f32>) -> vec3<f32> {
return bitcast<vec3<f32>>(bitcast<vec3<u32>>(v) ^ vec3(keep));
}
fn part_f(part: u32, i: u32) -> f32 {
return f(parts[part * PART_WORDS + i]);
}
@@ -40,8 +56,8 @@ fn scroll_u(u: f32, scroll: f32) -> f32 {
return u;
}
// Rust's `%` on floats: the remainder with the dividend's sign.
let s = u + scroll;
return s - trunc(s);
let s = r(u + scroll);
return r(s - r(trunc(s)));
}
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
@@ -72,33 +88,50 @@ fn skin(
let light = jobs[j + 10u];
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
let mode = jobs[j + 13u];
let cull = jobs[j + 14u] != 0u;
keep = jobs[j + 15u];
for (var k = lane; k < count; k += 64u) {
let v = (first + k) * MODEL_WORDS;
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
let axis = model[v + 5u];
let part = first_part + model[v + 6u];
// glam's Mat4::transform_point3, in its order.
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
// glam's Mat4::transform_point3, in its order, each operation rounded.
var pos = r3(vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x);
pos = r3(r3(vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y) + pos);
pos = r3(r3(vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z) + pos);
pos = r3(vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos);
// An axis normal times the normal matrix is that column, exactly.
let c = 16u + (axis % 3u) * 3u;
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
if axis >= 3u {
n = -n;
}
// An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`.
var vertex_color = color;
var normal = pack(n);
if cull {
// `mesher::SHADED_CULL_W` (-126) in the normal's w byte.
normal = (normal & 0x00ffffffu) | (u32(-126 & 0xff) << 24u);
}
if mode == 1u {
vertex_color = parts[part * PART_WORDS + 16u + axis];
normal = 1u << 24u;
} else if mode == 2u {
// The eyes render type's layers: no normal (`SkinJob::FLAT`).
normal = 0u;
}
let o = (dst + k) * VERTEX_WORDS;
out[o] = bitcast<u32>(pos.x);
out[o + 1u] = bitcast<u32>(pos.y);
out[o + 2u] = bitcast<u32>(pos.z);
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
out[o + 3u] = bitcast<u32>(rect.x + r(r(rect.z - rect.x) * scroll_u(uv.x, scroll)));
out[o + 4u] = bitcast<u32>(rect.y + r(r(rect.w - rect.y) * uv.y));
out[o + 5u] = overlay;
out[o + 6u] = color;
out[o + 6u] = vertex_color;
out[o + 7u] = light;
out[o + 8u] = pack(n);
out[o + 8u] = normal;
if glint {
out[o + 9u] = bitcast<u32>(uv.x);
out[o + 10u] = bitcast<u32>(uv.y);
+75 -12
View File
@@ -48,6 +48,10 @@ struct Plan {
}
const QUADS: u32 = u32::MAX;
const GROUP_LIMIT: u32 = 65535;
/// The page keys of the translucent arena (`Renderer::translucent_arena`, Translucent
/// Batching): its page plus this. The section arena's pages are their own keys, all far
/// below it. A call names its key: its vertices come from that arena's page.
pub const TRANSLUCENT_KEYS: u32 = 1 << 10;
struct PageTable {
/// Section id of every block of the page.
@@ -67,6 +71,7 @@ pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
/// A run of the frame's index list drawn from one arena page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Call {
/// The page's key (`TRANSLUCENT_KEYS`).
pub page: u32,
pub first: u32,
pub count: u32,
@@ -98,6 +103,9 @@ pub struct Flat {
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
pub(super) draw_layout: wgpu::BindGroupLayout,
blocks_per_page: u64,
/// Blocks of a translucent arena page (`TRANSLUCENT_KEYS`), whose pages are larger.
translucent_blocks_per_page: u64,
/// Block tables by page key.
pages: Vec<Option<PageTable>>,
free_ids: Vec<u32>,
next_id: u32,
@@ -218,7 +226,14 @@ fn buffer(
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(16),
usage: usage | wgpu::BufferUsages::COPY_DST,
// Tests read the tables back.
usage: usage
| wgpu::BufferUsages::COPY_DST
| if cfg!(test) {
wgpu::BufferUsages::COPY_SRC
} else {
wgpu::BufferUsages::empty()
},
mapped_at_creation: false,
})
}
@@ -242,6 +257,7 @@ fn plan(
shadow: [usize; 2],
passes: [usize; 2],
align: u64,
key: impl Fn(u32, Option<u32>) -> u32,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
segments: &mut Vec<(Source, u64, u64)>,
@@ -273,6 +289,7 @@ fn plan(
}
let starts =
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
let page = key(page, sorted);
match calls.last_mut() {
Some(last) if last.page == page && !starts => last.count += d[0],
_ => calls.push(Call {
@@ -451,6 +468,7 @@ impl Flat {
phase2_group: None,
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
pages: Vec::new(),
free_ids: Vec::new(),
next_id: 0,
@@ -479,8 +497,8 @@ impl Flat {
.max(1),
}
}
/// A section uploaded to `vertices` vertices from `start` of `page`: its
/// id, which its blocks now name (written through `uploads` in `encoder`).
/// A section uploaded to `vertices` vertices from `start` of the page keyed `page`:
/// its id, which its blocks now name (written through `uploads` in `encoder`).
pub fn add(
&mut self,
device: &wgpu::Device,
@@ -494,6 +512,28 @@ impl Flat {
self.next_id += 1;
self.next_id - 1
});
self.place(device, encoder, uploads, id, page, start, vertices);
id
}
/// Makes the blocks of `vertices` vertices from `start` of the page keyed `page` name
/// `id`: a section's other range (its translucent quads, in the translucent arena), or
/// a range that moved.
#[allow(clippy::too_many_arguments)]
pub fn place(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
id: u32,
page: u32,
start: u64,
vertices: u64,
) {
let blocks = if page >= TRANSLUCENT_KEYS {
self.translucent_blocks_per_page
} else {
self.blocks_per_page
};
let page = page as usize;
if self.pages.len() <= page {
self.pages.resize_with(page + 1, || None);
@@ -502,7 +542,7 @@ impl Flat {
blocks: buffer(
device,
"Section ids by block",
self.blocks_per_page * 4,
blocks * 4,
wgpu::BufferUsages::STORAGE,
),
group: None,
@@ -515,7 +555,19 @@ impl Flat {
first * 4,
bytemuck::cast_slice(&ids),
);
id
}
/// The block table of the page keyed `page`, for tests that read it back.
#[cfg(test)]
pub(super) fn blocks(&self, page: u32) -> &wgpu::Buffer {
&self.pages[page as usize]
.as_ref()
.expect("a page table")
.blocks
}
/// The most vertices a page of the translucent arena holds: its block tables' size.
/// Tables made before keep theirs, so this goes with `clear`.
pub fn set_translucent_page_vertices(&mut self, vertices: u64) {
self.translucent_blocks_per_page = vertices.div_ceil(GRAIN);
}
pub fn remove(&mut self, id: u32) {
self.free_ids.push(id);
@@ -525,7 +577,7 @@ impl Flat {
self.free_ids.clear();
self.next_id = 0;
}
/// Forgets the tables of pages the arena released.
/// Forgets the tables of pages the arenas released (`live`, by page key).
pub fn trim(&mut self, live: impl Fn(u32) -> bool) {
for (i, page) in self.pages.iter_mut().enumerate() {
if page.is_some() && !live(i as u32) {
@@ -556,6 +608,7 @@ impl Flat {
shadow: [usize; 2],
passes: [usize; 2],
phase2: Option<&wgpu::Buffer>,
translucent_arena: bool,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
timing: Option<&mut super::pack_timing::PassTiming>,
@@ -609,6 +662,11 @@ impl Flat {
shadow,
passes,
self.range_align,
// Translucent draws, and only they, have sorted indices.
|page, sorted| match sorted {
Some(_) if translucent_arena => page + TRANSLUCENT_KEYS,
_ => page,
},
&mut self.calls,
&mut self.range_data,
&mut self.segments,
@@ -801,7 +859,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
self.draw_with(pass, calls, vertices, |_, _| {});
}
@@ -811,7 +869,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -828,7 +886,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
instances: u32,
) {
@@ -842,7 +900,7 @@ impl Flat {
pub fn draw_phase2<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
@@ -857,13 +915,13 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
page: u32,
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
vertices: &impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
let table = self.pages[page as usize]
.as_ref()
.and_then(|t| t.group.as_ref())
.expect("prepared page");
pass.set_vertex_buffer(0, vertices(page).slice(..));
pass.set_vertex_buffer(0, vertices(page));
pass.set_bind_group(1, table, &[]);
}
}
@@ -912,6 +970,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -960,6 +1019,7 @@ mod tests {
[draws.len(); 2],
[3, 1],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -995,6 +1055,7 @@ mod tests {
[camera, camera + 2],
[camera, 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -1023,6 +1084,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
@@ -1140,6 +1202,7 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
align,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,203 @@
// GPU culling (gfx/gpu_culling.rs). `apply` writes the records that changed.
// `cull`: one invocation per section id keeps the sections the CPU's walk
// chose, writes each one's origin and counts it in its page, per block of 64
// ids. `scan`: one workgroup per page turns the blocks' counts into where
// each block's draws start, and the page's count. `scatter`: each block
// writes its sections' draws there, in id order, so a page's draws come in
// the same order every frame (atomics alone would order them as the GPU
// schedules, and where faces of two sections meet at the same depth the later
// draw keeps the pixel). Nothing here computes with floats: the origins are
// the CPU's bits, copied.
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
// regions cover, and where the regions and the chosen ids' bits start in
// `tables`.
ids: u32,
updates: u32,
pages: u32,
regions: u32,
mask: u32,
pad0: u32,
pad1: u32,
pad2: u32,
// The tables' first column: x, 0, z, 0.
first: vec4<i32>,
// Their entries along x, y and z, and 0.
cells: vec4<u32>,
}
// gpu_culling.rs `Record`.
struct Section {
// Section x, y (from the level's bottom), z, and its arena page (-1: a
// free id).
key: vec4<i32>,
// The first vertex of its solid quads, its solid quads, then two words
// unused here.
a: vec4<u32>,
}
// gpu_culling.rs `Update`: the record for id `id.x`.
struct Update {
id: vec4<u32>,
section: Section,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read_write> sections: array<Section>;
// gpu_culling.rs `GpuCulling::prepare`: the origins' f32 bits along x, y and
// z, each page's region of `draws` (its first draw and its size), then a bit
// per id the CPU's walk chose.
@group(0) @binding(2) var<storage, read> tables: array<u32>;
@group(0) @binding(3) var<storage, read> updates: array<Update>;
// Sections kept, draws, each page's draws, then per block of ids and page its
// draws (`cull`), turned into where they start (`scan`).
@group(0) @binding(4) var<storage, read_write> counts: array<atomic<u32>>;
// DrawIndexedIndirectArgs, five words each.
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
// Per id its page + 1 when it is drawn this frame, else 0.
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
// gfx.rs `section_instance`'s w for a whole section: 1 / mesher::UV_STEPS.
const STEP_BITS: u32 = 0x38000000u;
// Workgroups along x when the ids need more than one row of them.
const ROW: u32 = 65535u;
// Ids per block.
const BLOCK: u32 = 64u;
// Counts before the pages'.
const STATS: u32 = 2u;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
return STATS + params.pages + block * params.pages + page;
}
// `page`'s region of `draws`: its first draw and its size.
fn region(page: u32) -> vec2<u32> {
let at = params.regions + 2u * page;
return vec2<u32>(tables[at], tables[at + 1u]);
}
// The CPU's walk chose it (gpu_culling.rs `GpuCulling::choose`).
fn chosen(id: u32) -> bool {
return ((tables[params.mask + id / 32u] >> (id % 32u)) & 1u) != 0u;
}
// One invocation per changed record; the CPU lists each id once.
@compute @workgroup_size(64)
fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
if gid.x < params.updates {
let u = updates[gid.x];
sections[u.id.x] = u.section;
}
}
@compute @workgroup_size(64)
fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
let id = gid.y * (ROW * BLOCK) + gid.x;
if id >= params.ids {
return;
}
runs[id] = 0u;
let s = sections[id];
if s.key.w < 0 || s.a.y == 0u || !chosen(id) {
return;
}
let page = u32(s.key.w);
// The tables hold every column within the distance; a key before their
// first wraps past their end.
let c = vec3<u32>(s.key.xyz - params.first.xyz);
if page >= params.pages || any(c >= params.cells.xyz) {
return;
}
let nx = params.cells.x;
let ny = params.cells.y;
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
runs[id] = page + 1u;
atomicAdd(&counts[block_count(id / BLOCK, page)], 1u);
}
var<workgroup> partial: array<u32, 256>;
// One workgroup per page: each lane sums a stretch of blocks, the lanes'
// sums are scanned, and each block's sum becomes where its draws start.
@compute @workgroup_size(256)
fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let page = group.x;
let blocks = (params.ids + BLOCK - 1u) / BLOCK;
let stretch = (blocks + 255u) / 256u;
let first = min(lane * stretch, blocks);
let last = min(first + stretch, blocks);
var sum = 0u;
for (var b = first; b < last; b++) {
sum += atomicLoad(&counts[block_count(b, page)]);
}
partial[lane] = sum;
workgroupBarrier();
for (var step = 1u; step < 256u; step <<= 1u) {
var before = 0u;
if lane >= step {
before = partial[lane - step];
}
workgroupBarrier();
partial[lane] += before;
workgroupBarrier();
}
var start = 0u;
if lane > 0u {
start = partial[lane - 1u];
}
for (var b = first; b < last; b++) {
let count = atomicLoad(&counts[block_count(b, page)]);
atomicStore(&counts[block_count(b, page)], start);
start += count;
}
if lane == 255u {
// A region has room for every record of its page; were one short,
// its draws past it go.
let total = min(partial[255], region(page).y);
atomicStore(&counts[STATS + page], total);
atomicAdd(&counts[0], partial[255]);
atomicAdd(&counts[1], total);
}
}
var<workgroup> held: array<u32, 64>;
// One workgroup per block of ids: each section's draw goes after those of the
// block's earlier sections in its page.
@compute @workgroup_size(64)
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let block = group.y * ROW + group.x;
let id = block * BLOCK + lane;
var mine = 0u;
if id < params.ids {
mine = runs[id];
}
held[lane] = mine;
workgroupBarrier();
if mine == 0u {
return;
}
let page = mine - 1u;
var slot = atomicLoad(&counts[block_count(block, page)]);
for (var other = 0u; other < lane; other++) {
if held[other] == mine {
slot += 1u;
}
}
let r = region(page);
if slot >= r.y {
return;
}
// All of its solid quads, from its first vertex, with its origin.
let s = sections[id];
let d = (r.x + slot) * 5u;
draws[d] = s.a.y * 6u;
draws[d + 1u] = 1u;
draws[d + 2u] = 0u;
draws[d + 3u] = s.a.x;
draws[d + 4u] = id;
}
@@ -645,6 +645,7 @@ mod tests {
include_str!("../hdr.wgsl"),
include_str!("../terrain.wgsl"),
include_str!("../hdr_stats.wgsl"),
include_str!("stars.wgsl"),
&entity,
] {
let module = naga::front::wgsl::parse_str(source).unwrap();
+24 -4
View File
@@ -7,13 +7,19 @@ use crate::{
mesher::SectionMesh,
};
use glam::{DVec3, Vec3};
use std::time::{Duration, Instant};
use std::{
collections::VecDeque,
time::{Duration, Instant},
};
/// Finished tile meshes not uploaded yet, the tiles they replace (removed
/// once every mesh is in) and the columns they leave out.
#[derive(Default)]
pub(super) struct Pending {
meshes: FxHashMap<TileKey, SectionMesh>,
/// The order they go up in: as handed over (`Lod::update`: finer tiles
/// first, so no tile clipped around a finer one is drawn before it).
order: VecDeque<TileKey>,
removed: FxHashSet<TileKey>,
cutout: Option<FxHashSet<(i32, i32)>>,
}
@@ -30,7 +36,9 @@ impl Pending {
) {
for (key, mesh) in meshes {
self.removed.remove(&key);
self.meshes.insert(key, mesh);
if self.meshes.insert(key, mesh).is_none() {
self.order.push_back(key);
}
}
for key in removed {
self.meshes.remove(&key);
@@ -330,7 +338,10 @@ impl Renderer {
.copied()
.collect();
while let Some(pending) = &mut self.lod_pending {
let Some(&key) = pending.meshes.keys().next() else {
// Keys meshed again keep their place; removed ones are skipped.
let next = std::iter::from_fn(|| pending.order.pop_front())
.find(|key| pending.meshes.contains_key(key));
let Some(key) = next else {
let pending = self.lod_pending.take().expect("pending tiles");
// A tile fades in over the ones it replaces. One over nothing
// shows whole: its hidden chunks would open holes to the sky
@@ -498,7 +509,16 @@ mod tests {
let mut removed: Vec<_> = pending.removed.iter().map(|k| k.x).collect();
removed.sort_unstable();
assert_eq!((meshes, removed), (vec![1, 3], vec![2, 4]));
assert!(pending.cutout.is_some_and(|c| c.contains(&(0, 0))));
assert!(pending.cutout.as_ref().is_some_and(|c| c.contains(&(0, 0))));
// They go up in the order handed over, each once.
pending.add(vec![(key(1), SectionMesh::default())], vec![], None);
let order: Vec<_> = pending
.order
.iter()
.filter(|k| pending.meshes.contains_key(k))
.map(|k| k.x)
.collect();
assert_eq!(order, [1, 3]);
}
#[test]
@@ -23,6 +23,8 @@ pub struct NativePack {
pub shadow: Option<NativeShadow>,
/// `[[particle_lights]]`.
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
/// `[[lights]]`, for the colour of held and dropped items' light.
pub light_colors: Vec<horizon_shader_api::manifest::LightColor>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -136,6 +138,7 @@ impl NativePack {
}
let pack = Self {
particle_lights: report.manifest.particle_lights.clone(),
light_colors: report.manifest.lights.clone(),
frame,
sky,
material,
@@ -11,8 +11,10 @@ use std::sync::{
atomic::{AtomicBool, Ordering},
};
mod generated;
pub use generated::{RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
@@ -40,8 +42,8 @@ pub fn world_drawn_hooked() -> bool {
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// particles, weather and the world post pass, before the first-person hand ([`HandDrawn`]),
/// the HUD and the screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
#[non_exhaustive]
pub struct WorldDrawn<'a> {
@@ -70,10 +72,67 @@ pub struct WorldDrawn<'a> {
/// ([`RenderNeeds::entity_spans`]) or a shader pack draws them.
pub entities: &'a [Vertex],
pub spans: &'a [EntitySpan],
/// Which entity each run of `entities` is, recorded every frame (`Frame::entity_motion`):
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
/// frame to frame (API @API:world_drawn_motion@).
pub motion: &'a [(u32, u64)],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
pub quad_indices: &'a wgpu::Buffer,
/// The block textures the terrain samples, for drawing blocks with the player's own
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
pub block_sprites: BlockSpriteArrays<'a>,
}
/// The block sprite arrays ([`WorldDrawn::block_sprites`]): each texture a whole square layer,
/// its UVs 0 to 1 across it, as the world uses them (gamma-encoded, animated layers at the frame
/// shown).
#[non_exhaustive]
pub struct BlockSpriteArrays<'a> {
/// The arrays as `D2Array` views, terrain.wgsl's eight (`sprites`, `sprites_1`...): a 1-layer
/// stand-in past the ones in use, each with the mip levels the Mipmap Levels option leaves.
pub views: &'a [wgpu::TextureView],
/// Layers per array on this device (2048 on most; fewer where the GPU holds fewer, and
/// OpenGL pads each array past it): a sprite's `array` and `layer` are cut by it.
pub split: u32,
/// A layer's width and height in texels.
pub size: u32,
/// Nearest, repeating, the nearest mip level: texels drawn sharp.
pub nearest: &'a wgpu::Sampler,
/// The terrain's own (linear, clamped, anisotropic under that Texture Filtering).
pub filtered: &'a wgpu::Sampler,
}
/// What a mod can draw with once the first-person hand is drawn ([`native_hand_drawn`]): after
/// the world and the hand, before entity outlines, post effects, screen effects and the HUD.
/// `color` holds the frame's picture with the hand in it (copy it out before reading it).
#[non_exhaustive]
pub struct HandDrawn<'a> {
pub device: &'a wgpu::Device,
pub queue: &'a wgpu::Queue,
pub encoder: &'a mut wgpu::CommandEncoder,
/// The frame's target and a view of it in `color_format`.
pub color: &'a wgpu::Texture,
pub color_view: &'a wgpu::TextureView,
pub color_format: wgpu::TextureFormat,
/// The size of `color`.
pub size: [u32; 2],
/// The hand and held item just drawn, camera-relative quads of 4 vertices: empty when no
/// hand drew (third person, F1, a sleeping or spectating player) or a shader pack drew it.
pub hand: &'a [Vertex],
/// Camera-relative world to clip space for the hand: its own field of view
/// (`hand_fov_degrees`) and the view bob, without TAA's jitter (the hand has none). Its
/// depth is the hand's own: the hand's pass clears depth and keeps none.
pub view_proj: Mat4,
/// The camera's position in the world.
pub camera: DVec3,
/// The shared atlas the hand's UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices).
pub quad_indices: &'a wgpu::Buffer,
}
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
@@ -94,6 +153,13 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
generated::dispatch_world_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), world);
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API @API:hand_drawn@.
#[inline(never)]
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
}
/// Asked as a frame is built and drawn: set what your mod needs.
#[inline(never)]
pub fn native_render_needs(needs: &mut RenderNeeds) {
@@ -124,6 +190,189 @@ mod tests {
}
}
/// A mod samples `BlockSpriteArrays` as the renderer hands them over: a layer of the second
/// array through its view, texels sharp and repeating with the nearest sampler.
#[test]
fn a_pass_samples_a_sprite_layer_sharp() {
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 {
return;
};
let gl = device.adapter_info().backend == wgpu::Backend::Gl;
// two arrays of two 2 by 2 layers; array 1's layer 1 red, green / blue, white
let layers = super::super::array_layers(2, gl);
let quad = [
[255, 0, 0, 255],
[0, 255, 0, 255],
[0, 0, 255, 255],
[255; 4],
];
let textures: Vec<wgpu::Texture> = (0..2)
.map(|array| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let mut texels = vec![0u8; 16 * layers as usize];
if array == 1 {
texels[16..32].copy_from_slice(quad.as_flattened());
}
queue.write_texture(
texture.as_image_copy(),
&texels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(8),
rows_per_image: Some(2),
},
texture.size(),
);
texture
})
.collect();
let unused = textures[0].create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let views = super::super::sprite_views(&textures, &unused, Some(1));
let nearest = device.create_sampler(&super::super::SPRITE_NEAREST);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@group(0) @binding(0) var sprites: texture_2d_array<f32>;
@group(0) @binding(1) var sprites_1: texture_2d_array<f32>;
@group(0) @binding(2) var nearest: sampler;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4(f32(i & 1u) * 4.0 - 1.0, f32(i >> 1u) * 4.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) at: vec4<f32>) -> @location(0) vec4<f32> {
// inside each texel, and past the edge (repeating); the last reads array 0
let uv = array(vec2(1.3, 0.2), vec2(0.7, 0.45), vec2(-0.8, 0.9), vec2(0.6, 0.6));
let x = u32(at.x);
if x == 4u {
return textureSampleLevel(sprites, nearest, vec2(0.3), 1, 0.0);
}
return textureSampleLevel(sprites_1, nearest, uv[x], 1, 0.0);
}"
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
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: wgpu::BindingResource::TextureView(&views[0]),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&views[1]),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&nearest),
},
],
});
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 5,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let view = target.create_view(&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);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &out,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: None,
},
},
target.size(),
);
queue.submit([encoder.finish()]);
out.slice(..).map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let texels = out.slice(..).get_mapped_range().unwrap()[..20].to_vec();
let mut expected = quad.as_flattened().to_vec();
expected.extend([0, 0, 0, 0]);
assert_eq!(texels, expected);
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
@@ -1,36 +0,0 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
@@ -13,6 +13,7 @@
//! frame has (vanilla's partial update), so what it opens shows at once; what
//! it closes goes at the next full search.
use super::SectionKey;
use crate::fast_hash::FxHashSet;
use crate::mesher::ALL_VISIBLE;
use glam::DVec3;
use std::collections::{HashMap, VecDeque};
@@ -589,6 +590,19 @@ impl Occlusion {
}
}
}
/// `remove_column` for each of `gone`. When they are every column it holds (a
/// teleport drops the whole view), the old graph says nothing about what arrives
/// next: it starts over as `clear` does instead of seeing through each removed
/// section one edit at a time (some 90 000 at render distance 32).
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) {
if self.columns.map.keys().all(|column| gone.contains(column)) {
self.clear();
return;
}
for &(x, z) in gone {
self.remove_column(x, z);
}
}
pub fn clear(&mut self) {
self.columns.map.clear();
self.edits.clear();
@@ -1313,6 +1327,46 @@ mod tests {
println!("search_cost: best {best:.2} ms over 12, {reached} reached");
}
/// Removed columns see through every face: the graph is the one of a world that
/// never had them, whether some columns go or all of them (a teleport).
#[test]
fn removed_columns_leave_the_graph_of_a_world_without_them() {
fn without(o: &Occlusion, gone: &FxHashSet<(i32, i32)>) -> Occlusion {
let mut fresh = Occlusion::new(0, 6);
for (&(x, z), column) in &o.columns.map {
if !gone.contains(&(x, z)) {
for (y, &vis) in column.iter().enumerate() {
fresh.set((x, y as i32, z), vis);
}
}
}
fresh
}
let camera = at((0, 2, 0));
let mut o = Occlusion::new(0, 6);
random_world(&mut o, 0x51ed_2701, 900, 6);
o.settle(camera, 6);
let some: FxHashSet<(i32, i32)> = (-6..=6)
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
.collect();
let mut expected = without(&o, &some);
o.remove_columns(&some);
o.settle(camera, 6);
expected.settle(camera, 6);
let graph = |o: &Occlusion| o.graph.as_ref().expect("searched").directions.clone();
assert_eq!(graph(&o), graph(&expected));
let all: FxHashSet<(i32, i32)> = o.columns.map.keys().copied().collect();
o.remove_columns(&all);
assert!(o.contains((40, 3, 40)), "no graph from the old view culls");
random_world(&mut o, 0x2545_f491, 900, 6);
let mut arrived = Occlusion::new(0, 6);
random_world(&mut arrived, 0x2545_f491, 900, 6);
o.settle(camera, 6);
arrived.settle(camera, 6);
assert_eq!(graph(&o), graph(&arrived));
}
/// The worker's graph is the one the render thread would compute.
#[test]
fn the_worker_matches_a_search_on_the_render_thread() {
@@ -136,6 +136,10 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
uint hz_w3 = hz_vertices[hz_q + 21u];\n \
vec2 hz_t3 = vec2(hz_w3 & 65535u, hz_w3 >> 16u);\n \
hz_mid = (clamp(hz_t0 * hz_uv_scale, 0.0, 1.0) + clamp((hz_t0 + hz_d1) * hz_uv_scale, 0.0, 1.0)\n \
+ clamp((hz_t0 + hz_d2) * hz_uv_scale, 0.0, 1.0) + clamp(hz_t3 * hz_uv_scale, 0.0, 1.0)) * 0.25;\n \
vec3 hz_e1 = hz_p1 - hz_p0;\n \
vec3 hz_e2 = hz_p2 - hz_p0;\n \
vec3 hz_n = cross(hz_e1, hz_e2);\n \
@@ -164,8 +168,9 @@ impl PackAtlas {
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
);
// `mc_Entity` of each sprite layer: id, then 1 for fluids or -1 (the game's table),
// and the arena page's vertices (`TerrainVertex`, six words each).
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
// vertices (`TerrainVertex`, six words each).
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
@@ -194,7 +199,9 @@ impl PackAtlas {
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
hz_scale = vec2({:?}, {:?});\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
hz_mid = vec2(0.5);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_uv_scale, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = vec4(hz_v_color.rgb, {color_a});\n \
@@ -206,15 +213,19 @@ impl PackAtlas {
if flat {
body.push_str(FACE);
}
declarations.push_str("vec2 hz_cell; vec2 hz_scale; vec4 hz_tangent;\n");
declarations.push_str(
"vec2 hz_cell; vec2 hz_scale; vec2 hz_mid; float hz_uv_scale; vec4 hz_tangent;\n",
);
let entity = if flat {
"vec4(float(hz_block_ids[2u * hz_layer]), float(hz_block_ids[2u * hz_layer + 1u]), 0.0, 0.0)"
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
} else {
"vec4(-1.0, -1.0, 0.0, 0.0)"
};
// The block a face belongs to: half a block behind its centre (the quad's diagonal).
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
let mid_block = if flat {
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, 0.0)"
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, \
float(hz_block_ids[3u * hz_layer + 2u]))"
} else {
"vec4(0.0)"
};
@@ -223,7 +234,7 @@ impl PackAtlas {
("at_midBlock", mid_block),
(
"mc_midTexCoord",
"vec4((hz_cell + 0.5) * hz_scale, 0.0, 1.0)",
"vec4((hz_cell + hz_mid) * hz_scale, 0.0, 1.0)",
),
("at_tangent", "hz_tangent"),
("vaPosition", "hz_Vertex.xyz"),
@@ -245,6 +256,10 @@ impl PackAtlas {
}
}
#[cfg(test)]
#[path = "pack_atlas/midpoint_test.rs"]
mod midpoint_test;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,353 @@
//! Original GPU probes of the production terrain inputs, without a resource or shader pack.
use super::PackAtlas;
use crate::mesher::{TerrainVertex, UV_STEPS};
use wgpu::util::DeviceExt;
#[test]
fn mid_tex_coord_follows_each_quads_mapped_corners() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
eprintln!("midpoint probe adapter: {:?}", adapter.get_info());
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 5,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &[texture], 5, 5, 16).unwrap();
queue.submit([encoder.finish()]);
// Each row uses a different atlas cell. Expected atlas coordinates are independent
// numbers, including the existing clamp of a model UV beyond the sprite's edge.
let cases = [
(
"whole",
[[0., 0.], [1., 0.], [1., 1.], [0., 1.]],
[1. / 6., 0.25],
[1. / 6., 0.25],
[[0., 0.], [1. / 3., 0.], [1. / 3., 0.5], [0., 0.5]],
),
(
"half-height",
[[0., 0.], [1., 0.], [1., 0.5], [0., 0.5]],
[0.5, 0.125],
[0.5, 0.25],
[
[1. / 3., 0.],
[2. / 3., 0.],
[2. / 3., 0.25],
[1. / 3., 0.25],
],
),
(
"rotated partial",
[[0.25, 0.75], [0.25, 0.25], [0.5, 0.25], [0.5, 0.75]],
[19. / 24., 0.25],
[5. / 6., 0.25],
[
[0.75, 0.375],
[0.75, 0.125],
[5. / 6., 0.125],
[5. / 6., 0.375],
],
),
(
"clamped",
[[0., 0.], [1.5, 0.], [1.5, 0.5], [0., 0.5]],
[1. / 6., 0.625],
[1. / 6., 0.75],
[[0., 0.5], [1. / 3., 0.5], [1. / 3., 0.75], [0., 0.75]],
),
(
"fourth corner",
[[0., 0.], [1., 0.], [1., 0.5], [0., 1.]],
[0.5, 0.6875],
[0.5, 0.75],
[
[1. / 3., 0.5],
[2. / 3., 0.5],
[2. / 3., 0.75],
[1. / 3., 1.],
],
),
];
let positions = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
let vertices: Vec<_> = cases
.iter()
.enumerate()
.flat_map(|(layer, (_, uv, ..))| {
(0..4).map(move |k| {
TerrainVertex::new(positions[k], uv[k], layer as u16 | 0x8000, u32::MAX, 0)
})
})
.collect();
let mut failures = Vec::new();
// Positive origins encode fade bits beside the power-of-two scale. Negative origins
// use the older fractional scale encoding. Both must match the mapped vertex UVs.
for origin in [
f32::from_bits((1. / UV_STEPS).to_bits() | 0x12345),
-3. - 1. / UV_STEPS,
] {
for flat in [true, false] {
for shadow in [false, true] {
let pixels = sample(&device, &queue, &atlas, &vertices, origin, [flat, shadow]);
for (quad, (name, _, midpoint, fallback, mapped)) in cases.iter().enumerate() {
let centre = if flat { midpoint } else { fallback };
for (corner, uv) in mapped.iter().enumerate() {
let actual = pixels[4 * quad + corner];
let expected = [centre[0], centre[1], uv[0], uv[1]];
if actual
.iter()
.zip(expected)
.any(|(a, b)| !a.is_finite() || (a - b).abs() > 1e-6)
{
failures.push(format!("{name} corner {corner}, flat={flat} shadow={shadow} origin={origin}: {actual:?} != {expected:?}"));
}
}
}
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
vertices: &[TerrainVertex],
scale: f32,
[flat, shadow]: [bool; 2],
) -> Vec<[f32; 4]> {
let pp = |code| horizon_shaders::Preprocessed {
code,
..Default::default()
};
let vertex = pp(format!(
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
probe = vec4(mc_midTexCoord, gl_MultiTexCoord0.xy);\n\
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
2. / vertices.len() as f32,
));
// Integer attachments retain the float bits without requiring float-render-target
// capabilities on the adapter that the test runner picks.
let fragment = pp(
"varying vec4 probe;\nlayout(location = 0) out uvec4 midpoint_bits;\n\
void main() { midpoint_bits = floatBitsToUint(probe); }"
.into(),
);
let inputs = atlas.terrain_inputs(flat);
let program = if shadow {
horizon_shaders::translate_shadow(&vertex, &fragment, &inputs)
} else {
horizon_shaders::translate_gbuffers(
&vertex,
&fragment,
horizon_shaders::Target::Buffers,
&inputs,
)
}
.unwrap();
let storage = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let entries = [
vec![wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
vec![storage(0), storage(1)],
vec![],
vec![storage(0), storage(1)],
];
let layouts: Vec<_> = entries
.iter()
.map(|entries| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries,
})
})
.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);
let fs = module(program.fragment);
let vertex_layouts = [
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: &vertex_layouts[..if flat { 1 } else { 2 }],
},
primitive: wgpu::PrimitiveState {
topology: 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 buffer = |contents: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents,
usage,
})
};
let uniform = buffer(
&vec![0; program.interface.uniform_size.max(16) as usize],
wgpu::BufferUsages::UNIFORM,
);
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
let origin = buffer(
bytemuck::bytes_of(&[0., 0., 0., scale]),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0i32; 15]), wgpu::BufferUsages::STORAGE);
let data = buffer(
bytemuck::cast_slice(vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let bindings = [
vec![(0, &uniform)],
vec![(0, &slots), (1, &origin)],
vec![],
vec![(0, &ids), (1, &data)],
];
let groups: Vec<_> = bindings
.iter()
.zip(&layouts)
.map(|(buffers, layout)| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout,
entries: &buffers
.iter()
.map(|&(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect::<Vec<_>>(),
})
})
.collect();
let output = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: vertices.len() as u32,
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: vertices.len() as u64 * 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let view = output.create_view(&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 (k, group) in groups.iter().enumerate() {
pass.set_bind_group(k as u32, group, &[]);
}
pass.set_vertex_buffer(0, data.slice(..));
if !flat {
pass.set_vertex_buffer(1, origin.slice(..));
}
pass.draw(0..vertices.len() as u32, 0..1);
}
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout::default(),
},
output.size(),
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
bytemuck::cast_slice::<u8, [f32; 4]>(&mapped).to_vec()
}
@@ -191,21 +191,28 @@ impl CenterDepth {
slot.state.store(COPIED, Ordering::Release);
}
/// After the frame's submission: start reading back what it copied.
pub fn submitted(&mut self) {
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != COPIED {
continue;
/// The readback of what the frame copied, to start by calling it right after the frame's
/// submission: on the submit thread when the frame goes there, as culling's readback.
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
let copied: Vec<_> = self
.slots
.iter()
.filter(|s| s.state.load(Ordering::Acquire) == COPIED)
.map(|s| {
s.state.store(PENDING, Ordering::Release);
(s.buffer.clone(), s.state.clone())
})
.collect();
(!copied.is_empty()).then_some(move || {
for (buffer, state) in copied {
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
let next = if result.is_ok() { MAPPED } else { FREE };
state.store(next, Ordering::Release);
});
}
slot.state.store(PENDING, Ordering::Release);
let state = slot.state.clone();
slot.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
let next = if result.is_ok() { MAPPED } else { FREE };
state.store(next, Ordering::Release);
});
}
})
}
/// The newest depth read back so far, `None` before the first arrives.
@@ -242,6 +249,70 @@ mod tests {
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn clear(encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView, depth: f32) {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(depth),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
/// Frames that go to the submit thread start their readback there: the centre depth
/// follows the frames instead of keeping the first ones' depth.
#[test]
fn the_centre_depth_follows_frames_submitted_on_the_submit_thread() {
let Some((device, queue)) = test_device() else {
return;
};
let queue = super::super::submit::Queue::new(queue, true);
if !queue.threaded() {
return;
}
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = depth.create_view(&Default::default());
let mut center = CenterDepth::new(&device);
let mut latest = None;
for frame in 1..=4 * SLOTS {
let value = frame as f32 / 16.0;
let mut encoder = device.create_command_encoder(&Default::default());
clear(&mut encoder, &view, value);
center.copy(&device, &mut encoder, &depth);
let after = center
.submitted()
.map(|f| Box::new(f) as super::super::submit::AfterSubmit);
let job = super::super::submit::Job {
encoder,
surface: None,
after,
};
assert!(queue.submit_later(job).is_ok());
queue.wait();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
latest = center.collect(&device);
assert_eq!(latest, Some(value), "frame {frame}");
}
assert_eq!(latest, Some(0.75));
}
/// A depth texture cleared to a known depth comes back as that depth, a frame or more
/// later, without the frame waiting for it.
#[test]
@@ -280,7 +351,7 @@ mod tests {
});
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
center.submitted().expect("a copy to read back")();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
// Its slot is free again: every slot can be used once more.
@@ -288,7 +359,7 @@ mod tests {
let mut encoder = device.create_command_encoder(&Default::default());
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
center.submitted().expect("a free slot")();
}
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
@@ -104,6 +104,32 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}
}
/// What a shadow program with a geometry stage reads of entities and block entities: the
/// inputs of [`inputs`], each vertex read from set 1 (`hz_entity_vertices`), as `Vertex` lays
/// it out. The draw's corners walk its quads two triangles each, as `quad_indices` does: corner
/// `c` of a draw from vertex `first` (a whole quad) is drawn as vertex `first / 4 * 6 + c`.
pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
let words = std::mem::size_of::<Vertex>() / 4;
let pull = format!(
" uint hz_pw = hz_vertex_index * {words}u;\n \
hz_v_pos = uintBitsToFloat(uvec3(hz_entity_vertices[hz_pw], hz_entity_vertices[hz_pw + 1u], hz_entity_vertices[hz_pw + 2u]));\n \
hz_v_uv = uintBitsToFloat(uvec2(hz_entity_vertices[hz_pw + 3u], hz_entity_vertices[hz_pw + 4u]));\n \
hz_v_overlay = hz_entity_vertices[hz_pw + 5u];\n \
hz_v_color = unpackUnorm4x8(hz_entity_vertices[hz_pw + 6u]);\n \
uint hz_pl = hz_entity_vertices[hz_pw + 7u];\n \
hz_v_light = uvec4(hz_pl & 255u, (hz_pl >> 8u) & 255u, (hz_pl >> 16u) & 255u, hz_pl >> 24u);\n \
hz_v_normal = unpackSnorm4x8(hz_entity_vertices[hz_pw + 8u]);\n"
);
horizon_shaders::GeometryInputs {
inputs: inputs(),
pull,
// Quad `c / 6`, corner 0, 1, 2, 0, 2, 3 of it.
index:
"((@) / 6u * 4u + ((@) % 6u < 3u ? (@) % 6u : ((@) % 6u == 3u ? 0u : (@) % 6u - 2u)))"
.to_owned(),
}
}
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
/// full light, the face's normal. Set 1 holds the clouds' uniform (`clouds::Uniform`).
@@ -297,6 +323,10 @@ impl PackEntities {
(buffer, group)
}
pub fn skin_target(&self) -> &wgpu::Buffer {
&self.vertices
}
pub fn vertices(&self) -> wgpu::BufferSlice<'_> {
self.vertices.slice(..)
}
@@ -322,11 +352,32 @@ impl PackEntities {
crack: ([f32; 4], [u32; 2]),
shadow: Casters,
rest: &mut Vec<Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
) {
self.draws.clear();
self.scratch.clear();
self.uniforms.clear();
rest.clear();
let posed = skin.map_or(0, |s| s.vertices);
if let Some(skin) = skin {
for (span, range, overlay) in skin_runs(skin) {
let program = program_of(&span).expect("tagged skin stage");
let pack = drawn[program] && !span.hidden;
let casts = shadow.cast(&span, program);
if !pack && !casts {
continue;
}
self.push_draw(
span,
range.start,
range.len() as u32,
program,
pack,
casts,
overlay,
);
}
}
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
rest.extend_from_slice(&entities[..first.min(entities.len())]);
for (span, range) in runs(entities.len(), spans) {
@@ -347,35 +398,24 @@ impl PackEntities {
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
// The overlay: the layer's low 16 bits (HDR output's tier rides in the high ones).
for (i, c) in entity_color(run[0].layer & 0xffff).iter().enumerate() {
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
self.uniforms
.resize(self.uniforms.len() + (self.stride - SLOT) as usize, 0);
self.draws.push(Draw {
base: start,
quads: run.len() as u32 / 4,
self.push_draw(
span,
posed + start,
run.len() as u32,
program,
pack,
shadow: casts,
slot: self.draws.len() as u32,
});
casts,
run[0].layer & 0xffff,
);
}
if self.draws.is_empty() {
if self.draws.is_empty() && posed == 0 {
return;
}
let vertex_size = std::mem::size_of::<Vertex>() as u64;
let (need_vertices, need_slots) = (self.scratch.len() as u64, self.draws.len() as u64);
let (need_vertices, need_slots) = (
u64::from(posed) + self.scratch.len() as u64,
self.draws.len() as u64,
);
let have_vertices = self.vertices.size() / vertex_size;
let have_slots = self.slots.size() / self.stride;
if need_vertices > have_vertices {
@@ -391,8 +431,52 @@ impl PackEntities {
self.quads = quads.next_power_of_two();
self.indices = quad_indices(device, self.quads);
}
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(&self.scratch));
queue.write_buffer(&self.slots, 0, &self.uniforms);
if !self.scratch.is_empty() {
queue.write_buffer(
&self.vertices,
u64::from(posed) * vertex_size,
bytemuck::cast_slice(&self.scratch),
);
}
if !self.uniforms.is_empty() {
queue.write_buffer(&self.slots, 0, &self.uniforms);
}
}
#[allow(clippy::too_many_arguments)]
fn push_draw(
&mut self,
span: EntitySpan,
start: u32,
vertices: u32,
program: usize,
pack: bool,
casts: bool,
overlay: u32,
) {
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
// The overlay: the layer's low 16 bits (HDR output's tier rides in the high ones).
for (i, c) in entity_color(overlay & 0xffff).iter().enumerate() {
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
self.uniforms
.resize(self.uniforms.len() + (self.stride - SLOT) as usize, 0);
self.draws.push(Draw {
base: start,
quads: vertices / 4,
program,
pack,
shadow: casts,
slot: self.draws.len() as u32,
});
}
}
@@ -598,3 +682,258 @@ mod crack_projection_tests {
assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0);
}
}
/// Intersects shader identities with overlay runs, without reading posed vertices back.
fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range<u32>, u32)> {
let mut out: Vec<(EntitySpan, Range<u32>, u32)> = Vec::new();
let mut span_index = 0;
for job in &skin.jobs {
let mut at = job.dst;
let end = at + job.count;
while at < end {
while span_index + 1 < skin.spans.len() && skin.spans[span_index + 1].start <= at {
span_index += 1;
}
let Some(&span) = skin.spans.get(span_index) else {
break;
};
let stop = end.min(skin.spans.get(span_index + 1).map_or(end, |s| s.start));
if stop <= at {
break;
}
if let Some((old, range, overlay)) = out.last_mut()
&& (EntitySpan { start: 0, ..*old }) == (EntitySpan { start: 0, ..span })
&& *overlay == job.overlay
&& range.end == at
{
range.end = stop;
} else {
out.push((span, at..stop, job.overlay));
}
at = stop;
}
}
out
}
#[cfg(test)]
mod gpu_span_tests {
use super::*;
use crate::gfx::entity_skin::{SkinFrame, SkinJob};
#[test]
fn posed_draws_preserve_subject_ids_overlays_and_hidden_shadow_casters() {
let mut skin = SkinFrame {
vertices: 24,
..Default::default()
};
skin.spans = vec![
EntitySpan {
start: 0,
stage: render_stage("ENTITIES"),
id: 40,
..Default::default()
},
EntitySpan {
start: 12,
stage: render_stage("ENTITIES"),
id: 41,
hidden: true,
..Default::default()
},
];
skin.jobs = vec![
SkinJob {
dst: 0,
count: 4,
overlay: 1,
..Default::default()
},
SkinJob {
dst: 4,
count: 4,
overlay: 1,
..Default::default()
},
SkinJob {
dst: 8,
count: 12,
overlay: 5,
..Default::default()
},
SkinJob {
dst: 20,
count: 4,
overlay: 5,
..Default::default()
},
];
let runs = skin_runs(&skin);
assert_eq!(
runs.iter()
.map(|(s, r, o)| (s.id, r.clone(), *o, s.hidden))
.collect::<Vec<_>>(),
[
(40, 0..8, 1, false),
(40, 8..12, 5, false),
(41, 12..24, 5, true)
]
);
assert_eq!(entity_color(runs[0].2)[3], 0.30196077);
assert!(
Casters {
entities: true,
..Default::default()
}
.cast(&runs[2].0, ENTITY)
);
skin.truncate((2, 0, 8));
assert_eq!(skin.vertices, 8);
assert_eq!(skin.spans.len(), 1);
}
#[test]
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
let entity = |start, id| EntitySpan {
start,
id,
stage: render_stage("ENTITIES"),
..Default::default()
};
let mut skin = SkinFrame {
vertices: 12,
spans: vec![
entity(0, 40),
EntitySpan {
hidden: true,
..entity(4, 41)
},
EntitySpan {
start: 8,
id: 99,
stage: render_stage("BLOCK_ENTITIES"),
emissive: true,
..Default::default()
},
],
jobs: [1 | (2 << 16), 5, 0]
.into_iter()
.enumerate()
.map(|(i, overlay)| SkinJob {
dst: i as u32 * 4,
count: 4,
overlay,
..Default::default()
})
.collect(),
..Default::default()
};
let cpu = vec![
Vertex {
layer: 0,
..bytemuck::Zeroable::zeroed()
};
8
];
let spans = [entity(4, 50)];
let mut rest = Vec::new();
let shadows = Casters {
entities: true,
blocks: true,
..Default::default()
};
geometry.prepare(
&device,
&queue,
&layout.layout,
&cpu,
&spans,
[true; PROGRAMS],
([0.; 4], [1; 2]),
shadows,
&mut rest,
Some(&skin),
);
assert_eq!(rest.len(), 4, "untagged CPU prefix stays in Horizon's draw");
assert_eq!(geometry.scratch.len(), 4, "only CPU geometry is uploaded");
assert_eq!(
geometry
.draws
.iter()
.map(|d| (d.base, d.quads, d.pack, d.shadow))
.collect::<Vec<_>>(),
[
(0, 1, true, true),
(4, 1, false, true),
(8, 1, true, true),
(12, 1, true, true)
]
);
let slot = |i: usize| &geometry.uniforms[i * geometry.stride as usize..][..SLOT as usize];
let word = |bytes: &[u8], at| i32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
assert_eq!((word(slot(0), 4), word(slot(0), 8)), (40, 0));
assert_eq!((word(slot(1), 4), word(slot(1), 8)), (41, 0));
assert_eq!((word(slot(2), 4), word(slot(2), 8)), (0, 99));
assert_eq!((word(slot(3), 4), word(slot(3), 8)), (50, 0));
let hurt: &[f32] = bytemuck::cast_slice(&slot(0)[16..32]);
assert_eq!(&hurt[..3], &[1.0, 0.0, 0.0]);
assert!((hurt[3] - 77.0 / 255.0).abs() < 1e-6);
let flash: &[f32] = bytemuck::cast_slice(&slot(1)[16..32]);
assert_eq!(&flash[..3], &[1.0, 1.0, 1.0]);
assert!((flash[3] - 192.0 / 255.0).abs() < 1e-6);
assert_eq!(word(slot(2), 0), render_stage("BLOCK_ENTITIES") as i32);
let mut special = cpu.clone();
special[4..].iter_mut().for_each(|v| v.layer = 2);
geometry.prepare(
&device,
&queue,
&layout.layout,
&special,
&spans,
[true; PROGRAMS],
([0.; 4], [1; 2]),
shadows,
&mut rest,
None,
);
assert!(geometry.draws.is_empty());
assert_eq!(
bytemuck::cast_slice::<_, u8>(&rest),
bytemuck::cast_slice::<_, u8>(&special)
);
// Culling can retain hidden jobs for a pack whose entity caster is absent.
// The compute output must still fit, even though no visible/shadow draw uses it.
skin.vertices = 5000;
skin.jobs.truncate(1);
skin.jobs[0].count = 5000;
skin.spans = vec![EntitySpan {
hidden: true,
..entity(0, 60)
}];
geometry.prepare(
&device,
&queue,
&layout.layout,
&[],
&[],
[false; PROGRAMS],
([0.; 4], [1; 2]),
Casters::default(),
&mut rest,
Some(&skin),
);
assert!(geometry.draws.is_empty());
assert!(geometry.skin_target().size() >= 5000 * 44);
}
}
@@ -269,20 +269,121 @@ pub(super) fn clear(
/// The most bytes of zeros kept to clear images without `CLEAR_TEXTURE`.
const ZEROS: u64 = 4 << 20;
/// What an image no `image.*` gives binds. Iris 1.11.7 runs the program, and its loads read
/// zero, also after it stored into the image (observed with a probe pack). Here it is one texel
/// of the declared format, cleared before every pass that binds it; loads and stores outside
/// that texel are out of bounds (with the same probe pack on Vulkan, the loads read zero).
pub(super) struct StandIn {
format: wgpu::TextureFormat,
dimension: wgpu::TextureViewDimension,
pub(super) view: wgpu::TextureView,
zeros: wgpu::Buffer,
}
impl StandIn {
/// Zero again, before a pass that binds it.
pub(super) fn clear(&self, encoder: &mut wgpu::CommandEncoder) {
encoder.copy_buffer_to_texture(
wgpu::TexelCopyBufferInfo {
buffer: &self.zeros,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: None,
rows_per_image: None,
},
},
self.view.texture().as_image_copy(),
self.view.texture().size(),
);
}
}
/// One stand-in for each format and dimension of `wanted`.
pub(super) fn stand_ins(
device: &wgpu::Device,
wanted: Vec<(wgpu::TextureFormat, wgpu::TextureViewDimension)>,
) -> Vec<StandIn> {
wanted
.into_iter()
.map(|(format, dimension)| {
let atomic = device.features().contains(wgpu::Features::TEXTURE_ATOMIC)
&& matches!(
format,
wgpu::TextureFormat::R32Sint | wgpu::TextureFormat::R32Uint
);
let view = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack image stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: if dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC
| if atomic {
wgpu::TextureUsages::STORAGE_ATOMIC
} else {
wgpu::TextureUsages::empty()
},
view_formats: &[],
})
.create_view(&Default::default());
// New buffers start zeroed; one texel is at most 16 bytes.
let zeros = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack image stand-in zeros"),
size: 16,
usage: wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
StandIn {
format,
dimension,
view,
zeros,
}
})
.collect()
}
/// The stand-in of this format and dimension.
pub(super) fn stand_in(
stand_ins: &[StandIn],
format: wgpu::TextureFormat,
dimension: wgpu::TextureViewDimension,
) -> Option<&StandIn> {
stand_ins
.iter()
.find(|s| s.format == format && s.dimension == dimension)
}
/// The pack's storage buffers, bound in set 4.
pub(super) struct Buffers {
pub(super) layout: wgpu::BindGroupLayout,
objects: BTreeMap<u32, BufferObject>,
/// The fewest bytes each one's shaders bind it as (`Post::buffer_minimums`).
minimums: BTreeMap<u32, u64>,
pub(super) group: Option<wgpu::BindGroup>,
buffers: Vec<wgpu::Buffer>,
made_for: [u32; 2],
}
impl Buffers {
/// The layout of `objects`, bound in `stages`.
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
/// `minimums` long.
pub(super) fn new(
device: &wgpu::Device,
objects: BTreeMap<u32, BufferObject>,
minimums: BTreeMap<u32, u64>,
stages: wgpu::ShaderStages,
) -> Self {
let entries: Vec<_> = objects
@@ -305,6 +406,7 @@ impl Buffers {
Self {
layout,
objects,
minimums,
group: None,
buffers: Vec::new(),
made_for: [0; 2],
@@ -330,7 +432,8 @@ impl Buffers {
.objects
.iter()
.map(|(binding, o)| {
let wanted = o.size(size).next_multiple_of(4);
let least = self.minimums.get(binding).copied().unwrap_or(0);
let wanted = buffer_size(*o, size, least);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
@@ -368,6 +471,13 @@ impl Buffers {
}
}
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
/// OpenGL binds the smaller buffer; a multiple of 4.
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
object.size(render).max(least).next_multiple_of(4)
}
/// A debug readback of one of the pack's images (`HORIZON_SHADER_PACK_DUMP=<name>`): every 60
/// frames, how many of its texels are not zero, logged.
pub(super) struct Dump {
@@ -458,3 +568,27 @@ impl Dump {
self.pending = Some((buffer, texel, dims, row));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A buffer the pack declares smaller than a shader's block grows to the block (40 bytes
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
#[test]
fn a_buffer_is_at_least_its_shaders_block() {
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
40992
);
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
let per_pixel = BufferObject::PerPixel {
bytes: 4,
scale: [0.5, 0.5],
};
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
}
}
@@ -0,0 +1,65 @@
//! The block outline drawn by a shader pack's `gbuffers_line` (or `gbuffers_basic`, as in Iris),
//! so packs that style it (Complementary Reimagined, Super Duper Vanilla) draw it their way, and
//! packs that render the world into part of their buffers (SEUS PTGI HRR) move it with the rest.
//! The edges are the outline buffer Horizon draws itself, one instance per edge, widened to a
//! strip as `outline_clip` in terrain.wgsl does.
/// What `gbuffers_line` reads from an outline edge: its camera-relative ends, ARGB colour and
/// width in pixels. The strip's corners 0 and 1 are at the start, 2 and 3 at the end (six
/// vertices per edge: pack pipelines draw triangle lists); the
/// width is applied in screen space after the pack's transform.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_l_start;\nlayout(location = 1) in vec3 hz_l_end;\n\
layout(location = 2) in vec4 hz_l_argb;\nlayout(location = 3) in float hz_l_width;\n\
int hz_l_c;\n"
.to_owned();
// Pack pipelines draw triangle lists: six vertices per edge, the strip's corners
// 0 1 2 and 2 1 3.
let body = " int hz_l_v = int(gl_VertexIndex);\n \
hz_l_c = hz_l_v < 3 ? hz_l_v : hz_l_v == 3 ? 2 : hz_l_v == 4 ? 1 : 3;\n \
hz_Vertex = vec4(hz_l_c >= 2 ? hz_l_end : hz_l_start, 1.0);\n \
hz_Color = hz_l_argb.zyxw;\n \
hz_Normal = normalize(hz_l_end - hz_l_start);\n \
hz_MultiTexCoord0 = vec4(0.0, 0.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n"
.to_owned();
// ARGB in a little-endian u32 reads as B, G, R, A.
let attributes = [
("vaPosition", "hz_Vertex.xyz"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
let after = " {\n \
vec4 hz_l_a = hz_ModelViewProjectionMatrix * vec4(hz_l_start, 1.0);\n \
vec4 hz_l_b = hz_ModelViewProjectionMatrix * vec4(hz_l_end, 1.0);\n \
vec2 hz_l_d = (hz_l_b.xy / hz_l_b.w - hz_l_a.xy / hz_l_a.w) * hz_target.xy;\n \
hz_l_d = dot(hz_l_d, hz_l_d) > 1e-12 ? normalize(hz_l_d) : vec2(1.0, 0.0);\n \
vec2 hz_l_o = vec2(-hz_l_d.y, hz_l_d.x) * hz_l_width / hz_target.xy;\n \
if (hz_l_o.x < 0.0) hz_l_o = -hz_l_o;\n \
if ((hz_l_c & 1) != 0) hz_l_o = -hz_l_o;\n \
gl_Position.xy += hz_l_o * gl_Position.w;\n }\n"
.to_owned();
horizon_shaders::GbuffersInputs {
declarations,
body,
attributes: attributes
.iter()
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
.collect(),
after,
alpha_test: None,
}
}
/// The outline edges as the pack's lines program reads them: one 32-byte instance per edge.
pub fn layout() -> wgpu::VertexBufferLayout<'static> {
const ATTRIBUTES: [wgpu::VertexAttribute; 4] =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4, 3 => Float32];
wgpu::VertexBufferLayout {
array_stride: 32,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &ATTRIBUTES,
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,130 @@
//! Original regression guard: each HDR post draw fits, so its frame clears must also fit.
use super::*;
fn hdr_pack(sized: bool, format: &str) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let mut properties = String::new();
for buffer in 2..7 {
let program = if buffer == 2 {
"composite".to_owned()
} else {
format!("composite{}", buffer - 2)
};
add(&format!("{program}.vsh"), VERTEX);
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"
),
);
if sized {
properties.push_str(&format!("size.buffer.colortex{buffer}=0.5 0.5\n"));
}
}
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\nvoid main() { gl_FragColor=texture2D(colortex2,uv); }\n",
);
add("shaders.properties", &properties);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-hdr-clear-budget",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 6, "{:?}", post.notes);
let expected = horizon_shaders::Format {
channels: 4,
bits: if format == "RGBA16F" { 16 } else { 8 },
kind: if format == "RGBA16F" {
horizon_shaders::Kind::Float
} else {
horizon_shaders::Kind::Unorm
},
};
for buffer in 2..7 {
assert_eq!(
post.config.buffers[buffer].format, expected,
"fixture buffer {buffer} must have {format}"
);
}
post
}
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 {
eprintln!("HDR clear-budget test skipped: no GPU adapter");
return;
};
let mut limits = adapter.limits();
limits.max_color_attachment_bytes_per_sample = limits
.max_color_attachment_bytes_per_sample
.min(byte_budget);
limits.max_color_attachments = limits.max_color_attachments.min(attachment_limit);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: limits,
..Default::default()
}))
.expect("GPU device for HDR clear-budget contract");
eprintln!(
"HDR clear-budget adapter {:?}, requested attachments {} / bytes {}",
adapter.get_info(),
device.limits().max_color_attachments,
device.limits().max_color_attachment_bytes_per_sample
);
let gpu = Fixture {
adapter,
device,
queue,
};
// Five sequential single-output programs are legal. An implementation may group
// their clears, but it cannot turn those legal draws into an over-budget MRT.
let mut post = gpu.build(hdr_pack(sized, format));
let actual = gpu.render(&mut post, [16, 16]);
assert!(
actual.iter().all(|p| p
.iter()
.zip([0, 128, 0, 255])
.all(|(a, b)| a.abs_diff(b) <= 1)),
"sized={sized}: {:?}",
&actual[..16]
);
}
#[test]
fn fullsize_hdr_clear_respects_byte_budget() {
hdr_clear_case(false, 8, 32, "RGBA16F");
}
#[test]
fn sized_hdr_clear_respects_byte_budget() {
hdr_clear_case(true, 8, 32, "RGBA16F");
}
#[test]
fn fullsize_hdr_clear_respects_attachment_count() {
hdr_clear_case(false, 2, 64, "RGBA16F");
}
#[test]
fn sized_hdr_clear_respects_attachment_count() {
hdr_clear_case(true, 2, 64, "RGBA16F");
}
#[test]
fn fullsize_unorm_clear_respects_byte_budget() {
hdr_clear_case(false, 8, 32, "RGBA8");
}
#[test]
fn sized_unorm_clear_respects_byte_budget() {
hdr_clear_case(true, 8, 32, "RGBA8");
}
@@ -0,0 +1,637 @@
//! Original depth-buffer GPU regressions from public format documentation; no pack assets.
use super::*;
use crate::{gfx::Frame, hud::HudBatch};
use wgpu::{TextureFormat as T, util::DeviceExt};
const SIZE: [u32; 2] = [16, 16];
struct Gpu {
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Gpu {
fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
required_features: adapter.features() & wgpu::Features::FLOAT32_FILTERABLE,
..Default::default()
}))
.expect("GPU device required: setup failure is not a contract pass");
eprintln!("DEPTH_FIXTURE_ADAPTER {:?}", adapter.get_info());
Some(Self {
adapter,
device,
queue,
})
}
fn texture(&self, format: T) -> wgpu::TextureView {
self.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Original depth fixture input"),
size: wgpu::Extent3d {
width: SIZE[0],
height: SIZE[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn load(
&self,
files: Vec<(String, String)>,
world: World<'_>,
inputs: horizon_shaders::GameInputs,
) -> PackPost {
use std::io::{Cursor, Write};
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, contents) in files {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(contents.as_bytes()).unwrap();
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-depth-fixture",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(
post.programs.iter().any(|p| p.name == "final"),
"{:?}",
post.notes
);
let built = PackPost::new(
&self.device,
&self.queue,
&self.adapter,
post,
T::Rgba8Unorm,
world,
);
assert!(
built.passes.iter().any(|p| p.group == Group::Final),
"{:?}",
built.notes
);
built
}
fn clear(&self, color: &wgpu::TextureView, depth: &wgpu::TextureView, reverse_depth: f32) {
let mut encoder = self.device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Independent original fixture clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.0625,
g: 0.0625,
b: 0.0625,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(reverse_depth),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
self.queue.submit([encoder.finish()]);
}
fn render(
&self,
post: &mut PackPost,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
frame: &Frame<'_>,
terrain: &mut TerrainDraw<'_>,
) -> Vec<[u8; 4]> {
let screen = self.texture(T::Rgba8Unorm);
let stride = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Original fixture readback"),
size: u64::from(stride * SIZE[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = self.device.create_command_encoder(&Default::default());
post.run(
&self.device,
&self.queue,
&mut encoder,
color,
depth,
&screen,
frame,
&Default::default(),
SIZE,
SIZE,
&[],
terrain,
);
encoder.copy_texture_to_buffer(
screen.texture().as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(stride),
rows_per_image: None,
},
},
screen.texture().size(),
);
self.queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.unwrap();
read.slice(..)
.get_mapped_range()
.unwrap()
.chunks_exact(stride as usize)
.flat_map(|row| {
row[..SIZE[0] as usize * 4]
.as_chunks::<4>()
.0
.iter()
.copied()
})
.collect()
}
}
fn all_pixels(pixels: &[[u8; 4]], expected: [u8; 4], name: &str) {
let wrong = pixels
.iter()
.filter(|p| p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1))
.count();
eprintln!(
"DEPTH_FIXTURE_RESULT {name} expected={expected:?} actual_first={:?} wrong={wrong}/{}",
pixels[0],
pixels.len()
);
assert_eq!(wrong, 0, "{name}: independent expected pixels {expected:?}");
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Stage {
Fragment,
Deferred,
Attached,
ComputeOnly,
EarlierCompute,
}
#[derive(Clone, Copy, Debug)]
enum Scene {
World,
Terrain { hand: bool, water: bool },
WaterOnly,
}
const VERTEX: &str =
"#version 120\nvarying vec2 uv;void main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}";
fn shaders(sampler: &str, stage: Stage, scene: Scene) -> Vec<(String, String)> {
let names = if sampler == "all" {
vec!["depthtex0", "depthtex1", "depthtex2"]
} else {
vec![sampler]
};
let declarations = names
.iter()
.map(|s| format!("uniform sampler2D {s};\n"))
.collect::<String>();
let sample = |s| {
if matches!(stage, Stage::Fragment | Stage::Deferred) {
format!("texture2D({s},uv).r")
} else {
format!("texelFetch({s},p,0).r")
}
};
let result = if sampler == "all" {
format!(
"vec4({},{},{},1.0)",
sample(names[0]),
sample(names[1]),
sample(names[2])
)
} else {
format!("vec4({},0.25,0.5,1.0)", sample(names[0]))
};
let mut files = vec![
("final.vsh".into(), VERTEX.into()),
(
"shaders.properties".into(),
"blend.gbuffers_terrain=off\nblend.gbuffers_water=off\nblend.gbuffers_hand=off\n"
.into(),
),
];
if stage == Stage::Fragment {
files.push((
"final.fsh".into(),
format!(
"#version 120\n{declarations}varying vec2 uv;void main(){{gl_FragColor={result};}}"
),
));
} else if stage == Stage::Deferred {
files.push(("final.fsh".into(), "#version 120\nuniform sampler2D colortex1;varying vec2 uv;void main(){gl_FragColor=texture2D(colortex1,uv);}".into()));
files.push(("deferred.vsh".into(), VERTEX.into()));
files.push(("deferred.fsh".into(), format!("#version 120\n/* DRAWBUFFERS:1 */\n{declarations}varying vec2 uv;void main(){{gl_FragData[0]={result};}}")));
} else {
files.push(("final.fsh".into(),"#version 120\nuniform sampler2D colortex1;varying vec2 uv;void main(){gl_FragColor=texture2D(colortex1,uv);}".into()));
files.push(("composite.csh".into(),format!("#version 430\nlayout(local_size_x=1,local_size_y=1)in;const ivec3 workGroups=ivec3(16,16,1);layout(rgba8) writeonly uniform image2D colorimg1;\n{declarations}\nvoid main(){{ivec2 p=ivec2(gl_GlobalInvocationID.xy);imageStore(colorimg1,p,{result});}}")));
match stage {
Stage::Attached => {
files.push(("composite.vsh".into(),VERTEX.into()));
files.push(("composite.fsh".into(),"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){gl_FragData[0]=vec4(0.0);}".into()));
}
Stage::EarlierCompute => files.push(("composite_a.csh".into(),"#version 430\nlayout(local_size_x=1)in;const ivec3 workGroups=ivec3(1);layout(rgba8) writeonly uniform image2D colorimg2;void main(){imageStore(colorimg2,ivec2(0),vec4(0.125));}".into())),
_ => {}
}
}
if !matches!(scene, Scene::World) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
for (name, clip) in [
("gbuffers_terrain", 0.4),
("gbuffers_water", -0.2),
("gbuffers_hand", -0.6),
] {
if name == "gbuffers_hand" && !hand {
continue;
}
if name == "gbuffers_terrain" && matches!(scene, Scene::WaterOnly) {
continue;
}
files.push((
format!("{name}.vsh"),
format!("#version 120\nvoid main(){{gl_Position=vec4(gl_Vertex.xy,{clip},1.0);}}"),
));
files.push((format!("{name}.fsh"),"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){gl_FragData[0]=vec4(0.0,0.0,0.0,1.0);}".into()));
}
}
files
}
fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let Some(gpu) = Gpu::new() else {
eprintln!("DEPTH_FIXTURE_NO_ADAPTER");
return;
};
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
// A seeded world depth of reverse-Z .3 is GL window depth .7.
gpu.clear(&color, &depth, 0.3);
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let terrain = !matches!(scene, Scene::World);
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
shaders(sampler, stage, scene),
World {
atlas: None,
lightmap: color.clone(),
terrain: terrain.then_some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: false,
}),
block_ids: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand,
},
inputs,
);
if terrain {
assert!(
post.terrain.is_some() != matches!(scene, Scene::WaterOnly) && post.water.is_some(),
"{:?}",
post.notes
);
}
if hand {
assert!(post.hand.is_some(), "{:?}", post.notes);
}
if stage == Stage::Deferred {
assert!(
post.passes.iter().any(|p| p.group == Group::Deferred),
"{:?}",
post.notes
);
} else if stage != Stage::Fragment {
let count = post
.passes
.iter()
.flat_map(|p| std::iter::once(p).chain(&p.compute))
.filter(|p| p.name.starts_with("composite"))
.count();
let expected = if matches!(stage, Stage::Attached | Stage::EarlierCompute) {
2
} else {
1
};
assert_eq!(
count, expected,
"all original compute/draw stages must build: {:?}",
post.notes
);
}
let positions: [[f32; 3]; 6] = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original fixed depth geometry"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
let quad = [[-1., -1., 0.], [1., -1., 0.], [1., 1., 0.], [-1., 1., 0.]].map(|pos| {
crate::mesher::Vertex {
pos,
uv: [0.; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0, 0, 127, 0],
glint_uv: [0.; 2],
}
});
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
if hand {
frame.hand = &quad;
}
let actual = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::Solid || (water && list == TerrainList::Translucent) {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..6, 0..1);
}
});
all_pixels(&actual, expected, &format!("{sampler} {stage:?} {scene:?}"));
}
#[test]
fn hand_is_in_depth1_and_excluded_from_depth2() {
check(
"all",
Stage::Fragment,
Scene::Terrain {
hand: true,
water: true,
},
[51, 51, 179, 255],
);
}
#[test]
fn depth2_only_without_hand_excludes_water() {
check(
"depthtex2",
Stage::Fragment,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn depth1_only_without_hand_excludes_water_control() {
check(
"depthtex1",
Stage::Fragment,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn water_only_program_populates_both_opaque_snapshots() {
check(
"all",
Stage::Fragment,
Scene::WaterOnly,
[102, 179, 179, 255],
);
}
#[test]
fn deferred_depth2_only_excludes_hand() {
check(
"depthtex2",
Stage::Deferred,
Scene::Terrain {
hand: true,
water: false,
},
[179, 64, 128, 255],
);
}
#[test]
fn deferred_depth1_contains_hand_control() {
check(
"depthtex1",
Stage::Deferred,
Scene::Terrain {
hand: true,
water: false,
},
[51, 64, 128, 255],
);
}
#[test]
fn direct_fragment_gets_world_depth_control() {
check(
"depthtex0",
Stage::Fragment,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_gets_world_depth() {
check(
"depthtex0",
Stage::Attached,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn compute_only_gets_world_depth_control() {
check(
"depthtex0",
Stage::ComputeOnly,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn earlier_compute_child_gets_world_depth() {
check(
"depthtex0",
Stage::EarlierCompute,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_depth1_without_hand_excludes_water() {
check(
"depthtex1",
Stage::Attached,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_depth2_without_hand_excludes_water() {
check(
"depthtex2",
Stage::Attached,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_preserves_distinct_hand_snapshots() {
check(
"all",
Stage::Attached,
Scene::Terrain {
hand: true,
water: true,
},
[51, 51, 179, 255],
);
}
// Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup.
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 0.0,
fov_degrees: 90.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
@@ -0,0 +1,456 @@
//! Original asset-free post-output regressions, using only the public shader format.
use super::*;
use crate::{gfx::Frame, hud::HudBatch};
use std::io::{Cursor, Write};
use wgpu::TextureFormat as T;
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
#[derive(Clone, Copy, Debug)]
enum Coverage {
Full,
Discard,
Vertex,
Scale,
}
fn pack(
coverage: Coverage,
mrt: bool,
keep_main: bool,
history: Option<bool>,
shadow: bool,
) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let targets = match (shadow, mrt) {
(true, true) => "0,1",
(true, false) => "0",
(false, true) => "2,5",
(false, false) => "2",
};
let fragment = |first: &str, second: &str, discard: bool| {
format!(
"#version 120\n/* RENDERTARGETS:{targets} */\nvarying vec2 uv;\nvoid main() {{ {} gl_FragData[0]=vec4({first}); {} }}\n",
if discard { "if(uv.x<0.5) discard;" } else { "" },
if mrt {
format!("gl_FragData[1]=vec4({second});")
} else {
String::new()
},
)
};
let mut properties = if shadow {
"shadow.enabled=true\n".to_owned()
} else {
String::new()
};
if let Some(clear) = history {
add("composite.vsh", VERTEX);
add("composite.fsh", &fragment("0.25,0,0,1", "", false));
add("composite1.vsh", VERTEX);
add("composite1.fsh", &fragment("0,0.5,0,1", "", true));
add("composite2.vsh", VERTEX);
add(
"composite2.fsh",
"#version 120\n/* RENDERTARGETS:3 */\nuniform sampler2D colortex2;\nvarying vec2 uv;\nvoid main() { gl_FragData[0]=texture2D(colortex2,uv); }\n",
);
properties.push_str("flip.composite2.colortex2=true\n");
add(
"final.fsh",
&format!(
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex3;\nvarying vec2 uv;\nconst bool colortex2Clear={clear};\nconst vec4 colortex2ClearColor=vec4(0,0.375,0.5,1);\nvoid main() {{ gl_FragColor=vec4(texture2D(colortex2,uv).r,texture2D(colortex3,uv).g,texture2D(colortex3,uv).b,1); }}\n"
),
);
} else {
// B gets red, A gets blue, then the partial pass writes B again. Outside writes
// must retain B's red, independently of the current A's blue.
for (name, first, second) in [
("composite", "0.25,0,0,1", "0,0.375,0,1"),
("composite1", "0,0,0.25,1", "0.25,0,0.25,1"),
("composite2", "0,0.5,0,1", "0,0.75,0,1"),
] {
let partial = name == "composite2";
let name = if shadow {
name.replace("composite", "shadowcomp")
} else {
name.to_owned()
};
let vertex = if partial && matches!(coverage, Coverage::Vertex) {
VERTEX.replace(
"ftransform();",
"ftransform(); gl_Position.x=gl_Position.x*0.5+gl_Position.w*0.5;",
)
} else {
VERTEX.to_owned()
};
add(&format!("{name}.vsh"), &vertex);
add(
&format!("{name}.fsh"),
&fragment(
first,
second,
partial && matches!(coverage, Coverage::Discard),
),
);
}
if matches!(coverage, Coverage::Scale) {
properties.push_str("scale.composite2=0.5 0.5 0.0\n");
}
if keep_main {
properties
.push_str("flip.composite2.colortex2=false\nflip.composite2.colortex5=false\n");
}
let final_source = if mrt {
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex5;\nvarying vec2 uv;\nvoid main() { gl_FragColor=vec4(texture2D(colortex2,uv).r,texture2D(colortex5,uv).g,texture2D(colortex2,uv).g,1); }\n"
} else {
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\nvoid main() { gl_FragColor=texture2D(colortex2,uv); }\n"
};
let final_source = if shadow {
final_source
.replace("colortex2", "shadowcolor0")
.replace("colortex5", "shadowcolor1")
.replace(
"#version 120",
"#version 120\nconst int shadowMapResolution=16;",
)
} else {
final_source.to_owned()
};
add("final.fsh", &final_source);
}
add("final.vsh", VERTEX);
add("shaders.properties", &properties);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-destination-contract",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(
post.programs.len(),
if shadow { 1 } else { 4 },
"{:?}",
post.notes
);
assert_eq!(
post.shadow_comp.len(),
if shadow { 3 } else { 0 },
"{:?}",
post.notes
);
post
}
struct Fixture {
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Fixture {
fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("Post destination test skipped: no GPU adapter");
return None;
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device for destination contract");
eprintln!("Post destination adapter: {:?}", adapter.get_info());
Some(Self {
adapter,
device,
queue,
})
}
fn texture(&self, size: [u32; 2], format: T) -> wgpu::TextureView {
self.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Original post destination fixture"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn build(&self, post: Post) -> PackPost {
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
let post = PackPost::new(
&self.device,
&self.queue,
&self.adapter,
post,
T::Rgba8Unorm,
World {
atlas: None,
lightmap,
terrain: None,
block_ids: &[],
celestial: None,
gui_size: [16, 16],
clouds: None,
weather: false,
hand: false,
},
);
assert!(
post.notes.iter().all(|n| n.starts_with("shadow map ")),
"{:?}",
post.notes
);
post
}
fn render(&self, post: &mut PackPost, size: [u32; 2]) -> Vec<[u8; 4]> {
let world = self.texture(size, T::Rgba8Unorm);
let depth = self.texture(size, T::Depth32Float);
let screen = self.texture(size, T::Rgba8Unorm);
let stride = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = self.device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(stride * size[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let hud = HudBatch::new(size);
let mut encoder = self.device.create_command_encoder(&Default::default());
post.run(
&self.device,
&self.queue,
&mut encoder,
&world,
&depth,
&screen,
&fixture_frame(&hud),
&Default::default(),
size,
size,
&[],
&mut |_, _, _, _| {},
);
encoder.copy_texture_to_buffer(
screen.texture().as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(stride),
..Default::default()
},
},
screen.texture().size(),
);
self.queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.unwrap();
read.slice(..)
.get_mapped_range()
.unwrap()
.chunks_exact(stride as usize)
.flat_map(|row| {
row[..size[0] as usize * 4]
.as_chunks::<4>()
.0
.iter()
.copied()
})
.collect()
}
}
#[test]
fn partial_writes_retain_the_alternate_with_flip_scale_and_mrt_controls() {
let Some(gpu) = Fixture::new() else { return };
let mut failures = Vec::new();
for (coverage, mrt, keep_main, shadow) in [
(Coverage::Full, false, false, false),
(Coverage::Discard, false, false, false),
(Coverage::Vertex, false, false, false),
(Coverage::Scale, false, false, false),
(Coverage::Discard, true, false, false),
(Coverage::Discard, false, true, false),
(Coverage::Full, false, false, true),
(Coverage::Discard, false, false, true),
(Coverage::Vertex, false, false, true),
(Coverage::Discard, true, false, true),
] {
let mut post = gpu.build(pack(coverage, mrt, keep_main, None, shadow));
// The same pack is also exercised after resize; all destination seeds come from
// real shader passes, never from the load operation under test.
for size in [[16, 16], [8, 8]] {
let actual = gpu.render(&mut post, size);
let wrong = actual
.iter()
.enumerate()
.filter(|(n, pixel)| {
let x = *n as u32 % size[0];
let y = *n as u32 / size[0];
let outside = match coverage {
Coverage::Full => false,
Coverage::Discard | Coverage::Vertex => x < size[0] / 2,
Coverage::Scale => x < size[0] / 2 || y < size[1] / 2,
};
let expected = match (keep_main, outside, mrt) {
(true, _, _) => [0, 0, 64, 255],
(false, true, false) => [64, 0, 0, 255],
(false, false, false) => [0, 128, 0, 255],
(false, true, true) => [64, 96, 0, 255],
(false, false, true) => [0, 191, 128, 255],
};
pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1)
})
.count();
eprintln!(
"Destination {coverage:?} mrt={mrt} keep_main={keep_main} shadow={shadow} size={size:?}: wrong={wrong}/{}",
actual.len()
);
if wrong != 0 {
failures.push((coverage, mrt, keep_main, shadow, size, wrong));
}
}
}
assert!(
failures.is_empty(),
"Destination pixels differ: {failures:?}"
);
}
#[test]
fn frame_clear_and_retained_history_apply_before_partial_post_writes() {
let Some(gpu) = Fixture::new() else { return };
for clear in [true, false] {
let mut post = gpu.build(pack(Coverage::Full, false, false, Some(clear), false));
// Every frame changes which physical image is main. The other image is fully
// seeded red before the partial write, independent of its initial clearing.
for frame in 0..3 {
let actual = gpu.render(&mut post, [16, 16]);
assert!(
actual.iter().enumerate().all(|(n, p)| {
let outside = n % 16 < 8;
if frame == 0 && !clear && outside {
return true;
}
let expected = if !outside {
[64, 128, 0, 255]
} else if clear {
[64, 96, 128, 255]
} else {
[64, 0, 0, 255]
};
p.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1)
}),
"clear={clear} frame={frame}: {:?}",
&actual[..16]
);
}
}
}
// Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup.
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 0.0,
fov_degrees: 90.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
#[path = "clear_budget_tests.rs"]
mod clear_budget_tests;
@@ -0,0 +1,371 @@
//! Original offscreen inputs exercise the production blit and post-processing output.
use super::*;
use crate::{gfx::Frame, hud::HudBatch};
use std::io::{Cursor, Write};
use wgpu::TextureFormat as T;
#[derive(Clone, Copy, Debug)]
enum Change {
Colour,
Depth,
Both,
Contents,
Resize,
}
#[test]
fn replacing_only_world_colour_at_the_same_size_reads_the_new_view() {
replacement(Change::Colour, true);
}
#[test]
fn replacing_only_world_depth_at_the_same_size_reads_the_new_view() {
replacement(Change::Depth, true);
}
#[test]
fn replacing_both_world_views_at_the_same_size_matches_fresh_bindings() {
replacement(Change::Both, true);
}
#[test]
fn retained_world_views_read_updated_contents() {
replacement(Change::Contents, true);
}
#[test]
fn resized_world_views_match_fresh_bindings() {
replacement(Change::Resize, true);
}
#[test]
fn a_pack_without_final_reads_the_replaced_world_colour() {
replacement(Change::Colour, false);
}
fn replacement(change: Change, final_shader: bool) {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
})) else {
return;
};
eprintln!("world-target adapter: {:?}", adapter.get_info());
let mut size = [4, 2];
let mut colour = texture(&device, size, T::Rgba8Unorm);
let mut depth = texture(&device, size, T::Depth32Float);
clear(&device, &queue, &colour, &depth, [32, 64, 16, 255], 0.25);
let mut post = pack(&device, &queue, &adapter, &colour, final_shader);
let hud = HudBatch::new(size);
let frame = frame(&hud);
let initial = render(&device, &queue, &mut post, &colour, &depth, &frame, size);
let expected = if final_shader {
[32, 64, 191, 255]
} else {
[32, 64, 16, 255]
};
assert!(
initial.iter().all(|pixel| *pixel == expected),
"{initial:?}"
);
if matches!(change, Change::Resize) {
size = [6, 3];
}
if matches!(change, Change::Colour | Change::Both | Change::Resize) {
colour = texture(&device, size, T::Rgba8Unorm);
}
if matches!(change, Change::Depth | Change::Both | Change::Resize) {
depth = texture(&device, size, T::Depth32Float);
}
let pixel = if matches!(change, Change::Depth) {
[32, 64, 16, 255]
} else {
[160, 96, 48, 255]
};
let reverse_depth = if matches!(change, Change::Colour) {
0.25
} else {
0.75
};
clear(&device, &queue, &colour, &depth, pixel, reverse_depth);
let actual = render(&device, &queue, &mut post, &colour, &depth, &frame, size);
let mut fresh = pack(&device, &queue, &adapter, &colour, final_shader);
let reference = render(&device, &queue, &mut fresh, &colour, &depth, &frame, size);
// Independently specified RG channels and GL depth (1 - reverse-Z), in UNORM8.
let expected = if !final_shader {
[160, 96, 48, 255]
} else if matches!(change, Change::Colour) {
[160, 96, 191, 255]
} else if matches!(change, Change::Depth) {
[32, 64, 64, 255]
} else {
[160, 96, 64, 255]
};
assert!(
reference.iter().all(|pixel| *pixel == expected),
"{reference:?}"
);
eprintln!("world-target {change:?}, final={final_shader}, size={size:?}: {actual:?}");
assert_eq!(
actual, reference,
"current world inputs must match fresh bindings"
);
}
fn texture(device: &wgpu::Device, [width, height]: [u32; 2], format: T) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Original world-target regression input"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn clear(
device: &wgpu::Device,
queue: &wgpu::Queue,
colour: &wgpu::TextureView,
depth: &wgpu::TextureView,
pixel: [u8; 4],
reverse_depth: f32,
) {
let [r, g, b, a] = pixel.map(|n| f64::from(n) / 255.0);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: colour,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(reverse_depth),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
queue.submit([encoder.finish()]);
}
fn pack(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
lightmap: &wgpu::TextureView,
final_shader: bool,
) -> PackPost {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
zip.start_file("shaders/shaders.properties", options)
.unwrap();
zip.write_all(b"oldLighting=false\n").unwrap();
if final_shader {
for (name, source) in [
(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}",
),
(
"final.fsh",
"#version 120\nvarying vec2 uv;uniform sampler2D colortex0;uniform sampler2D depthtex0;\nvoid main(){gl_FragColor=vec4(texture2D(colortex0,uv).rg,texture2D(depthtex0,uv).r,1.0);}",
),
] {
zip.start_file(format!("shaders/{name}"), options).unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-world-inputs",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let macros = horizon_shaders::Macros::standard();
let properties = horizon_shaders::Properties::parse("oldLighting=false", &macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert!(post.notes.is_empty(), "{:?}", post.notes);
let post = PackPost::new(
device,
queue,
adapter,
post,
T::Rgba8Unorm,
World {
atlas: None,
lightmap: lightmap.clone(),
terrain: None,
block_ids: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
},
);
assert!(post.notes.is_empty(), "{:?}", post.notes);
post
}
#[allow(clippy::too_many_arguments)]
fn render(
device: &wgpu::Device,
queue: &wgpu::Queue,
post: &mut PackPost,
colour: &wgpu::TextureView,
depth: &wgpu::TextureView,
frame: &Frame,
size: [u32; 2],
) -> Vec<[u8; 4]> {
let screen = texture(device, size, T::Rgba8Unorm);
let stride = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(stride * size[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
post.run(
device,
queue,
&mut encoder,
colour,
depth,
&screen,
frame,
&Default::default(),
size,
size,
&[],
&mut |_, _, _, _| {},
);
encoder.copy_texture_to_buffer(
screen.texture().as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(stride),
rows_per_image: None,
},
},
screen.texture().size(),
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
read.slice(..)
.get_mapped_range()
.unwrap()
.chunks_exact(stride as usize)
.flat_map(|row| {
row[..size[0] as usize * 4]
.as_chunks::<4>()
.0
.iter()
.copied()
})
.collect()
}
fn frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
fov_degrees: 85.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
@@ -35,7 +35,7 @@ pub(super) fn upload(
let (view, sample_type) = match create(device, queue, &texture, features) {
Ok(created) => created,
Err(e) => {
notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
notes.push(format!("{}: {e}", key(&t.stage, &t.name)));
continue;
}
};
@@ -60,8 +60,8 @@ pub(super) fn upload(
Image::Atlas
} else {
notes.push(format!(
"texture.{}.{}: `{location}` is not provided yet",
t.stage, t.name
"{}: `{location}` is not provided yet",
key(&t.stage, &t.name)
));
continue;
};
@@ -83,9 +83,11 @@ pub(super) fn upload(
}
/// The stage name of `texture.<stage>.*` a program of `group` reads.
fn stage(group: Group) -> &'static str {
pub(super) fn stage(group: Group) -> &'static str {
match group {
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
Group::Setup => "setup",
Group::Begin => "begin",
Group::Prepare => "prepare",
Group::Deferred => "deferred",
Group::Composite | Group::Final => "composite",
@@ -95,7 +97,8 @@ fn stage(group: Group) -> &'static str {
/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
/// may hold one texture per dimension.
/// may hold one texture per dimension. Without one, a `customTexture` of that name and
/// dimension, which every program reads.
pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option<usize> {
let dimension = if ty.contains("1D") {
wgpu::TextureViewDimension::D1
@@ -105,14 +108,27 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
textures.iter().position(|t| {
let staged = textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
t.stage == horizon_shaders::EVERY_STAGE && t.dimension == dimension && t.name == name
})
})
}
/// The `shaders.properties` key a texture came from, for notes.
fn key(stage: &str, name: &str) -> String {
match stage {
horizon_shaders::EVERY_STAGE => format!("customTexture.{name}"),
_ => format!("texture.{stage}.{name}"),
}
}
/// `texture.noise`, if the pack has one.
pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
textures
@@ -316,6 +332,47 @@ mod tests {
);
}
/// Iris's `customTexture.<name>`: every program reads it under its own name and dimension;
/// a `texture.<stage>.<name>` of the same name comes first in its stage.
#[test]
fn custom_textures_reach_every_program() {
use wgpu::TextureViewDimension as D;
let every = horizon_shaders::EVERY_STAGE;
let textures = [
lightmap(every, "texClouds", D::D3),
lightmap("composite", "texClouds", D::D3),
lightmap(every, "colortex4", D::D2),
];
for group in [
Group::Gbuffers,
Group::Translucent,
Group::Shadow,
Group::Prepare,
Group::Deferred,
Group::ShadowComp,
] {
assert_eq!(
find(&textures, group, "sampler3D", "texClouds"),
Some(0),
"{group:?}"
);
}
// `final` reads the `composite` stage's textures too.
for group in [Group::Composite, Group::Final] {
assert_eq!(find(&textures, group, "sampler3D", "texClouds"), Some(1));
}
assert_eq!(
find(&textures, Group::Gbuffers, "sampler2D", "texClouds"),
None
);
// Only its own name: no other name of the same buffer.
assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), None);
assert_eq!(
find(&textures, Group::Final, "sampler2D", "colortex4"),
Some(2)
);
}
#[test]
fn texels_pack_into_their_format() {
let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
@@ -49,6 +49,10 @@ pub struct PackInputs {
pub elytra_flying: bool,
/// `cloudHeight`, see [`cloud_height`].
pub cloud_height: f32,
/// `cloudTime`: how far the game's clouds have moved along X, in blocks (`clouds::drift`
/// times vanilla's 0.03 blocks a tick). Iris 1.11.7 gives it, though ShaderDoc doesn't list
/// it: observed with a probe pack, three game times 0.03 apart per tick.
pub cloud_time: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
/// The player's state, for Iris's status uniforms.
@@ -57,6 +61,11 @@ pub struct PackInputs {
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
pub end_flash: f32,
pub end_flash_direction: Vec3,
/// The dimension type's `ambient_light` (`ambientLight`).
pub ambient_light: f32,
/// The player's cave mood, 0..1 (`BiomeAmbientSoundsHandler`'s moodiness):
/// `playerMood` and `constantMood`.
pub mood: f32,
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
@@ -252,6 +261,9 @@ pub const NAMES: &[&str] = &[
"is_sprinting",
"endFlashIntensity",
"endFlashPosition",
"ambientLight",
"playerMood",
"constantMood",
"centerDepthSmooth",
"isRightHanded",
"is_on_ground",
@@ -260,6 +272,7 @@ pub const NAMES: &[&str] = &[
"logicalHeightLimit",
"hasSkylight",
"hasCeiling",
"cloudTime",
"isElytraFlying",
"cloudHeight",
];
@@ -382,6 +395,10 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
(view, projection)
}
/// The seconds a tick of a smoothing half-life (`wetnessHalflife`, `eyeBrightnessHalflife`,
/// `centerDepthHalflife`) lasts in Iris 1.11.7: measured, twice a game tick.
const SECONDS_A_TICK: f32 = 0.1;
/// `value` moved toward `target` with a half-life of `half_life` seconds over `dt` (5.6).
fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
target + (value - target) * 0.5f32.powf(dt / half_life)
@@ -428,26 +445,29 @@ impl FrameUniforms {
let tilt = self.shadow.sun_path_rotation;
let sun = to_view(body(frame.sky.sun_angle, tilt));
let moon = to_view(body(frame.sky.moon_angle, tilt));
// Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26).
// Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26), at
// a tenth of a second a tick: Iris 1.11.7 smooths that slowly (measured frame by frame
// with a probe pack: wetness 60 s rising, 20 s falling; eyeBrightnessSmooth 1 s;
// centerDepthSmooth 0.1 s).
let half_life = if inputs.rain > self.wetness {
600.0
} else {
200.0
};
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
self.wetness = smooth(self.wetness, inputs.rain, half_life * SECONDS_A_TICK, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
if first_frame {
self.wetness = inputs.rain;
self.eye_smooth = eye;
}
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
*smoothed = smooth(*smoothed, target, 10.0 * SECONDS_A_TICK, dt);
}
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
// read back is taken as it is.
if let Some(raw) = self.center_depth_raw {
self.center_depth = Some(match self.center_depth {
Some(v) => smooth(v, raw, 1.0 / 20.0, dt),
Some(v) => smooth(v, raw, SECONDS_A_TICK, dt),
None => raw,
});
}
@@ -544,7 +564,8 @@ impl FrameUniforms {
"biome_precipitation" => Value::i(&[inputs.biome_precipitation]),
"temperature" => Value::f(&[inputs.temperature]),
"rainfall" => Value::f(&[inputs.rainfall]),
"eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.round() as i32)),
// Rounded down, as Iris 1.11.7 gives it (probe pack).
"eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.floor() as i32)),
"gbufferModelView" => m4(view),
"gbufferModelViewInverse" => m4(view.inverse()),
"gbufferProjection" => m4(projection),
@@ -557,6 +578,10 @@ impl FrameUniforms {
"heldBlockLightValue2" => Value::i(&[inputs.held_light[1]]),
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
"cloudTime" => Value::f(&[inputs.cloud_time]),
"ambientLight" => Value::f(&[inputs.ambient_light]),
// Iris 1.11.7 gave both the same value in every scene measured.
"playerMood" | "constantMood" => Value::f(&[inputs.mood]),
// renderStage is NONE outside gbuffers draws.
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
}
@@ -900,6 +925,25 @@ mod initial_history_tests {
float(&u, "cameraPosition")
);
}
/// Iris 1.11.7 (probe pack, a world saved at game time 5073 and loaded about ten seconds
/// before): `cloudTime` read 157.98, 160.56 and 163.11, four seconds apart. That is the
/// clouds' drift at 0.03 blocks a tick, 5266 ticks in.
#[test]
fn cloud_time_is_how_far_the_clouds_drifted() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let drift = crate::clouds::drift(5266, 0.0, 256);
let input = PackInputs {
cloud_time: drift as f32 * 0.030_000_001,
..Default::default()
};
u.update(&f, &input, [800, 450]);
assert!((float(&u, "cloudTime")[0] - 157.98).abs() < 1e-3);
// A trip across the 256-cell texture takes 256 * 400 ticks, then starts again.
assert_eq!(crate::clouds::drift(256 * 400 + 3, 0.25, 256), 3.25);
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
@@ -922,6 +966,275 @@ mod initial_history_tests {
assert!(float(&u, "wetness")[0] > 0.0);
assert!(u.eye_smooth.iter().all(|v| *v > 0.0));
}
/// Actual provider bytes through translated GLSL and a GPU pixel, with a nonidentity
/// model-view/projection and a distant camera. Geometry is already camera-relative.
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn core_matrix_inputs_render_provider_values() {
use horizon_shaders::{
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
};
use std::io::Write;
use wgpu::util::DeviceExt;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Core matrix GPU: {:?}", adapter.get_info());
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let hud = HudBatch::new([800, 400]);
let mut f = frame(&hud);
f.camera = DVec3::new(1024.25, 100.5, -2048.75);
f.yaw = 180.0;
f.pitch = 0.0;
f.fov_degrees = 90.0;
f.bob = Mat4::from_translation(Vec3::new(0.5, -0.25, 0.0));
let mut values = FrameUniforms::default();
values.update(&f, &PackInputs::default(), [800, 400]);
for (declaration, setup, projection, view, expected) in [
(
"",
"",
"projectionMatrix",
"modelViewMatrix",
[64u8, 64, 128, 255],
),
(
"uniform mat4 projectionMatrix, modelViewMatrix;",
"",
"projectionMatrix",
"modelViewMatrix",
[64, 64, 128, 255],
),
(
"",
"",
"gl_ProjectionMatrix",
"gl_ModelViewMatrix",
[64, 64, 128, 255],
),
(
"",
"mat4 projectionMatrix = mat4(2.0); mat4 modelViewMatrix = mat4(3.0);",
"projectionMatrix",
"modelViewMatrix",
[128, 191, 255, 255],
),
(
"mat4 projectionMatrix = mat4(2.0), modelViewMatrix = mat4(3.0);",
"",
"projectionMatrix",
"modelViewMatrix",
[128, 191, 255, 255],
),
] {
let vertex = format!(
"#version 330 compatibility\n{declaration}\nout vec3 result;\n\
void main() {{\n{setup}\n\
vec4 q = {projection} * {view} * vec4(0.5, 0.75, -2.0, 1.0);\n\
result = vec3(q.xy / q.w, q.w / 4.0);\n\
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (path, source) in [
("shaders/gbuffers_line.vsh", vertex.as_str()),
(
"shaders/gbuffers_line.fsh",
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
),
] {
zip.start_file(path, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack = Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner())
.unwrap();
let macros = Macros::standard();
let program = translate_gbuffers(
&preprocess(&pack, "gbuffers_line.vsh", &macros),
&preprocess(&pack, "gbuffers_line.fsh", &macros),
Target::Buffers,
&GbuffersInputs::default(),
)
.unwrap();
let mut bytes = vec![0; program.interface.uniform_size as usize];
for uniform in &program.interface.uniforms {
if let Some(i) = index(&uniform.name) {
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
}
}
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::UNIFORM,
});
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);
let fs = module(program.fragment);
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: 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::Rgba8Unorm,
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: uniform.as_entire_binding(),
}],
});
let texture = 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::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let 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(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
// Independent result: translated point (1, .5, -2), perspective (aspect 2,
// vertical FOV 90): clip (.5, .5, z, 2), hence (.25, .25, .5, 1).
for (actual, expected) in mapped[..4].iter().zip(expected) {
assert!(
actual.abs_diff(expected) <= 1,
"{projection}: {:?}",
&mapped[..4]
);
}
}
}
/// Iris 1.11.7, read frame by frame with a probe pack: eyeBrightnessSmooth falls by half
/// in 1 s and is rounded down; wetness takes 60 s to rise halfway and 20 s to fall; the
/// centre depth 0.1 s.
#[test]
fn smoothed_uniforms_take_iris_half_lives() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
// The next frame comes `seconds` after the last (and a few microseconds of the test).
let after = |u: &mut FrameUniforms, seconds: f32| {
u.last = Some(std::time::Instant::now() - std::time::Duration::from_secs_f32(seconds));
};
let mut input = PackInputs {
eye_light: [0, 15],
..Default::default()
};
u.center_depth_raw = Some(1.0);
u.update(&f, &input, [800, 450]);
input.eye_light = [0, 0];
u.center_depth_raw = Some(0.0);
after(&mut u, 0.1);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "centerDepthSmooth")[0] - 0.5).abs() < 1e-3);
after(&mut u, 0.6);
u.update(&f, &input, [800, 450]);
// 240 falling toward 0 for 0.7 s: 147.8, read as 147.
match u.values[index("eyeBrightnessSmooth").unwrap()] {
Value::I(v) => assert_eq!(v[1], 147),
_ => panic!("integer uniform expected"),
}
input.rain = 1.0;
after(&mut u, 60.0);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
input.rain = 0.0;
after(&mut u, 20.0);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
}
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
#[test]
fn ambient_light_and_mood_come_from_the_inputs() {
let hud = HudBatch::new([800, 450]);
let mut u = FrameUniforms::default();
let input = PackInputs {
ambient_light: 0.25,
mood: 0.0235,
..Default::default()
};
u.update(&frame(&hud), &input, [800, 450]);
assert_eq!(float(&u, "ambientLight")[0], 0.25);
assert_eq!(float(&u, "playerMood")[0], 0.0235);
assert_eq!(float(&u, "constantMood")[0], 0.0235);
}
fn frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
@@ -964,6 +1277,9 @@ mod initial_history_tests {
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
@@ -5,7 +5,10 @@
#[cfg(test)]
use super::Frustum;
use super::{Cull, SectionKey};
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
streaming::in_view_distance,
};
use glam::{DVec3, Vec3};
/// One chunk column's resident sections.
@@ -80,6 +83,15 @@ impl<T> Resident<T> {
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// Every section's key and value.
#[cfg(debug_assertions)]
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T)> {
self.items.iter().map(|(key, value)| (*key, value))
}
/// Every section's key and value.
pub fn iter_mut(&mut self) -> impl Iterator<Item = (SectionKey, &mut T)> {
self.items.iter_mut().map(|(key, value)| (*key, value))
}
/// `key`'s value, when it is resident.
pub fn find(&self, key: SectionKey) -> Option<&T> {
self.slots.get(&key).map(|&slot| self.get(slot))
@@ -99,6 +111,10 @@ impl<T> Resident<T> {
pub fn set_min_y(&mut self, min_y: i32) {
self.min_y = min_y;
}
/// The minimum Y of section index 0.
pub fn min_y(&self) -> i32 {
self.min_y
}
pub fn clear(&mut self) {
self.items.clear();
self.bounds.clear();
@@ -141,6 +157,35 @@ impl<T> Resident<T> {
column.low = column.low.min(key.1);
column.high = column.high.max(key.1);
}
/// Removes every section of the `gone` columns, the others keeping their order: one
/// pass over the store, where `remove` moves a slot and looks its column up for each.
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
// Every column (a far teleport): emptied, without a lookup per section.
if self.columns.iter().all(|c| gone.contains(&(c.x, c.z))) {
self.bounds.clear();
self.slots.clear();
self.columns.clear();
self.column_index.clear();
return std::mem::take(&mut self.items)
.into_iter()
.map(|(_, value)| value)
.collect();
}
let items = std::mem::take(&mut self.items);
let bounds = std::mem::take(&mut self.bounds);
self.slots.clear();
self.columns.clear();
self.column_index.clear();
let mut removed = Vec::new();
for ((key, value), bounds) in items.into_iter().zip(bounds) {
if gone.contains(&(key.0, key.2)) {
removed.push(value);
} else {
self.insert_with_bounds(key, value, bounds);
}
}
removed
}
pub fn remove(&mut self, key: SectionKey) -> Option<T> {
let slot = self.slots.remove(&key)?;
let c = self.column_index[&(key.0, key.2)] as usize;
@@ -191,7 +236,7 @@ pub struct Visible {
}
/// The camera-relative origin of the section at `key`.
fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
pub(super) fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
DVec3::new(
f64::from(key.0 * 16),
f64::from(min_y + key.1 * 16),
@@ -200,7 +245,7 @@ fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
}
/// The camera's chunk column.
fn camera_column(camera: DVec3) -> (i32, i32) {
pub(super) fn camera_column(camera: DVec3) -> (i32, i32) {
(
(camera.x.floor() as i32) >> 4,
(camera.z.floor() as i32) >> 4,
@@ -352,7 +397,7 @@ impl<T> Resident<T> {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
#[test]
fn column_walk_finds_exactly_the_sections_in_view() {
@@ -512,13 +557,49 @@ mod tests {
((seed >> 4) % 6) as i32 - 1,
((seed >> 8) % 5) as i32 - 2,
);
if (seed >> 12).is_multiple_of(3) {
if (seed >> 12).is_multiple_of(389) {
// Every column at once (a far teleport), and one that holds nothing.
let gone: FxHashSet<(i32, i32)> = (-2..=3)
.flat_map(|x| (-2..=2).map(move |z| (x, z)))
.collect();
let mut removed = r.remove_columns(&gone);
let mut expected: Vec<u32> = model.drain().map(|(_, v)| v).collect();
removed.sort_unstable();
expected.sort_unstable();
assert_eq!(removed, expected);
assert!(r.columns.is_empty() && r.column_index.is_empty());
} else if (seed >> 12).is_multiple_of(97) {
// Whole columns at once: the column of `key` and the next one along x.
let gone: FxHashSet<(i32, i32)> =
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
let mut removed = r.remove_columns(&gone);
let mut expected: Vec<u32> = model
.extract_if(|k: &SectionKey, _| gone.contains(&(k.0, k.2)))
.map(|(_, v)| v)
.collect();
removed.sort_unstable();
expected.sort_unstable();
assert_eq!(removed, expected);
} else if (seed >> 12).is_multiple_of(3) {
assert_eq!(r.remove(key), model.remove(&key));
} else {
r.insert(key, step);
model.insert(key, step);
}
check(&r, &model);
// `iter_mut` yields every key once, with its value.
let mut seen = HashSet::new();
for (key, value) in r.iter_mut() {
assert_eq!(*value, model[&key], "{key:?}");
assert!(seen.insert(key), "{key:?} twice");
}
assert_eq!(seen.len(), model.len());
}
for (_, value) in r.iter_mut() {
*value += 1;
}
for (key, value) in &model {
assert_eq!(r.find(*key), Some(&(value + 1)));
}
r.clear();
assert_eq!(r.len(), 0);
@@ -132,14 +132,15 @@ impl Stars {
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
// RenderPipelines.STARS: the celestial blend (src alpha, one).
// RenderPipelines.STARS: the celestial blend (src alpha, one), with the
// colour premultiplied in fs_stars, so its alpha can carry HDR's mark.
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::OVER,
alpha: wgpu::BlendComponent::REPLACE,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -11,6 +11,10 @@ struct Stars {
return stars.clip_from_sky * vec4(position, 1.0);
}
// The colour premultiplied, as the celestial blend (src alpha, one) would: the pipeline adds it.
// The alpha is HDR output's mark (hdr.wgsl): stars are points of light, at the bright tier,
// fully lifted wherever they show at all. Nothing else reads the frame's alpha.
@fragment fn fs_stars() -> @location(0) vec4<f32> {
return stars.color;
let b = stars.color.a;
return vec4(stars.color.rgb * b, 0.175);
}
@@ -15,6 +15,11 @@ use glam::Vec3;
pub const TRIANGLES: usize = 16;
/// One vertex per triangle corner, each as `(position, weight)`: the apex
/// weighs 1, the rim 0, so the rasterizer interpolates the barycentric apex
/// weight the shader used to compute per pixel.
pub const VERTICES: usize = TRIANGLES * 3;
/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight
/// times S), w `[A, r0, r1]` (its sign is S's where the hit lies ahead).
/// Then per rim vertex: xyz `r × A`.
@@ -29,6 +34,19 @@ pub fn normals(alpha: f32, sun: Vec3) -> [[f32; 4]; 2 * TRIANGLES] {
out
}
/// The fan as triangle-list vertices in camera space, with the apex at
/// weight 1 and each rim vertex at 0 (`VERTICES`).
pub fn mesh(alpha: f32, sun: Vec3) -> [[f32; 4]; VERTICES] {
let (apex, rim) = vertices(alpha, sun);
let mut out = [[0.0; 4]; VERTICES];
for i in 0..TRIANGLES {
out[i * 3] = apex.extend(1.0).to_array();
out[i * 3 + 1] = rim[i].extend(0.0).to_array();
out[i * 3 + 2] = rim[(i + 1) % TRIANGLES].extend(0.0).to_array();
}
out
}
/// The fan's apex and rim in camera space, as vanilla builds them: the
/// fan turned to the horizon the sun sets on (or rises from), its rim
/// tilted by the alpha.
@@ -208,6 +226,21 @@ mod tests {
best
}
#[test]
fn mesh_puts_the_apex_weight_on_the_apex_of_every_triangle() {
let (apex, rim) = vertices(0.7, Vec3::new(0.0, 0.6, -0.8));
let mesh = mesh(0.7, Vec3::new(0.0, 0.6, -0.8));
assert_eq!(mesh.len(), VERTICES);
for i in 0..TRIANGLES {
assert_eq!(&mesh[i * 3][..3], apex.to_array());
assert_eq!(mesh[i * 3][3], 1.0);
assert_eq!(&mesh[i * 3 + 1][..3], rim[i].to_array());
assert_eq!(mesh[i * 3 + 1][3], 0.0);
assert_eq!(&mesh[i * 3 + 2][..3], rim[(i + 1) % TRIANGLES].to_array());
assert_eq!(mesh[i * 3 + 2][3], 0.0);
}
}
#[test]
fn normals_weigh_every_ray_as_the_per_pixel_fan_did() {
let mut hits = 0;
+105 -13
View File
@@ -245,7 +245,8 @@ impl GraphicsApi {
/// What the frame limiter does in the last half millisecond before a frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Pacing {
/// Sleep the whole wait: no CPU spent spinning, a little less precise.
/// Sleep the whole wait: no CPU spent spinning, a little less precise; frames
/// keep to a fixed grid of slots, so late wake-ups don't lower the frame rate.
#[default]
Efficient,
/// Vanilla's `FramerateLimiter`: sleep, then spin until the deadline.
@@ -543,6 +544,9 @@ pub struct Graphics {
pub lod_distance: u32,
/// How finely far terrain is drawn; Full unless the player picks Reduced.
pub far_detail: FarDetail,
/// Far terrain from servers' BlueMap web maps; no server has one unless
/// the player names it (`blueMap:` lines) or turns on auto-detect.
pub blue_map: crate::bluemap::Settings,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
@@ -551,6 +555,20 @@ pub struct Graphics {
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
pub leaf_culling: LeafCullingMode,
/// Cache settled shulker-box models.
pub cache_shulker_boxes: bool,
/// Cache sign-text geometry.
pub cache_signs: bool,
/// Cache decorated pots outside their wobble animation.
pub cache_decorated_pots: bool,
/// Cache bells while they are not ringing.
pub cache_bells: bool,
/// Cache closed chests, including copper variants.
pub cache_chests: bool,
/// Cache banner poles while leaving their flags animated.
pub cache_banners: bool,
/// Cache copper-golem statue models.
pub cache_copper_golem_statues: bool,
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
@@ -627,9 +645,18 @@ impl Default for Graphics {
preset: Preset::Custom,
lod_distance: 0,
far_detail: FarDetail::Full,
blue_map: crate::bluemap::Settings::default(),
cave_culling: true,
entity_culling: EntityCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
cache_shulker_boxes: false,
// Sign text has used its renderer cache independently of the new model caches.
cache_signs: true,
cache_decorated_pots: false,
cache_bells: false,
cache_chests: false,
cache_banners: false,
cache_copper_golem_statues: false,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
@@ -670,6 +697,14 @@ impl Graphics {
let mut g: Self = fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
.unwrap_or_default();
g.blue_map.servers = match fs::read_to_string(dir.join(crate::bluemap::SERVERS_FILE)) {
Ok(text) => crate::bluemap::Settings::parse_servers(&text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
log::warn!("{}: {e}; no BlueMap is named", crate::bluemap::SERVERS_FILE);
Vec::new()
}
};
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
g.hdr = false;
g.save(dir);
@@ -698,9 +733,38 @@ impl Graphics {
g.lod_distance = number(g.lod_distance).min(crate::lod::MAX_CHUNKS)
}
"farDetail" => g.far_detail = FarDetail::parse(value),
"blueMapRadius" => {
if let Some(radius) = crate::bluemap::Settings::parse_radius(value) {
g.blue_map.radius = radius;
}
}
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"blockEntityCache" => {
let enabled = flag(false);
g.cache_shulker_boxes = enabled;
g.cache_signs = enabled;
g.cache_decorated_pots = enabled;
g.cache_bells = enabled;
g.cache_chests = enabled;
g.cache_banners = enabled;
g.cache_copper_golem_statues = enabled;
}
"blockEntityCacheShulkerBoxes" => {
g.cache_shulker_boxes = flag(g.cache_shulker_boxes)
}
"blockEntityCacheSigns" => g.cache_signs = flag(g.cache_signs),
"blockEntityCacheDecoratedPots" => {
g.cache_decorated_pots = flag(g.cache_decorated_pots)
}
"blockEntityCacheBells" => g.cache_bells = flag(g.cache_bells),
"blockEntityCacheChests" => g.cache_chests = flag(g.cache_chests),
"blockEntityCacheBanners" => g.cache_banners = flag(g.cache_banners),
"blockEntityCacheCopperGolemStatues" => {
g.cache_copper_golem_statues = flag(g.cache_copper_golem_statues)
}
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
@@ -766,14 +830,21 @@ impl Graphics {
g
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
self.entity_culling.name(),
self.leaf_culling.name(),
bool_text(self.cache_shulker_boxes),
bool_text(self.cache_signs),
bool_text(self.cache_decorated_pots),
bool_text(self.cache_bells),
bool_text(self.cache_chests),
bool_text(self.cache_banners),
bool_text(self.cache_copper_golem_statues),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
@@ -810,7 +881,9 @@ impl Graphics {
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
)
);
text += &self.blue_map.serialize();
text
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
pub fn set_key(&mut self, name: &str, value: String) -> bool {
@@ -861,10 +934,7 @@ impl Graphics {
// and render threads are raised above four cores, where vanilla
// raises its server thread). Leaving two idled a core of a 4-thread
// laptop while a world loaded: a teleport there took ~15% longer.
let auto = std::thread::available_parallelism()
.map_or(2, |n| n.get())
.saturating_sub(1)
.max(1);
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
if self.battery { auto.div_ceil(2) } else { auto }
}
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
@@ -937,9 +1007,22 @@ mod tests {
preset: Preset::Balanced,
lod_distance: 256,
far_detail: FarDetail::Reduced,
blue_map: crate::bluemap::Settings {
radius: 40,
auto_detect: true,
// In their own file (`bluemap::SERVERS_FILE`).
servers: Vec::new(),
},
cave_culling: false,
entity_culling: EntityCullingMode::Synchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
cache_shulker_boxes: true,
cache_signs: true,
cache_decorated_pots: true,
cache_bells: true,
cache_chests: true,
cache_banners: true,
cache_copper_golem_statues: true,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
@@ -986,6 +1069,13 @@ mod tests {
Graphics::parse("entityCulling:false\n").entity_culling,
EntityCullingMode::Off
);
// A map named here (a shared instance's, a pack's) is no map.
assert!(
Graphics::parse("blueMap:play.example.net https://map.example.net\n")
.blue_map
.servers
.is_empty()
);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is on, in files from before it too; only false turns it off,
// and only under its key: the old one held the false every save wrote while it was
@@ -1141,10 +1231,12 @@ mod tests {
assert_eq!(g.lod_distance_on(&discrete), distance);
}
assert!(
crate::graphics_ui::rows(crate::graphics_ui::Level::Simple)
.iter()
.any(|row| matches!(row, crate::graphics_ui::Row::Setting(s)
if s.text.contains(&format!("{INTEGRATED_LOD_DISTANCE} chunks")))),
crate::graphics_ui::rows()
.into_iter()
.flat_map(|(_, groups)| groups.into_iter().flat_map(|(_, rows)| rows))
.any(|r| r
.text
.contains(&format!("{INTEGRATED_LOD_DISTANCE} chunks"))),
"the LOD Distance description names the cap"
);
}
@@ -1202,7 +1294,7 @@ mod tests {
fn automatic_workers_leave_a_core_as_vanilla_does() {
let g = Graphics::default();
assert_eq!(g.workers, 0);
let cores = std::thread::available_parallelism().map_or(2, |n| n.get());
let cores = horizon_threads::worker_count();
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
// A manual count is the player's own choice, kept as asked.
let manual = Graphics { workers: 3, ..g };
+238 -209
View File
@@ -1,14 +1,19 @@
//! The look of Horizon's graphics settings screens, in one place: sizes,
//! colours and the widgets they draw with. The screens (`graphics_ui`) only
//! lay out rows and handle input; a menu theme replaces this module.
//! The look of Horizon's graphics settings screen, in one place: sizes,
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
//! with a thin edge, so the world shows through, flat controls, and the violet
//! face on the one main button, in vanilla's font.
use crate::{
hud::{HudAtlas, HudBatch},
lang,
text::Text,
ui,
};
use horizon_theme::BLOCKS_DARK as B;
/// What a setting costs in frame time, shown beside its description.
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cost {
None,
@@ -28,217 +33,241 @@ impl Cost {
Cost::Saves => "Saves time",
}
}
}
pub struct Style {
/// Width of a setting's button or slider.
pub control_width: f32,
/// Space between the control and its description.
pub gap: f32,
/// Widest description column; narrower on small screens.
pub max_text_width: f32,
/// Vertical distance between setting rows.
pub row_height: f32,
pub header_height: f32,
/// Top and bottom bands holding the title and the buttons.
pub band: f32,
pub title_color: u32,
pub header_color: u32,
pub description_color: u32,
/// Colour of each `Cost`, in its order.
pub cost_colors: [u32; 5],
}
/// `hud::rgba` with full alpha, usable in constants.
const fn opaque(r: u8, g: u8, b: u8) -> u32 {
0xFF00_0000 | (b as u32) << 16 | (g as u32) << 8 | r as u32
}
pub const STYLE: Style = Style {
control_width: 150.0,
gap: 8.0,
max_text_width: 250.0,
row_height: 25.0,
header_height: 16.0,
band: 33.0,
title_color: u32::MAX,
header_color: opaque(255, 255, 85),
description_color: opaque(170, 170, 170),
cost_colors: [
opaque(120, 200, 120),
opaque(170, 220, 120),
opaque(240, 200, 90),
opaque(240, 110, 90),
opaque(85, 255, 85),
],
};
impl Style {
/// Width of the description column on a screen `screen_width` wide.
pub fn text_width(&self, screen_width: f32) -> f32 {
(screen_width - self.control_width - self.gap - 24.0).clamp(120.0, self.max_text_width)
}
/// Left edge of a row's control, centring control and description.
pub fn row_x(&self, screen_width: f32) -> f32 {
let total = self.control_width + self.gap + self.text_width(screen_width);
((screen_width - total) / 2.0).floor()
}
pub fn background(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
top: f32,
bottom: f32,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
ui::list_background(batch, atlas, top, bottom, in_world);
}
pub fn frame(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
top: f32,
bottom: f32,
in_world: bool,
) {
ui::separators(batch, atlas, top, bottom, in_world);
}
pub fn title(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
let w = batch.width;
ui::centered_text(batch, atlas, text, 0.0, 12.0, w, self.title_color);
}
pub fn header(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text, x: f32, y: f32) {
batch.text_styled(atlas, text, x, y + 4.0, self.header_color, true);
}
pub fn button(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
hover: bool,
active: bool,
) {
ui::button(batch, atlas, x, y, w, 20.0, label, active, hover);
}
pub fn slider(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
active: bool,
dragging: bool,
) {
ui::slider(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
active,
dragging && active,
);
}
pub fn slider_ext(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
ui::slider_ext(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
focused,
can_change_value,
active,
dragging && active,
);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
x: f32,
y: f32,
cost: Cost,
text: &str,
) {
let tag = Text::literal(lang::get(cost.label()));
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
let indent = atlas.font.width_text(&tag) + 4.0;
let text = lang::get(text);
let lines = wrap(atlas, &text, self.text_width(batch.width), indent);
for (i, line) in lines.iter().take(2).enumerate() {
let lx = if i == 0 { x + indent } else { x };
batch.text_styled(
atlas,
&Text::literal(line.clone()),
lx,
y + i as f32 * 10.0,
self.description_color,
true,
);
/// `0xRRGGBB`: the palette's success, warn and danger.
pub fn rgb(self) -> u32 {
match self {
Cost::None => B.ink_soft,
Cost::Low | Cost::Saves => B.success,
Cost::Medium => B.warn,
Cost::High => B.danger,
}
}
}
/// Greedy word wrap; the first line is `indent` narrower.
fn wrap(atlas: &HudAtlas, text: &str, width: f32, indent: f32) -> Vec<String> {
let mut lines = vec![String::new()];
for word in text.split_whitespace() {
let room = if lines.len() == 1 {
width - indent
} else {
width
};
let line = lines.last().expect("one line");
let candidate = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if atlas.font.width(&candidate) <= room || line.is_empty() {
*lines.last_mut().expect("one line") = candidate;
} else {
lines.push(word.to_owned());
}
}
lines
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
}
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
const GLASS: u32 = hex(B.page, 118);
/// Their 1 px edge.
const EDGE: u32 = hex(B.ink, 26);
/// A group's panel, a little lighter than the plate it sits on.
const GROUP: u32 = hex(B.ink, 11);
/// A hovered or focused row, lit on its group's panel.
pub const HOVER: u32 = hex(B.ink, 18);
/// The open page in the page list.
pub const OPEN: u32 = hex(B.ink, 26);
/// The rule under the plate's heading, and between two rows of a group.
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
/// Fields, boxes and the value stepper: sunk, see-through.
const WELL: u32 = hex(B.outline, 102);
/// A button, a slider's track, the scroller's thumb.
const BUTTON: u32 = hex(B.ink, 20);
const BUTTON_HOVER: u32 = hex(B.ink, 41);
const TRACK: u32 = hex(B.ink, 64);
const HANDLE: u32 = hex(B.ink, 255);
/// The one main button.
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
/// A tick box's edge when off.
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const ACCENT: u32 = hex(B.accent, 255);
/// Vanilla's white text, and its grey for what can't be used now.
pub const WHITE: u32 = u32::MAX;
pub const MUTED: u32 = hex(B.muted, 255);
/// A page that isn't open, a value at rest.
pub const SOFT: u32 = hex(B.ink_soft, 255);
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
pub const GREY: u32 = hex(0xaaaaaa, 255);
/// A light veil over the backdrop, under the panels.
pub const VEIL: u32 = hex(B.page, 77);
#[cfg(test)]
mod tests {
use super::*;
/// Sizes in GUI pixels.
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
pub const TITLED: f32 = 300.0;
pub const ROW: f32 = 20.0;
/// A group's name over its panel, and a page's over its groups while searching.
pub const GROUP_ROW: f32 = 14.0;
pub const PAGE_ROW: f32 = 16.0;
/// Space under a group's panel, and more before the next page's name.
pub const GROUP_GAP: f32 = 10.0;
pub const PAGE_GAP: f32 = 6.0;
/// The control on the right of a row (a button, a box or a slider), and the
/// left column: the search field, the page list and the buttons.
pub const CONTROL_W: f32 = 112.0;
pub const CONTROL_H: f32 = 16.0;
pub const SEARCH_H: f32 = 16.0;
/// Vanilla's scroller: the column `ui::Scroll::press` takes.
pub const SCROLLER_W: f32 = 6.0;
/// The strip under the plate that describes a setting.
pub const DOCK_H: f32 = 50.0;
pub const BUTTON_H: f32 = 20.0;
/// A row's name and control, from its group panel's edges.
pub const INSET: f32 = 10.0;
#[test]
fn every_cost_has_a_label_and_a_colour() {
let all = [Cost::None, Cost::Low, Cost::Medium, Cost::High, Cost::Saves];
assert_eq!(all.len(), STYLE.cost_colors.len());
for (i, cost) in all.into_iter().enumerate() {
assert_eq!(cost as usize, i);
assert!(!cost.label().is_empty());
pub fn fill(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], color: u32) {
batch.fill(atlas, x, y, w, h, color);
}
/// A 1 px ring inside `rect`: the keyboard or controller focus, in white as vanilla draws it.
pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], color: u32) {
batch.fill(atlas, x, y, w, 1.0, color);
batch.fill(atlas, x, y + h - 1.0, w, 1.0, color);
batch.fill(atlas, x, y + 1.0, 1.0, h - 2.0, color);
batch.fill(atlas, x + w - 1.0, y + 1.0, 1.0, h - 2.0, color);
}
/// The screen's plate, and the strip under it: dark glass with a thin edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GLASS);
ring(batch, atlas, rect, EDGE);
}
/// A group's panel on the plate, its rows on it.
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GROUP);
}
/// A sunk well: the search field, the value stepper.
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, WELL);
}
/// The scroller: a faint track and its thumb.
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
fill(batch, atlas, track, ROW_RULE);
fill(batch, atlas, thumb, TRACK);
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control.
pub fn fit(atlas: &HudAtlas, text: &str, width: f32) -> String {
if atlas.font.width(text) <= width {
return text.to_owned();
}
let mut out = String::new();
for c in text.chars() {
if atlas.font.width(&format!("{out}{c}...")) > width {
break;
}
out.push(c);
}
format!("{}...", out.trim_end())
}
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
fill(batch, atlas, rect, WELL);
let edge = match (on, live) {
(true, true) => ACCENT,
(true, false) => MUTED,
(false, _) => BOX_EDGE,
};
ring(batch, atlas, rect, edge);
if on {
tick(batch, atlas, rect[0] + 1.0, rect[1] + 2.0, edge);
}
}
/// A pixel tick, 10 by 8, over its own shadow.
fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
const TICK: [&str; 8] = [
" #",
" ##",
" ## ",
"# ## ",
"## ## ",
" ## ## ",
" ### ",
" # ",
];
for (j, row) in TICK.iter().enumerate() {
for (i, c) in row.chars().enumerate() {
if c == '#' {
let (px, py) = (x + i as f32, y + j as f32);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
fill(batch, atlas, [px, py, 1.0, 1.0], color);
}
}
}
}
/// A flat button with its label centred: Undo, Calibrate..., and Done in the
/// violet (`primary`). Pointed at or focused, vanilla's white edge.
pub fn button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
(hover, live, primary): (bool, bool, bool),
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(true, false, true) => BUTTON_HOVER,
};
fill(batch, atlas, rect, face);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let color = if live { WHITE } else { MUTED };
let ty = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(batch, atlas, label, x + 2.0, ty, w - 4.0, color);
}
/// A slider: its value over a thin track filled in the accent up to the handle at `position`.
pub fn slider(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
value: &Text,
position: f32,
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
let color = if live { WHITE } else { MUTED };
ui::centered_text(batch, atlas, value, x + 2.0, y + 1.0, w - 4.0, color);
let line = y + h - 3.0;
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
fill(batch, atlas, [x, line, w, 2.0], TRACK);
fill(
batch,
atlas,
[x, line, at - x, 2.0],
if live { ACCENT } else { MUTED },
);
let handle = [at, line - 3.0, 4.0, 8.0];
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
if hover && live {
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
}
}
/// A value that a press steps through: in a well, between the arrows that say so.
pub fn cycler(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
value: &Text,
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
well(batch, atlas, rect);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let ty = y + ((h - 8.0) / 2.0).floor();
let arrows = if hover && live { WHITE } else { MUTED };
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
let color = match (live, hover) {
(false, _) => MUTED,
(true, true) => WHITE,
(true, false) => SOFT,
};
batch.text_styled(atlas, value, x + 9.0, ty, color, true);
}
/// A screen's title, centred at the top (split-screen profiles).
pub fn title(batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
let w = batch.width;
ui::centered_text(batch, atlas, text, 0.0, 12.0, w, u32::MAX);
}
/// The cost's colour in the HUD's encoding: the square beside its name.
pub fn cost_color(cost: Cost) -> u32 {
hex(cost.rgb(), 255)
}
Loaded 100 of 1292 files, more files were not shown because too many files have changed in this diff. Show more