Compare commits

...
1057 Commits
Author SHA1 Message Date
Ludovic Marie a99e31d01f Merge pull request #2152 from kacpherek/fix/trident-unbreaking-riptide
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: a thrown trident wears through `hurtAndBreak`, so Unbreaking applies, and `TridentItem.releaseUsing` doesn't launch a riding player with Riptide. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:42:31 +01:00
Ludovic Marie 97ddd2c6f9 Merge pull request #2151 from kacpherek/fix/trident-channeling
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: channeling.json summons lightning at the trident's hit while it thunders and the target sees the sky. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:39:42 +01:00
Ludovic Marie f01a4e2119 Merge pull request #2150 from kacpherek/fix/villager-bed-search-radius
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `VillagerMakeLove.takeVacantBed` asks the POI manager for a free home within 48 blocks (a sphere, closest first) and keeps only a bed a path reaches. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:36:14 +01:00
Ludovic Marie 7f8a6680a8 Merge pull request #2149 from kacpherek/fix/farmer-composter-bread
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `WorkAtComposter` composts the farmer's seeds through `ComposterBlock.insertItem` and makes bread from wheat. I pushed one small fix: the `ComposterBlock.addLayer` doc comment had moved off its function. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:32:30 +01:00
Ludovic Marie 68db8847e7 Merge pull request #2147 from kacpherek/fix/creeper-fire-charge
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Creeper.mobInteract` ignites with any `#creeper_igniters` item. A fire charge is used up, flint and steel takes damage, and both play their own sound. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:29:08 +01:00
Ludovic Marie 3a5aa2b3e7 Merge pull request #2146 from kacpherek/fix/skeleton-attack-interval-difficulty
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `AbstractSkeleton.reassessWeaponGoal` sets the bow goal's interval to 20 ticks on Hard and 40 otherwise. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:25:43 +01:00
Ludovic Marie c2e4eee251 Merge pull request #2145 from kacpherek/fix/mob-griefing-explosions-endermen
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerLevel.explode` turns `ExplosionInteraction.MOB` into KEEP when `mob_griefing` is off, and `EnderMan`'s take and leave block goals need the rule too. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:22:10 +01:00
Ludovic Marie bc13a67e74 Merge pull request #2143 from kacpherek/fix/sweep-attack-damage
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Player.doSweepAttack` hits the living entities in the target's box inflated by (1, 0.25, 1), not the player, the target or allies, and not marker armor stands. I pushed one fix: the distance bound is vanilla's fixed `distanceToSqr(entity) < 9.0`, not the player's reach. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:21:26 +01:00
Ludovic Marie c35575f462 Merge pull request #2138 from kacpherek/fix/animal-breeding-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: rabbits breed (`Rabbit.isFood`), damage ends love (`Animal.hurtServer` resets `inLove`), the horse and wolf `canMate` checks, and the breeding orb only with `mob_drops` (`Animal.finalizeSpawnChildFromBreeding`). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:17:45 +01:00
Ludovic Marie 5ecce8a7a3 Merge pull request #2142 from kacpherek/fix/mob-damage-difficulty-scaling
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Player.hurtServer` scales damage that `scalesWithDifficulty` from a living non-player: none on Peaceful, `min(d / 2 + 1, d)` on Easy, `d * 3 / 2` on Hard (so a creeper's blast scales, and TNT a player lit doesn't). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:14:06 +01:00
Ludovic Marie badceed27c Merge pull request #2188 from kacpherek/fix/loot-set-stew-effect
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SetStewEffectFunction` keeps the effect it rolled, with the duration times 20 unless the effect is instant. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:10:53 +01:00
Ludovic Marie 45159841bc Merge pull request #2316 from ludovic111/fix/campfire-not-a-container
🤖 Agent: Merging ahead of the queue. verify, launcher and CI parity pass, and dispenser and block_removal are back to 4000/4000 and 6000/6000. This turns main green again after #2015.
2026-10-26 19:10:13 +01:00
Ludovic Marie cfc55bb6c6 Merge pull request #2185 from kacpherek/fix/slime-block-step-slowdown
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SlimeBlock.stepOn` slows a walker that isn't sneaking when `|dy| < 0.1`, by `0.4 + |dy| * 0.2`. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:06:12 +01:00
Ludovic Marie 6af65a142b Merge pull request #2182 from kacpherek/fix/weather-command-without-cycle
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `/weather` sets the weather whether or not `advance_weather` is on. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 19:00:56 +01:00
Ludovic Marie 60109e0d91 Merge pull request #2181 from kacpherek/fix/sleep-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerPlayer.startSleepInBed` (creative skips the monster check; the block above must be free; the reach box), and `ServerLevel.tick`'s morning skip with the weather reset. One difference is left for later: the monster box is centred on the player, and vanilla centres it on the bed. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:56:09 +01:00
Ludovic Marie 1f88194b61 Merge pull request #2180 from kacpherek/fix/fire-burnout-difficulty-radius
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `FireBlock.tick`: burn-out chances, ignite odds scaled by difficulty, and the spread box (-1..1 sideways, -1..4 up). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:53:10 +01:00
Ludovic Marie 17fa57db3d Merge pull request #2179 from kacpherek/fix/lightning-fire-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LightningBolt.spawnFire` only on Normal and Hard and with fire spread on, as here. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:49:47 +01:00
Ludovic Marie 0b98555009 Merge pull request #2189 from kacpherek/fix/dragon-crystal-heal-reach
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `EnderDragon.checkCrystals` looks for crystals in its box grown by 32 and heals 1 every 10 ticks, capped at max health. Main moved the crystal search into `nearest_crystal_to`, which the server thread's client uses too, so I merged main into the branch and put the grown box there. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:46:47 +01:00
Ludovic Marie d6a7f7f32c Merge pull request #2176 from kacpherek/fix/2161-shared-key-bindings
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `KeyMapping.click` and `set` act on every mapping bound to the key, as here, and your second commit stops a key from replaying a press when two mappings share it by default. #2207 fixes the same issue; I kept this one because it came first and changes less. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:46:14 +01:00
Ludovic Marie 2ca383f36d Merge pull request #2183 from kacpherek/fix/campfire-shovel-douse
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ShovelItem.useOn` puts out a lit campfire (`CampfireBlock.dowse`) with its sound and smoke. One difference is left for later: clients of the server thread don't see that smoke yet. Main conflicted with its new `campfire` module (cooking), so I merged main into the branch, keeping both modules. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:43:00 +01:00
Ludovic Marie 5812071a1b Merge pull request #2172 from rdbtCVS/fix/1525-forced-dimension-ticking
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerLevel.tick` keeps ticking a level that has forced chunks, even with no players and past the 300-tick empty timeout. `lan_e2e.py --server-thread --check forced-levels` passes. Main conflicted in lan_e2e.py, so I merged main into the branch, keeping both checks. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @rdbtCVS!
2026-10-26 18:42:08 +01:00
Ludovic Marie 1a43fa5859 Merge pull request #2206 from socksprox/fix/piston-moving-block-anim
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerLevel.runBlockEvents` broadcasts each handled block event, and `ClientLevel.blockEvent` re-runs `triggerEvent` on the client, which builds the moving piston it ticks and draws (`deathTicks`, client-side `finalTick`, the 2001 effect for destroyed blocks). I pushed one fix with a test. The client's re-run also ran the server's neighbour logic: a block it moved could start another piston on that client alone, and vanilla calls `checkIfExtend` only server-side. Its `playSound(null, ...)` also played the piston and note sounds a second time, next to the server's; `ClientLevel.playSeededSound` plays only when the excluded player is the local one. In game on the server thread, a piston that pushes a redstone block under a second piston leaves both clients' blocks as the server has them. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @socksprox!
2026-10-26 18:39:19 +01:00
Ludovic Marie 212775a11f Merge pull request #2144 from kacpherek/fix/thorns-hits-attacker
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: Thorns' `post_attack` effect damages the attacker (the shooter for a projectile). Main conflicted in `lan_pvp.rs` because it already had `Strike.attacker`, so I merged it, keeping main's critical-hit code and then your Thorns loop. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:37:46 +01:00
Ludovic Marie edd7f712b6 Merge pull request #2293 from ludovic111/ci/thinkpad-off
Tools: the ThinkPad no longer runs harnesses or builds; playhorizon is the Linux host
2026-10-26 18:35:33 +01:00
Ludovic Marie cbfb8845d2 Merge pull request #2191 from kacpherek/fix/hoglin-repellents
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `HoglinSpecificSensor` finds the nearest `#hoglin_repellents` block (8 sideways, 4 up and down), and `HoglinAi` walks away and stays passive for 200 ticks. I pushed one rename: this PR and #2192 each added a `find_repellent` to piglin.rs, so this one is now `find_hoglin_repellent` and the two can merge together. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 18:34:59 +01:00
Ludovic Marie 0b1ed3cdf5 Merge pull request #2175 from rdbtCVS/t3/fix-breaking-crack-jitter
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked: the crack overlay keeps its UVs aligned from one stage to the next. Its ignored GPU test passes here (macOS, Metal). Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @rdbtCVS!
2026-10-26 18:33:51 +01:00
Ludovic MarieandClaude Opus 5.5 1825b3991c Campfires are no Container for droppers and hoppers
Since campfires got their block entity (#2015), the container lookup took
it for a zero-slot container: a dropper facing one kept its item instead of
dropping it, as vanilla's HopperBlockEntity.getContainerAt finds no Container
there. The dispense fixture also filled campfires through the generic slots,
which a campfire doesn't have, so block_removal saw them empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:30:56 +01:00
Ludovic Marie 9422f698ee Merge pull request #2107 from kacpherek/fix/1959-pack-reload-surface
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked: the renderer that replaces this one shares its instance, adapter and device, so it can take the configured surface instead of creating a second one for the same window. Wayland allows only one tearing-control object per surface, and the reporter's log shows the fresh surface coming back lost. I can't reproduce the Wayland/RADV crash here (macOS), so this is checked from the code and the log. On macOS the game still draws after the renderer is rebuilt (screenshots before and after). Fixes #1959. Main's HDR output conflicted in recreate_surface, so I merged main into the branch: the reused surface now goes through main's Renderer::configure too. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 18:21:17 +01:00
Ludovic MarieandClaude Opus 5.5 64f4e9b6ff parity_hosts: playhorizon skips mob_ai, lan_integration, world_reopen (failed in its trial)
Full trial (run 40341431094): 342/348 in 96.9 min; save_roundtrip,
remote_fireball and server_player_sculk passed on a rerun with vscreen and
fewer jobs; these three did not.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:12:19 +01:00
Ludovic Marie 800b91567a Merge pull request #2303 from ludovic111/fix/setblock-loot-table
🤖 Agent: Merged by Ludovic's AI agent (Claude). setblock and data now keep a container's LootTable and LootTableSeed until its items change, as vanilla does. command_exec matches the 26.3 oracle (870/870), and in game the chest opens with vanilla's exact roll, in-frame and on the server thread.
2026-10-26 18:11:16 +01:00
Ludovic MarieandClaude Opus 5.5 fbbed63f04 setblock and data keep a container's loot table
A command's tag went through decode_block_entity, which has nowhere to put
LootTable, so `setblock ... chest{LootTable:...}` made an empty chest
(`data get` showed `Items: []`). An empty container given a LootTable is
now rolled as a loaded chunk's are, and the roll is kept with its chunk
(RolledLoot), so it saves, and `data get` shows, LootTable and
LootTableSeed until its items change (tryLoadLootTable / trySaveLootTable).
`data merge` and `data modify` go through the same path.

command_exec: setblock a seeded loot chest, then data get it whole and its
LootTableSeed (870/870 match the 26.3 oracle).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:02:34 +01:00
Ludovic Marie 21a89c2e7f Merge pull request #2046 from ESHAYAT102/fix/remote-squid-animation
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The client branch of Squid.aiStep (body pitch/roll from the server's velocity, the tentacle cycle, entity event 19 resetting it) matches the 26.3 bytecode. The new `squid_client` harness passes against the real Squid oracle on an integration tree with the other open contributor PRs. I merged main into the branch and stubbed in test_remote_motion's fixture what remote_motion now also reaches (dragon, fireballs, thrown potions, eyes of ender), so that test runs again. Fixes #1684 is right. Thanks @ESHAYAT102!
2026-10-26 18:00:12 +01:00
Ludovic Marie e24ed85036 Merge main into fix/1525-forced-dimension-ticking 2026-10-26 17:59:02 +01:00
Ludovic MarieandClaude Opus 5.5 06a45d86a1 Merge main into fix/1959-pack-reload-surface
Main's HDR output configures the surface through Renderer::configure; the
reused spare surface goes through it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:58:15 +01:00
Ludovic Marie 64fab2cc83 Merge pull request #2060 from kacpherek/fix/1953-creative-menu-fps
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Before/after on the Mac mini (M4 Pro) with the creative inventory open, two runs each, fast profile, `horizon-ctl bench`. Main: CPU frame 0.81 to 0.91 ms on the building/combat tabs and 1.32 to 1.47 ms while searching (664 to 742 fps). This PR: 0.44 to 0.49 ms on building/combat and 0.49 ms while searching (1122 to 1125 fps). The panel pixels match main. After #1990 made the search an EditBox, I merged main in, and the shown items now key on its value; in game, typing "diamond" narrows the search tab from 45 to 17 stacks. `item_model`, `creative`, `container_screen` and `hud` pass, and so do the creative unit tests (18). Fixes #1953 is right. Thanks @kacpherek!
2026-10-26 17:57:51 +01:00
Ludovic Marie 57f5e1fb8e Merge pull request #2299 from ludovic111/fix/macos-python-tests
🤖 Agent: Merged by Ludovic's AI agent (Claude). Two tests in tests/ errored only on macOS (a python3-less PATH for the oracle shim, and /var vs /private/var); the suite now passes on the Mac mini, 311/311.
2026-10-26 17:53:36 +01:00
Ludovic MarieandClaude Opus 5.5 d01a621fec tests: two parity-runner tests pass on macOS
OracleReuseTests ran the shim through `#!/usr/bin/env python3` with a PATH
whose Python folder is Homebrew's, which names it only python3.13: the
shim wasn't found (127). The test now gives that PATH a python3 of its own.

test_report_rejects_sources_changed_during_a_run compared a resolved source
path with an unresolved temp root, and macOS's /var is /private/var.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:46:06 +01:00
Ludovic MarieandClaude Opus 5.5 0f22a274fa test_remote_motion: stub what remote_motion reaches on main
remote_motion now also hands motion to the dragon, its fireballs, thrown
potions and eyes of ender; the extracted fixture needs those to compile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:44:05 +01:00
Ludovic Marie a42857ab48 Merge pull request #2050 from rdbtCVS/rdbt/fix-1527-forced-chunk-save
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Forced chunks are now kept in each dimension's data/minecraft/chunk_tickets.dat as 26.3's TicketStorage writes them, and the persistent tickets Horizon doesn't simulate are kept too. A world with /forceload areas round-trips with vanilla 26.3, and the save harnesses pass on an integration tree with the whole save series. I merged main into the branch after #2070 and #2288 and moved the new test to the end of save.rs's tests, so the other save PRs don't collide with it (save:: 27/27). Fixes #1527 is right. Thanks @rdbtCVS!
2026-10-26 17:34:12 +01:00
Ludovic Marie 5963e6cc01 Merge pull request #2294 from ludovic111/fix/recipe-command-targets
🤖 Agent: Merged by Ludovic's AI agent (Claude). /recipe now changes each target's own recipe book and tells that player's client, as RecipeCommand does through ServerPlayer.awardRecipes/resetRecipes. lan_e2e recipe_targets fails on main and passes here, in-frame and on the server thread; recipe_removal and command_exec still pass. Thanks to platform-2 for finding it.
2026-10-26 17:31:33 +01:00
Ludovic Marie d26252bff5 Merge pull request #2291 from ludovic111/carry/2074-poi-files
🤖 Agent: Merged by Ludovic's AI agent (Claude). This lands @FusselTV's #2074 (write points of interest), verified by a vanilla 26.3 round trip; the save harnesses pass. The only additions are merges of main (after #2070 and #2288); save:: passes 29/29. Thanks @FusselTV!
2026-10-26 17:26:38 +01:00
Ludovic MarieandClaude Opus 5.5 f69d23019b Merge main (the probe list keeps squid_client_probe beside the new probes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:26:12 +01:00
Ludovic MarieandClaude Opus 5.5 2fc2ee1e13 Merge main (creative.rs: the search is an EditBox since #1990; the shown items key on its value)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:23:53 +01:00
Ludovic Marie abd2d9f3fe Merge pull request #2283 from ludovic111/ci/launcher-check
🤖 Agent: Merging. verify passes, and the new launcher job passes on this head (fmt, clippy -D warnings, lib tests). On the earlier head it had caught main's E0061 before #2281. The Python tests show the same 2 environment errors as main.
2026-10-26 17:23:37 +01:00
Ludovic MarieandClaude Opus 5.5 b8e90da1c2 Merge main (save.rs tests: the forced-chunk test moves to the end of the module, clear of the tests other PRs add at its top)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:22:07 +01:00
Ludovic MarieandClaude Opus 5.5 d8e68148be /recipe gives and takes each target's own recipes
cmd_recipe changed self.level.recipe_book for every target, so
`/recipe give|take <other player>` changed the book of whoever ran it (or
the dedicated server's own) and the target's client heard nothing. Each
target now runs with its state swapped in (for_player), as
ServerPlayer.awardRecipes/resetRecipes do on the player's own
ServerRecipeBook: the guest's book changes and its client gets
ClientboundRecipeBookAdd/RemovePacket.

lan_e2e recipe_targets: the host gives and takes the guest's recipes and,
on the server thread, the guest gives the host one; each open book must
show exactly its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:18:47 +01:00
Ludovic MarieandClaude Opus 5.5 7e5fa6f91b Merge main (save.rs tests: the POI tests beside #2073's loot test)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:18:19 +01:00
Ludovic Marie e0423bba77 Merge remote-tracking branch 'origin/main' into ci/launcher-check
# Conflicts:
#	tools/ci/rust_changes.py
2026-10-26 17:15:21 +01:00
Ludovic Marie 04cdf22527 Merge pull request #2288 from ludovic111/carry/2073-keep-loot-tables
🤖 Agent: Merged by Ludovic's AI agent (Claude). This lands @FusselTV's #2073 (unopened containers keep their loot table), verified against vanilla 26.3: seeded loot 48/48 in both single-player paths, and the save harnesses pass. The only addition is a merge of main after #2070. Thanks @FusselTV!
2026-10-26 17:15:02 +01:00
Ludovic MarieandClaude Opus 5.5 9f1784dc0c rust_changes: append the repository root to sys.path instead of prepending tools/
Prepending tools/ made unittest discovery import tools/test_decoration_parity.py
in place of tests/test_decoration_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:14:46 +01:00
Ludovic MarieandClaude Opus 5.5 3ae2aad690 Merge main (save.rs: SaveDir's shape beside the forced chunks; both tests kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:13:59 +01:00
Ludovic MarieandClaude Opus 5.5 0bd6a576a9 Tools: the ThinkPad no longer runs harnesses or builds; playhorizon is the Linux host
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:12:10 +01:00
Ludovic Marie 5f1c25c226 Merge pull request #2171 from ludovic111/feat/mod-run-quiet
🤖 Agent: merged main to resolve a conflict in game/remote.rs (main's boss-overlay reset beside this PR's QuietCommands event; both kept). Clippy with mods on is clean. Requeued at the new head.
2026-10-26 17:11:50 +01:00
Ludovic Marie f7170d6b9f Merge pull request #2045 from ESHAYAT102/fix/creative-tool-block-breaking
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Item.canDestroyBlock in creative (swords, maces and tridents can't break blocks) matches the 26.3 bytecode, on the player's prediction and on the server. On the integration tree, `lan_e2e --check creative_tools` passes in-frame and on the server thread, and the new `creative_mining` harness passes against its oracle. I merged main into the branch twice; the second time, lan_e2e.py and the probe list keep both sides, every def checked against its source branch, and client_probe and the bins compile. Fixes #2035 is right. Thanks @ESHAYAT102!
2026-10-26 17:09:09 +01:00
Ludovic MarieandClaude Opus 5.5 43ae7a0974 Merge main (save.rs: LevelShape beside the POI encoder; both sets of tests kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:08:19 +01:00
Ludovic Marie 91295ab88c Merge pull request #2109 from kacpherek/fix/2083-remote-effect-clouds
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `AreaEffectCloud` syncs `DATA_RADIUS` (8), `DATA_WAITING` (9) and `DATA_PARTICLE` (10), waits 20 ticks (dragon clouds too), and `clientTick` is ported draw for draw. `DragonFireball`'s trail is `dragon_breath`. One fix pushed: `AbstractHurtingProjectile.tick` calls `applyInertia` before the move, so the client's fireball now accelerates and then moves by the new velocity, with `Vec3.normalize`'s zero for a tiny vector. Main conflicted with #2086 (thrown projectiles between server updates), so I merged it, keeping both: the thrown and eye path first, then the dragon fireball's. `lan_e2e.py --check particles` and `objects` pass in both modes. Part of #2083, as it says. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 17:06:31 +01:00
Ludovic Marie bb81cb4586 Merge pull request #2078 from RoyalPixelsDE/fix/structure-starts
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Chunks now write the structure starts and references they hold, so vanilla keeps the structures (and their spawns) after the round trip. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 17:03:35 +01:00
Ludovic MarieandClaude Opus 5.5 bf39e5d49e Merge main (save.rs: keep both new tests; the loot test encodes with LevelShape::OVERWORLD after #2070)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:01:49 +01:00
Ludovic Marie 8b792957f7 Merge pull request #2286 from ludovic111/chore/site-repo-split
🤖 Agent: merging. playhorizon.dev already deploys from ludovic111/horizon-site (verified page by page); this drops website/ and moves tokens.css, roadmap.json, the benchmark fixtures and the web icons where the site's build and the tests read them. verify passed; pr_ready --quick and the telemetry server's tests pass locally.
2026-10-26 17:00:07 +01:00
Ludovic Marie 2c6b60f3fb Merge pull request #2105 from socksprox/fix/fire-smoke-no-break-particles
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `BaseFireBlock.animateTick` (three large smoke above, or two per burnable side when the block below neither burns nor is sturdy up, in that order and with the `* 0.1F` float edge), `SoulFireBlock.canBurn` always true, and `Level.destroyBlock` → `spawnDestroyParticles` → `BaseFireBlock`'s empty `spawnDestroyByEntityParticles`. `fire.igniteOdds` is in block-behaviours.json at the top level, as read. I added one rule: `FireBlock.getIgniteOdds` is 0 for a waterlogged state, so such a neighbour no longer counts as burnable. This and #2082 (the player's predicted break) cover different paths, so both stay; #2092 was already closed in favour of this one. In game, fire and soul fire show their large smoke above them. Thanks @socksprox, and @kacpherek for #2092! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:59:27 +01:00
Ludovic Marie 8f827db52d Merge pull request #2102 from kacpherek/fix/1516-vehicles-in-nether-portals
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `NetherPortalBlock.entityInside` takes any entity that `canUsePortal(false)`, and boats and minecarts travel through `teleportCrossDimension` with their data, as items and mobs already did here. A ridden vehicle stays out of this PR's scope (the player-and-vehicle case is #1486). `lan_e2e.py --check vehicles`, `boats` and `minecarts` pass in both modes. Fixes #1516. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:56:54 +01:00
Ludovic Marie 0e0b0c947f Merge pull request #2100 from kacpherek/fix/1436-item-frame-light
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ItemFrameRenderer.getBlockLightLevel` gives a glow frame's model at least block light 5 (the item and map inside stay full bright), and the frame model takes the entity shader's directional shading from its face normals. My screenshot run couldn't place a frame, so this rests on the code and on `lan_e2e.py --check frames`, which passes in both modes. Fixes #1436. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:54:21 +01:00
Ludovic Marie e065f2af6f Merge pull request #2097 from kacpherek/fix/1507-server-end-credits
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `EndPortalBlock.entityInside` calls `showEndCredits` on a player in the End without `seenCredits`. That sends `WIN_GAME` with 0.0 once (`wonGame`), and `handleClientCommand(PERFORM_RESPAWN)` while `wonGame` respawns the player in the Overworld keeping its data. `seenCredits` round-trips through playerdata. One difference is left for later: vanilla takes the player out of the End level (`removePlayerImmediately`) while the poem rolls, and here it stays in the level. `lan_e2e.py --check portals` passes with the host on its server thread (I pushed one fix to the check: it closes the credits on the host's first End exit). Fixes #1507. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:51:57 +01:00
Ludovic Marie 8ccf20d909 Merge pull request #2077 from RoyalPixelsDE/fix/structure-starts-3-legacy
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The remaining legacy structure pieces now save their vanilla fields. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:51:18 +01:00
Ludovic Marie c5f9e3404c Merge remote-tracking branch 'origin/main' into feat/mod-run-quiet
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-26 16:49:16 +01:00
Ludovic Marie 7ace1e9e8f Merge pull request #2076 from RoyalPixelsDE/fix/structure-starts-2-templates
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Template pieces now save as vanilla's TemplateStructurePiece does. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:48:38 +01:00
Ludovic Marie ebf2132885 Merge pull request #2103 from rdbtCVS/fix/1948-merchant-cost-components
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ItemCost.STREAM_CODEC` is item, count, then `DataComponentExactPredicate.STREAM_CODEC`, a list of typed components with no removal count, as now written and read. Its verify failed only on rustfmt (item_sync.rs), which I pushed. Main conflicted in item_sync.rs's tests (the new bundle test), so I merged it, keeping both tests. I also took the codec round trip back out of the trading probe: the probe's output is what the harness compares with vanilla, the round trip is already covered by your two unit tests, and its `unwrap` would abort the probe on any cost whose components name an entry the stub registry lacks. `lan_e2e.py --check hero-trading` passes in both modes. Fixes #1948. Thanks @rdbtCVS! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:44:53 +01:00
Ludovic MarieandClaude Opus 5.5 4fe648b674 Move the website to its own repository (ludovic111/horizon-site)
playhorizon.dev now deploys from the private ludovic111/horizon-site, which holds website/'s history. The files
its build still reads from here move out of website/:

- the design tokens to crates/horizon-theme/tokens.css (xtask tokens writes them, tokens_css_is_up_to_date checks them);
- the roadmap's JSON to roadmap/roadmap.json (tools/roadmap.py build);
- the benchmark fixtures to crates/horizon-telemetry-server/tests/fixtures/benchmarks/ (the site keeps a copy);
- the web icons to docs/media/brand/web/ (xtask icons).

pr_ready loses its website area and the tokens special case; rust_changes, the roadmap's links and every doc
that pointed into website/ follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:43:32 +01:00
Ludovic Marie 747c22a5c2 Merge pull request #2088 from rdbtCVS/rdbt/fix-1491-guardian-beams-20261026
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Guardian.DATA_ID_MOVING` (16) and `DATA_ID_ATTACK_TARGET` (17, the target's entity id, 0 for none) are synced, `onSyncedDataUpdated` zeroes `clientSideAttackTime`, and the client's `aiStep` advances it up to `getAttackDuration` (80, elder 60), which drives the beam. The in-frame tick now shares `client_tick`. Main conflicted (the bat flags from #2003, the sonic and cushion checks), so I merged it into the branch, keeping both sides. `lan_e2e.py --check guardian-beams` passes with the host in-frame and on its server thread (I pushed one fix to the check: the host reads the guardian from the level that ticks it). Fixes #1491. Thanks @rdbtCVS! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:41:53 +01:00
Ludovic Marie cab57a158f Merge pull request #2072 from RoyalPixelsDE/fix/proto-block-entity-id
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Block entities placed in proto chunks now save with their id, as vanilla's ProtoChunk does, so vanilla loads them. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:41:09 +01:00
Ludovic MarieandClaude Opus 5.5 402b8dc01e Merge main (lan_e2e.py and the probe list keep both checks and probes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:39:28 +01:00
Ludovic Marie 9cbf93a193 Merge pull request #2070 from RoyalPixelsDE/fix/dimension-height
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Nether and End chunks now save with their own min_y/height and sky light, as ChunkSerializer does per dimension: vanilla reads them back with every section in place. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:38:29 +01:00
Ludovic Marie b1afe112a4 Merge pull request #2281 from ludovic111/fix/launcher-proxy-redact
🤖 Agent: Merged by Ludovic's AI agent (Claude). This keeps proxy credentials out of the launcher's messages and fixes the launcher build that broke on main (download::agent call in browser/horizon.rs). Launcher lib tests 137/137, horizon-port-pack 68/68. Thanks again to @zhykowo for the proxy support in #2059.
2026-10-26 16:36:08 +01:00
Ludovic Marie b107b8539d Merge pull request #2101 from kacpherek/fix/server-thread-chest-minecart
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ContainerEntity.isChestVehicleStillValid` is `!isRemoved() && player.isWithinEntityInteractionRange(getBoundingBox(), 4.0)`, which is entity interaction range (3, creative 5) plus 4 measured from the eye to the box, as here. The menu had closed on the tick it opened because the placeholder key was checked as a block. This is also the Discord report of a chest minecart that plays its sound but opens no screen. `lan_e2e.py --check minecarts` passes in both modes, and through horizon-ctl a chest minecart's screen is still open 60 ticks after it opens. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:35:45 +01:00
Ludovic MarieandClaude Opus 5.5 0c8e8037df Merge main into fix/campfire-shovel-douse
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:35:19 +01:00
Ludovic Marie df518c1683 Merge pull request #2071 from RoyalPixelsDE/fix/trial-spawner-spawn-data
🤖 Agent: Gate check by Ludovic's AI agent (Claude). A trial spawner with nothing to spawn no longer writes an empty spawn_data, matching TrialSpawnerStateData's codec. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:35:11 +01:00
Ludovic Marie 2aa41f867e Merge pull request #2068 from lazy-importer/fix/2039-custom-item-name-tooltip
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The item_name component now sits on items and tooltips as vanilla's Item.getName/getStyledHoverName read it (custom_name first, then item_name, then the translation key). I pushed d983ef89b so item_name stays a component and is not folded into custom_name. `tooltip_display`, `lore_tooltip` and `firework_tooltip` pass on an integration tree with the other open contributor PRs. Fixes #2039 is right. Thanks @lazy-importer!
2026-10-26 16:32:32 +01:00
Ludovic Marie 8741f4d590 Merge pull request #2015 from vlesmentie/feat/campfire-cooking
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Campfire cooking (CampfireBlockEntity.cookTick/cooldownTick, placing food, dropping the result, the arm swing) matches 26.3, and food cooks in game on the server thread and in-frame. Since campfires now tick, the save round trip saw the fixture's lit campfire advance its CookingTimes between load and save, as the lit furnace already did. I added that one ignore rule (ba797f60a); `save_roundtrip` then passes with 0 unexplained differences. Thanks @vlesmentie!
2026-10-26 16:29:52 +01:00
Ludovic MarieandClaude Opus 5.5 dbbe3a45d9 CI: check the launcher's workspace on every push to main and on pull requests that touch it
verify's cargo runs never build launcher/ (its own workspace), so #2059 and the
registry ratings commit each passed alone and left main's launcher failing to
compile (E0061). The new launcher job runs its rustfmt, clippy -D warnings and lib
tests when a pull request changes the launcher, a crate it builds or the root
manifests, and on every push to main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:29:09 +01:00
Ludovic MarieandClaude Opus 5.5 8df89ca4d6 Merge main into fix/dragon-crystal-heal-reach
Main moved the crystal search into nearest_crystal_to, which the server
thread's client uses too; the dragon's grown box now lives there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:28:09 +01:00
Ludovic MarieandClaude Opus 5.5 81c8e0e815 Merge main into fix/1525-forced-dimension-ticking
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:27:56 +01:00
Ludovic Marie d880b23fec Merge pull request #2278 from ludovic111/feat/sculk-banner-launcher
🤖 Agent: Sculk partner banner in the launcher (Servers and Home), as Ludovic asked. Launcher fmt, clippy -D warnings and tests pass; checked in dark and light on the Mac mini; verify passed on this head.
2026-10-26 16:26:53 +01:00
Ludovic Marie c97be88ae5 Merge pull request #2094 from rdbtCVS/rdbt/fix-1478-firework-bursts-20261026
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `FireworkRocketEntity.explode` broadcasts entity event 17 before damage and `discard`, and `handleEntityEvent(17)` calls `createFireworks` with the rocket's movement and its item's explosions, so sending the event before the tracker drops the rocket is the right order. Main conflicted (fireworks.rs, lan_e2e.py), so I merged it, keeping main's rocket damage and your `remote_firework_explosion`. `lan_e2e.py --check fireworks` passes in both modes. Fixes #1478. Thanks @rdbtCVS! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:26:25 +01:00
Ludovic MarieandClaude Opus 5.5 600b2a1dda Launcher proxy: keep a proxy's credentials out of messages; build again
An address the launcher can't use as a proxy is printed to stderr by
horizon-port-pack and shown under the Settings field or by `settings set`:
a user name and password in it now read `***`.

The registry's star ratings called download::agent() without the address
it now takes (#2059 and the ratings landed side by side), so the launcher
didn't build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:25:16 +01:00
Ludovic MarieandClaude Opus 5.5 4b89d0cd85 Merge main into fix/2083-remote-effect-clouds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:24:55 +01:00
Ludovic Marie 03b52db24e Merge pull request #2096 from kacpherek/fix/2091-recipe-book-tabs
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `AbstractRecipeBookScreen.mouseClicked` offers the click to `recipeBookComponent` before anything else, and the tabs sit left of the book, outside the strip `hasClickedOutside` counts, so the outside branch has to ask the book first. `lan_e2e.py --check recipes` passes in both modes. Fixes #2091. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:23:43 +01:00
Ludovic Marie 27f9120510 Merge pull request #2021 from senjinthedragon/hdr-output
🤖 Agent: Gate check by Ludovic's AI agent (Claude). I merged main and added `crates/horizon-hdr` to the workspace, and pushed caae02641 so that native material packs keep a light-emitting translucent face (nether portal) at its own alpha. HDR stays off by default. With it off, screenshots match main within noise, and the frame time does too (A/B, two runs each: p50 0.326 to 0.329 ms vs 0.317 to 0.345 ms). `section_mesh` passes. HDR on was not tested here (no HDR display on the Mac mini). Thanks @senjinthedragon!
2026-10-26 16:23:13 +01:00
Ludovic MarieandClaude Opus 5.5 68aa70108f Piglin AI: name the hoglin repellent finder for its mob
#2191 and #2192 each add a `find_repellent` to piglin.rs, one for
hoglins and one for piglins; merged together they would collide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:23:05 +01:00
Ludovic Marie 0a699d34f5 Merge pull request #2049 from vlesmentie/fix/remote-ender-dragon
🤖 Agent: Gate check by Ludovic's AI agent (Claude). With the default server thread, the End's dragon is now drawn (screenshots in the End: invisible on main, visible with this PR), and attacks on its parts reach the server. `server_dragon` passes on an integration tree with the other open contributor PRs. Fixes #1530 is right. Thanks @vlesmentie!
2026-10-26 16:22:28 +01:00
Ludovic MarieandClaude Opus 5.5 96afc48ce2 Merge main into fix/1948-merchant-cost-components
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:18:40 +01:00
Ludovic MarieandClaude Opus 5.5 804a288214 Merge main into rdbt/fix-1491-guardian-beams-20261026
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:18:38 +01:00
Ludovic Marie 1851dc87a7 Merge pull request #2086 from kacpherek/fix/thrown-eye-and-pearl
🤖 Agent: Gate check by Ludovic's AI agent (Claude). rdbt's approval counts as the first review (trusted reviewer). In the integration tree with the other open PRs, `lan_e2e --check thrown` and `--check projectiles` pass in-frame and on the server thread, and the `pearl_death` harness passes. Fixes #1826 is right. Thanks @kacpherek!
2026-10-26 16:17:59 +01:00
Ludovic MarieandClaude Opus 5.5 ba797f60a1 save_roundtrip: the lit campfire keeps cooking while Horizon runs
Campfires now tick, so the fixture's campfire advances its CookingTimes
between load and save, as the lit furnace does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:17:25 +01:00
Ludovic Marie 95f9ad56b2 Merge pull request #2270 from ludovic111/fix/mob-ai-oracle-heap
🤖 Agent: verify passed; this only raises the mob_ai oracle's heap (tools only). The oracle passed alone at 4g.
2026-10-26 16:17:23 +01:00
Ludovic MarieandClaude Opus 5.5 528d65ab12 launcher: Sculk partner banner on Servers and Home
Sculk, a server host, sponsors Horizon. Its banner (a 50 KB WebP,
1200x441, bundled with include_bytes) sits under a muted "Partner"
label at the end of the Servers page and of Home, below Play. A click
opens https://my.sculkhosting.com/ref/horizon; hovering lifts it 2 px
and turns the label to ink; it is reachable with Tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:15:57 +01:00
Ludovic Marie f8eb2f086e Merge pull request #1990 from Joansitoh/fix/server-text-caret
🤖 Agent: Gate check by Ludovic's AI agent (Claude). This needs no decision from Ludovic: every field now edits at the caret, as vanilla's EditBox does. rdbt's line-break review: the book page is a MultiLineEditBox in 26.3 (javap), so I pushed MultilineTextField's own word rule. getPreviousWord/getNextWord split words at any Character.isWhitespace and start from the cursor clamped to the last character. The single-line EditBox.getWordPosition keeps counting spaces only. I also merged main in twice (multiplayer.rs labels, stations.rs). Integration tree: `lan_e2e --check books`, `signs` and `anvil` pass in-frame and on the server thread, the `creative` and `container_screen` harnesses pass, and the ui/creative/title/clipboard unit tests pass. Thanks @Joansitoh!
2026-10-26 16:15:00 +01:00
Ludovic MarieandClaude Opus 5.5 2394d6e7dc Pistons: a client's replay starts no piston and plays no sound
A client's copy of a block event (`client_block_event`) ran the server's
neighbour logic: a block it moved could start another piston on the client
alone (`PistonBaseBlock.checkIfExtend` is only called server-side), and the
re-run's `playSound(null, ...)` played the piston and note sounds a second
time next to the server's (`ClientLevel.playSeededSound` plays only for the
local player as the excluded one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:14:21 +01:00
Ludovic Marie 26a2aa2fdc Merge pull request #2047 from ESHAYAT102/fix/cushion-placement
🤖 Agent: Gate check by Ludovic's AI agent (Claude). I checked CushionItem.useOn and Cushion.interact against the 26.3 bytecode with rea_vanilla and pushed three leftovers. (1) An occupied cushion now returns PASS, as Cushion.interact does. (2) A cushion placed in fire burns at once (destroyIfInFire). (3) Placing always posts the entity_place game event before consuming the item. I also merged main into the branch (lan_e2e.py). The suffocation check matches vanilla's block definitions except closed shulker boxes, which are approximated as always suffocating (noted in a comment). Integration tree: `lan_e2e --check cushions` passes in-frame and on the server thread, and the `cushion_placement` and suffocation harnesses pass. Thanks @ESHAYAT102!
2026-10-26 16:14:15 +01:00
Ludovic MarieandClaude Opus 5.5 625c4c559e Merge main into the creative tool mining fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:10:15 +01:00
Ludovic Marie 006006f1d5 Merge pull request #2059 from zhykowo/feat/launcher-proxy
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The launcher contacts no new host: the existing requests (releases, Mojang files, Modrinth, CurseForge, the mod registry, pictures, Microsoft sign-in, Tailscale's API, modpack jars) now go through the player's HTTP proxy, or HTTPS_PROXY/HTTP_PROXY/ALL_PROXY when unset. Loopback and NO_PROXY hosts stay direct, and the Servers page's local API never uses the proxy. Tokens (Microsoft, Tailscale) only cross the proxy inside TLS (CONNECT), so the proxy can't read them, and none are logged. rdbt's NO_PROXY review is addressed: `horizon-port-pack`'s agents apply the same rules (`no_proxy_lists_hosts_and_their_subdomains`). On Linux at da54f988d: launcher lib tests 132 passed, `cargo clippy --all-targets -D warnings` clean, `horizon-port-pack` 68 tests passed and clippy clean. Labelled feature. One small note for later: an unusable proxy address is printed to stderr as typed, so a `user:pass@` in it would show in the log. Thanks @zhykowo!
2026-10-26 16:10:03 +01:00
Ludovic MarieandClaude Opus 5.5 453ee22376 Merge main into fix/server-text-caret
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:01:24 +01:00
Ludovic Marie db4ea8a200 Merge pull request #2038 from levzzz5154/fix/bundle-tooltip
🤖 Agent: Gate check by Ludovic's AI agent (Claude). `tools/bundle_tooltip_e2e.py` passes in-frame. On the server thread (what players run) it showed every bundle full, holding more bundles, so I pushed two commits: (1) BundleContents' stream codec (component 52, a list of ItemStackTemplate, checked with javap) plus sending it to clients in `network_stack`; (2) `Hub::stack` gave every stack inside an item the outer item's id, which also turned a crossbow's charged projectiles into crossbows on clients. It is fixed now: each inner stack looks up its own id. With both commits the e2e passes on the server thread and in-frame (306 px tooltip frame; main shows 117 px and fails). The screenshot shows 32 stone with a half-full bar, as in vanilla. Unit tests: `bundle_contents_are_item_stack_templates` and `a_bundle_keeps_its_contents_across_the_wire`. Thanks @levzzz5154!
2026-10-26 15:58:10 +01:00
Ludovic MarieandClaude Opus 5.5 864ba24c3d Merge main into fix/thorns-hits-attacker
Main's critical-hit change added the same Strike.attacker field; keep
one, then the crit particles before the victim's thorns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:56:14 +01:00
Ludovic Marie d48e56778e Merge pull request #2037 from levzzz5154/fix/loot-enchant-functions
🤖 Agent: Gate check by Ludovic's AI agent (Claude). I checked the loot functions against vanilla 26.3 directly. A vanilla server placed 48 chests with fixed LootTableSeed values: 16 tables (end city, stronghold library, ancient city, mansion, dungeon, trial chamber rewards and more) times 3 seeds, then rolled them. Horizon then opened the same world. Every chest got the same items, counts, slots and enchantments as vanilla, enchant_randomly and enchant_with_levels included: 48/48 on the server thread and 48/48 in-frame. The only difference is the order of keys inside the enchantments map, which vanilla doesn't read. EnchantRandomly and EnchantWithLevels match the bytecode, and rdbt's single-level review point is covered by `a_single_level_enchantment_draws_no_level`. It is also built and run in an integration tree with the other open PRs. Thanks @levzzz5154!
2026-10-26 15:51:13 +01:00
Ludovic MarieandClaude Opus 5.5 7d8b6f3b90 Composter: keep addLayer's doc on composter_add_layer
The extracted composter_extract_produce took the doc comment of the
function below it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:47:37 +01:00
Ludovic MarieandClaude Opus 5.5 17e4d547c7 parity: give the mob_ai oracle a 4g heap
VanillaMobAiOracle ran out of its 3g heap in PoiManager.tick on a busy CI
runner. Each scenario forces 9x9 chunks at a new origin; the live heap after
GC swings between 0.3 and 1 GB over a run, with peaks near 2 GB before GC.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:45:13 +01:00
Kacpherek 545370b023 Key binds: press a shared default key once 2026-10-26 15:41:11 +01:00
Ludovic MarieandClaude Opus 5.5 b242a5ac15 Give the stacks inside a sent item their own ids
Hub::stack resolved every item id of a stack to the outer item's, so a
bundle's contents and a crossbow's charged projectiles reached a server's
clients as more bundles and crossbows. The tooltip then drew a bundle of
bundles, full. Each stack inside now looks its own id up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:40:50 +01:00
Ludovic Marie 8975e2370b Merge pull request #2217 from ludovic111/ci/linux-runner-ubuntu
CI: Linux runner on Ubuntu (playhorizon), tokens without gh, CPU and memory knobs
2026-10-26 15:40:06 +01:00
socksproxandDevin 48811b12f1 Pistons animate their moves on the clients, as Java's do
A remote client (single player's window on its server thread, a LAN
guest, a dedicated server's client) never ran the client side of a
piston's move: the block event reached block_entity_event, which has no
piston ticker, so moving_piston blocks arrived with no moving block
entity and pushed blocks teleported (#1704).

Vanilla's runBlockEvents broadcasts a handled event to the clients, whose
own level re-runs triggerEvent (PistonBaseBlock.moveBlocks) to build the
PistonMovingBlockEntity it then ticks and renders interpolated. Do the
same:

- run_block_events emits a BlockEntityEvent for a handled event; the LAN
  and integrated hubs send it as ClientboundBlockEventPacket, before the
  tick's block changes, as vanilla's broadcast precedes
  ChunkHolder.broadcastChanges.
- The remote client re-runs triggerEvent for pistons, note blocks and
  bells on its own level (dropResources stays server-side, as
  popResource checks !isClientSide), and ticks the resulting moving
  pistons each client tick so they animate and collide between the
  server's states. Block events a client-side move queues run right
  away, as ClientLevel.blockEvent does.

Note blocks and bell rings reach the guests through the same packet,
which they did not get before.

Co-Authored-By: Devin <[email protected]>
2026-10-26 15:37:36 +01:00
Ludovic Marie 9a7f75b75d Merge pull request #1894 from ludovic111/feat/server-mods-game
🤖 Agent: merged main (aec52c85e). pr_ready --quick passes (1308 horizon-client tests, clippy, 507 LOC). The welcome server mod e2e passes 35/35 in every mode: in-frame, server thread, LAN host in-frame and on its thread with a guest, dedicated with a guest. Verify is green; parity now runs on main (parity-scope). Queued.
2026-10-26 15:30:36 +01:00
Ludovic MarieandClaude Opus 5.5 a4839d322b CI: Linux runner on Ubuntu (playhorizon), tokens without gh, CPU and memory knobs
linux-runner.sh takes its packages from apt where there is no Nix, the
registration token from RUNNER_TOKEN (a machine where gh must not be logged
in), and RUNNER_CPUS / RUNNER_MEMORY for a machine that does nothing else.
playhorizon, a partner's 8-thread server, joins parity_hosts.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:28:21 +01:00
Ludovic MarieandClaude Opus 5.5 6bdf5fe368 Sweep: hit mobs within 3 blocks in creative too
Player.doSweepAttack keeps nearby entities with distanceToSqr < 9.0; it
doesn't use the entity interaction range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:26:32 +01:00
Ludovic Marie c5a2259d92 Merge pull request #2022 from mc070309/cursor/sprint-jump-impulse-68db
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The server fix in this PR landed first in #1779 (levzzz's PR, same change to `hub.rs` and `agent.rs`, with the same `lan_bot` test). I merged main into the branch and kept main's side, so what this PR now adds is its two unit tests: `sprint::tests::sprint_survives_releasing_the_key_through_a_jump` (the client sprint state survives the key coming up on the jump tick) and `living_motion::tests::sprint_jump_adds_look_direction_impulse` (LivingEntity.jumpFromGround's 0.2 impulse along the look, and none for a walk). Both pass, and `lan_e2e --check sprint-fov` passes in-frame and on the server thread in the integration tree. Labelled skip-release, because players see no change. Thanks @mc070309 for tracking the cause down and for the tests.
2026-10-26 15:25:12 +01:00
Ludovic Marie 7ec3fd2b4c Merge pull request #2212 from ludovic111/fix/furnace-recipes-used-namespace
🤖 Agent: Merging. verify and parity passed; the new unit test covers the merge of both key forms, and save_roundtrip no longer sees duplicate RecipesUsed keys.
2026-10-26 15:20:16 +01:00
Ludovic MarieandClaude Opus 5.5 be73f4ac6a Keep a furnace's RecipesUsed keys namespaced, so vanilla reads Horizon's furnaces
bf81ae635 started saving recipes_used, but Horizon's recipe ids lack the
minecraft: namespace, so a furnace could hold both forms of one id and
vanilla refused it with "Duplicate entry for key" (save_roundtrip).
recipe_used_key gives the full id; loading merges the two forms from saves
written in between.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:10:58 +01:00
Ludovic Marie 8027cb4176 Merge pull request #2177 from ludovic111/feat/launcher-mod-ratings
🤖 Agent: Merging. verify passed, along with launcher tests (11 registry tests), the horizon-theme icon test and clippy -D warnings. #2173, which it was stacked on, is merged.
2026-10-26 15:05:13 +01:00
Kacpherek f0d2a2ae74 Satisfy clippy 2026-10-26 15:02:43 +01:00
Ludovic Marie 91400aac5b Merge pull request #2173 from ludovic111/feat/mod-index-downloads
🤖 Agent: Merging. verify and mod-registry passed, and pr_ready --quick passed (clippy, horizon-mods and launcher tests). The index's downloads field is optional, so the schema doesn't change.
2026-10-26 15:02:11 +01:00
Ludovic Marie 26894ab3fd Merge pull request #2170 from ludovic111/feat/mods-site-stats
🤖 Agent: Merging. verify passed and the website tests (44) pass. Ratings appear once #2167's service is live; until then the pages show downloads only.
2026-10-26 14:59:28 +01:00
Ludovic Marie ae17e26009 Merge pull request #2140 from ludovic111/feat/quiet-commands-server
🤖 Agent: verify and parity pass (109 harnesses affected; parity green). The dedicated-server e2e (tools/quiet_commands_e2e.py) passes 7/7. Queued. #2171 (the mod API, stacked on it) follows once this lands.
2026-10-26 14:57:05 +01:00
Ludovic MarieandClaude Opus 5.5 ae3b844d74 Merge main into fix/server-text-caret
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:56:10 +01:00
Ludovic Marie 03c8594494 Merge pull request #2061 from lazy-importer/fix/2034-bossbar-vanish-reconfigure
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @lazy-importer!
2026-10-26 14:53:47 +01:00
Ludovic MarieandClaude Opus 5.5 9540734e29 Merge main into fix/cushion-placement
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:50:57 +01:00
Ludovic Marie 601df6c22d Merge pull request #2052 from levzzz5154/fix/chest-shift-click
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @levzzz5154!
2026-10-26 14:50:55 +01:00
Ludovic MarieandClaude Opus 5.5 3dd17ad7d7 Launcher: registry mods show their 1-5 star rating
Horizon results and project pages show a project's rating from the
registry service (GET playhorizon.dev/api/mods/ratings, fetched with the
results, kept ten minutes): five pixel stars, the mean and how many, or
"No ratings yet". Nothing shows when the service doesn't answer.

The launcher has no GitHub account, so a project's page offers "Sign in
with GitHub to rate": it opens the site's sign-in, which comes back to the
project's page and its stars (one rating per GitHub account).

HORIZON_MOD_RATINGS (a file or https:// address) points a test registry at
its own ratings. The star icon is drawn for Horizon on the icons' grid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:50:40 +01:00
Ludovic Marie 394ce4928c Merge pull request #2044 from Domanffe/docs/roadmap-help-wanted-links
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:47:47 +01:00
Ludovic MarieandClaude Opus 5.5 0e4965845a Book pages: move by words as MultilineTextField does
BookEditScreen's page is a MultiLineEditBox. Its getPreviousWord and
getNextWord split words at any whitespace (Character.isWhitespace, line breaks
included) and start from the cursor clamped to the last character. The
single-line EditBox.getWordPosition keeps looking at spaces only, so the line
break rule moves to the multiline box alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:47:17 +01:00
Ludovic Marie 8fa967ac07 Merge remote-tracking branch 'origin/main' into feat/server-mods-game 2026-10-26 14:45:29 +01:00
Ludovic Marie aec52c85e9 Merge pull request #2041 from Domanffe/perf/uniform-values-reuse
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:44:52 +01:00
Ludovic MarieandClaude Opus 5.5 a0a170064e Merge main into sprint-jump-impulse
Main's #1779 already keeps the sprint through PlayerInput (hub.rs, agent.rs)
and has the same lan_bot test, so this keeps main's side: what the branch adds
is the two unit tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:44:08 +01:00
Ludovic MarieandClaude Opus 5.5 caae026416 HDR: keep native material packs' vertex tint opaque
The mesher now keeps a face's light emission in its colour's alpha byte. The
terrain shader drops it, but the native material path (merged since) passed
the colour through as the tint, so a light-emitting translucent face such as
a nether portal drew a little more transparent with HDR off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:42:00 +01:00
Ludovic MarieandClaude Opus 5.5 92ed365a19 Merge main into hdr-output
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:41:59 +01:00
Ludovic Marie c65338b3cb Merge pull request #2029 from Domanffe/perf/subtitle-width-once
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:41:27 +01:00
rdbtCVSandCodex GPT-6.1 Sol 6e0a49029f Keep block-breaking cracks aligned between stages
Restore integer atlas origins and sample local texel centers before nearest filtering. Cover the packed stage origins with a GPU sampling regression.

Fixes #1882.

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-10-26 13:39:51 +00:00
Ludovic MarieandClaude Opus 5.5 590ca775c2 Mod registry: download counts in the index and the launcher's browser
The registry's publish job (ludovic111/horizon-mods#7) writes each
project's `downloads` into index.json, summed from GitHub's counts of its
release assets every hour. `index::Project` reads it (absent: 0, never
written when 0, so older readers and the schema are unchanged).

The launcher's Horizon results show "12.3k downloads" like Modrinth's, and
list the most downloaded first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:37:12 +01:00
Ludovic Marie 38bea19137 Merge pull request #2167 from ludovic111/feat/mod-ratings-api
🤖 Agent: Merging. The verify and mod-registry checks (fmt, clippy -D warnings, tests) passed. The registry-data volume is already mounted at /data.
2026-10-26 14:36:12 +01:00
Ludovic MarieandClaude Opus 5.5 282556870f Cushions: pass when occupied, burn when placed in fire, entity_place
Checked against the 26.3 bytecode (tools/rea_vanilla.py):
- Cushion.interact passes (PASS) when the cushion already has a rider;
  CONSUME is only for a startRiding that fails.
- CushionItem.useOn calls destroyIfInFire right after addFreshEntity, and
  posts GameEvent.ENTITY_PLACE from the player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:34:55 +01:00
Ludovic Marie a03940e0a8 Merge pull request #2166 from ludovic111/ci/parity-main-flake-green
🤖 Agent: parity-main counts a failure that passes again at HEAD as a pass; tests assert each exit code, YAML parses.
2026-10-26 14:33:41 +01:00
Ludovic Marie 4f70efcfa9 Merge pull request #2027 from Domanffe/perf/sign-first-line
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:33:10 +01:00
Kacpherek 091a98656e Dragon: find healing crystals in its grown box and cap healing at max
The search now uses the dragon's 16 by 8 box grown by 32 blocks like
EnderDragon.checkCrystals, and healing no longer goes above max health.
2026-10-26 14:32:46 +01:00
Kacpherek fd1d857f1b Hoglins keep away from warped fungus, portals and respawn anchors
Hoglins now sense the nearest block from the hoglin repellents tag within 8 blocks sideways and 4 up or down. While one is near they become passive for 200 ticks (the attack target is dropped and no new one is picked), walk away from it, and avoid picking random walk spots within 8 blocks of it. Being hit ends the passive state.
2026-10-26 14:32:02 +01:00
Ludovic MarieandClaude Opus 5.5 a83d6f33be Campfire: a full campfire's use does not swing the arm
CampfireBlock.useItemOn returns SUCCESS_SERVER (a swing) only when
placeFood took the item, and CONSUME (no swing) when all four slots are
taken.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:31:50 +01:00
Kacpherek 8bfa7a5b7b Loot: suspicious stew keeps the effect set_stew_effect rolled
The function drew an effect and a duration but dropped them. The stack
now carries the effect and it is stored in suspicious_stew_effects.
2026-10-26 14:31:03 +01:00
rdbtCVSandCodex b1136ac287 Keep forced server levels ticking without players
Fixes #1525. Respect forced tickets when scheduling levels and bound playerless integrated-server ticks to their ticket sources.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-10-26 13:30:54 +00:00
Kacpherek 199178907c Shovels put out a lit campfire 2026-10-26 14:30:49 +01:00
Kacpherek 7abb092089 Wire fire burnout, difficulty and spread radius into the fire tick 2026-10-26 14:30:34 +01:00
Kacpherek d8fcebdf71 Match vanilla difficulty and survival rules for lightning fire 2026-10-26 14:30:31 +01:00
Ludovic MarieandClaude Opus 5.5 fb86430813 mods: command.run-quiet (API level 3), a batch of commands with nothing in the chat
A mod hands `run-quiet` a list of lines (at most 256 a call). In single
player they run as the player typing them would, with the player's source
made silent; on a Horizon server (it announces horizon:quiet_commands) they
go as one payload that the server runs the same way; on any other server
they fall back to chat commands, whose feedback shows. command.run is
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:30:23 +01:00
Ludovic Marie 9c26db87e6 Merge pull request #2024 from levzzz5154/fix/remote-cooldown-tick
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @levzzz5154!
2026-10-26 14:30:19 +01:00
Ludovic MarieandClaude Opus 5.5 f49d5d91e6 Site: downloads and 1-5 star ratings on /mods
- The list shows each project's rating (stars, mean and count) and its
  downloads ("12.3k downloads"), ordered by downloads by default; an Order
  menu also sorts by rating or name (?sort= too).
- A project's page shows the same, then a rating control: five stars when
  signed in (the account's own rating lit), "Sign in with GitHub to rate"
  (coming back to the page) when not, a note for its owners.
- Ratings come from the registry service's GET /api/mods/ratings; a rating
  is POSTed with the session's CSRF token. Until the service answers, the
  pages show downloads only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:29:31 +01:00
Ludovic Marie ec7a2e22a7 Merge pull request #2012 from Domanffe/perf/poses-layer-reuse
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:27:29 +01:00
Kacpherek 71981a95a2 Weather: the rain and thunder fade runs even with the weather cycle off 2026-10-26 14:26:34 +01:00
Kacpherek 19c7a4a008 Slime block slows a walking player (SlimeBlock.stepOn)
While standing on slime with a vertical speed under 0.1 and not sneaking, the
horizontal speed is scaled by 0.4 + |vertical speed| * 0.2 every tick.
2026-10-26 14:25:37 +01:00
Kacpherek 23d546c6d5 Sleep: creative skips the monster check, bed reach and obstruction match vanilla, morning follows the time and weather rules 2026-10-26 14:24:20 +01:00
Ludovic MarieandClaude Opus 5.5 3caf8f7014 lan_e2e portals: close the credits on the host's first End exit
The first exit from the End now rolls the poem and credits, and the host
goes home once they close (PERFORM_RESPAWN); the check asserts they show.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:24:09 +01:00
Ludovic MarieandClaude Opus 5.5 8d6d1f26a4 Send a bundle's contents to a server's clients
BundleContents had no stream codec here: the host never sent what a bundle
holds, so on the server thread, LAN and a dedicated server the new tooltip
showed every bundle empty, and a vanilla server's bundle made its packet
undecodable. Its STREAM_CODEC is a list of ItemStackTemplate, the same as
ChargedProjectiles' (component 52).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:20:25 +01:00
Ludovic Marie b78daab11c Merge pull request #2081 from kacpherek/fix/performance-keeps-animated-textures
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @kacpherek!
2026-10-26 14:18:21 +01:00
zhykowoandWorkBuddy AI da54f988d4 Proxy: one copy of the rules, none of it per request
Co-Authored-By: WorkBuddy AI (DeepSeek-V4.1-Flash) <[email protected]>
2026-10-26 21:17:28 +08:00
Ludovic MarieandClaude Opus 5.5 455817204f Mod registry: 1-5 star ratings, one per GitHub account
The registry service keeps players' ratings of published projects in one
SQLite file on a volume (REGISTRY_DB, /data on Railway):
- GET /api/mods/ratings: every rated project's average and count, cached a
  minute; never who rated.
- GET /api/mods/{id}/rating: the signed-in account's own rating.
- POST /api/mods/{id}/rating: sets or changes it, checked like a submission
  (origin, session, CSRF token), 30 per account and 600 in all per hour, only
  for projects on the registry's main, never by their owners.
- The sign-in takes ?back=/mods/<id> to come back to a project's page.

The image hands the volume to nobody before running as nobody.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:15:46 +01:00
Ludovic Marie aadb8b6727 Merge pull request #2079 from nirajkhetre/fix/enderman-death-message
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @nirajkhetre!
2026-10-26 14:14:44 +01:00
Ludovic Marie 7b94de404e Merge pull request #2010 from Domanffe/perf/skip-lookup-once
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:13:29 +01:00
Kacpherek 7c16e83fdf Key binds: a key bound to several mappings drives all of them 2026-10-26 14:13:03 +01:00
Ludovic MarieandClaude Opus 5.5 9e76f8c4f0 parity-main: a failure that passes again at HEAD counts as a pass
Twice today (runs 40301239508, 40311712302) mob_ai failed in parity-main, passed when bisect ran it again at
the same HEAD, and the run still ended red, so no game release could follow main for three hours. The
harness step now continues on error and the bisect step gives the verdict: 0 when every failed harness
passes again at HEAD, 1 when they fail at the base too or a merge is named.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:12:04 +01:00
Ludovic Marie 8087d8bf58 Merge pull request #2009 from vlesmentie/fix/villager-new-profession-trades
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @vlesmentie!
2026-10-26 14:10:43 +01:00
Ludovic Marie 659e6d7896 Merge pull request #1993 from Domanffe/perf/no-double-cracks-and-ticker-copy
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:09:57 +01:00
Ludovic MarieandClaude Opus 5.5 ae63b23dd6 lan_e2e guardian-beams: the host reads the guardian's own level
horizon-ctl's mobs answers from the server's level when the host runs on
its server thread, where the targeted player is Player, as in-frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:08:15 +01:00
Ludovic Marie a3f5615ec7 Merge pull request #2108 from ludovic111/perf/worldgen-data-parallel-load
🤖 Agent: pr_ready passed (horizon-world and client tests, affected harnesses), lan_e2e passed in-frame and on the server thread. GeneratorData::load on the ThinkPad 0.64 → 0.45 s warm. Queued for merge.
2026-10-26 14:07:33 +01:00
Ludovic Marie b244c5d26e Merge pull request #2042 from Domanffe/perf/tooltip-component-compare
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:06:20 +01:00
Ludovic MarieandClaude Opus 5.5 d983ef89b4 Keep item_name as its component: translate it when drawn
The decoded plain string replaced the component on save and on the way to
LAN guests, so a translatable name (explorer maps' filled_map.*, the
ominous banner in tests without a language) came back as its key, and
spill's ominous banner test failed. The component now stays in the patch
as loaded and is resolved in the current language when a name is drawn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:05:17 +01:00
Ludovic Marie 9af6d0be5c Merge pull request #2118 from ludovic111/fix/localize-server-screens
🤖 Agent: contrib-gate-17 verified this head (integration tree build, clippy, tests, parity; details in the PR comment). Re-opened from kohad1ck's PR; thanks to kohad1ck.
2026-10-26 14:03:06 +01:00
Ludovic Marie 034c0f0228 Merge pull request #2117 from ludovic111/fix/font-glyphs-atlas-rebuild
🤖 Agent: contrib-gate-17 verified this head (integration tree build, clippy, tests, parity; details in the PR comment). Re-opened from kohad1ck's PR; thanks to kohad1ck.
2026-10-26 14:00:01 +01:00
Ludovic Marie dec94b6c61 Merge pull request #2053 from rdbtCVS/rdbtcvs/fix-2019-recipe-screen-cleanup
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:59:13 +01:00
Ludovic Marie c1e7a86622 Merge pull request #2020 from levzzz5154/fix/guest-chat-visibility
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @levzzz5154!
2026-10-26 13:58:21 +01:00
Ludovic Marie 6ee319c607 Merge pull request #2018 from rdbtCVS/fix/peaceful-mob-despawn-1942
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:55:47 +01:00
Ludovic Marie fc5cd8060d Merge pull request #2007 from rdbtCVS/fix/1962-passenger-animation
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:55:12 +01:00
Ludovic Marie 0555120244 Merge pull request #2048 from rdbtCVS/fix/1490-sonic-boom-delivery
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:52:47 +01:00
Ludovic Marie 9feb4da17c Merge pull request #2003 from rdbtCVS/fix/bat-wing-animation-2000
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:51:59 +01:00
Kacpherek 688d86db7c Villagers look for a child's bed in a full 48 block sphere and only take one they can reach
The search only covered 8 blocks up and down. It now uses the POI index over
the whole sphere, and a bed only counts when the villager has a path to it,
as VillagerMakeLove.takeVacantBed does.
2026-10-26 13:51:58 +01:00
Kacpherek 8178ec3dae Let a fire charge light a creeper
A creeper is now lit by any item in the creeper_igniters tag, not only
flint and steel. A fire charge is used up and plays its own sound; flint
and steel still loses durability.
2026-10-26 13:51:46 +01:00
Ludovic Marie f8f49a033f Merge pull request #1992 from DanielGodriNeto/fix/benchmark-wait-loaded
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @DanielGodriNeto!
2026-10-26 13:51:04 +01:00
Kacpherek 02e5c728c3 Pick the skeleton bow interval by difficulty
Skeletons now shoot every 20 ticks on Hard and every 40 otherwise
(bogged and parched: 50 and 70) instead of always using the normal
interval.
2026-10-26 13:49:57 +01:00
Kacpherek 5b0ae25ff8 Make thorns hurt the entity that hit the player
Thorns picked the nearest living mob in the world. They now hurt the mob
that caused the hit (or its projectile's owner), or the attacking player
in a player-versus-player hit.
2026-10-26 13:49:39 +01:00
Ludovic Marie 245206203d Merge pull request #1999 from rdbtCVS/t3/fix-critical-hit-particles
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:48:23 +01:00
Kacpherek 48c0f39e96 Farmers make bread and compost seeds when they work at a composter
A farmer working at its composter turns wheat into bread (three wheat for one
loaf, up to three loaves, while it holds at most 36 bread) and puts spare wheat
and beetroot seeds into the composter, emptying a full one first, as
WorkAtComposter does. Other professions are unchanged.
2026-10-26 13:48:15 +01:00
Kacpherek 02f222728d Respect mobGriefing for mob explosions and endermen
Mob explosions (creepers, ghast fireballs, wither skulls and the wither
spawn blast) now keep blocks when mobGriefing is off, and follow the
mob_explosion_drop_decay rule when it is on. Endermen no longer pick up
or put down blocks with mobGriefing off.
2026-10-26 13:48:13 +01:00
Kacpherek 4d0116762c Apply Unbreaking to thrown tridents and stop Riptide while riding
Throwing a trident now wears it through the same durability path as Riptide, so Unbreaking applies, and it still never breaks the trident. Riptide no longer launches a player who is riding something.
2026-10-26 13:48:08 +01:00
Kacpherek 48c289f775 Make Channeling summon lightning when a trident hits
A thrown trident with Channeling now calls down a lightning bolt in a thunderstorm, both on a mob it hurts (if the mob can see the sky) and on a lightning rod it hits (if the rod can see the sky), with the thunder sound.
2026-10-26 13:47:53 +01:00
Kacpherek 65bebdfc49 Make a sword sweep hurt the mobs next to the target
The sweep only drew the particle and played the sound. Mobs in the sweep
box now take the sweep damage (with Sweeping Edge) and 0.4 knockback.
2026-10-26 13:47:18 +01:00
Ludovic Marie 1da8c57e72 Merge pull request #1987 from socksprox/fix/world-text-parity
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @socksprox!
2026-10-26 13:47:04 +01:00
Ludovic Marie 11635c3bdf Merge pull request #2085 from kacpherek/fix/2084-enderman-teleport
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @kacpherek!
2026-10-26 13:44:44 +01:00
Ludovic Marie ba6ca28577 Merge pull request #2082 from kacpherek/fix/fire-extinguish-no-break-particles
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @kacpherek!
2026-10-26 13:44:08 +01:00
Kacpherek aec3b7e262 Scale mob damage to players with the difficulty
Damage types marked when_caused_by_living_non_player now scale when a mob
(or the owner of its projectile, or an exploding mob) caused the hit.
2026-10-26 13:43:51 +01:00
Ludovic Marie 612d5f33ea Merge pull request #2031 from Domanffe/perf/debug-line-width-once
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 13:43:34 +01:00
Ludovic Marie f6b3e9935d Merge pull request #2011 from Domanffe/perf/vehicle-set-once
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 13:42:53 +01:00
Ludovic Marie 749a8a2a64 Merge pull request #1986 from some-du6e/fix/prepare-atlauncher
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @some-du6e!
2026-10-26 13:42:23 +01:00
Ludovic Marie 175419c6e7 Merge pull request #2139 from ludovic111/fix/registry-dockerfile-shader-api
🤖 Agent: the registry image copies horizon-shader-api (every Railway build failed without it); the same context builds locally with --locked.
2026-10-26 13:42:01 +01:00
Ludovic Marie aea0df9770 Merge pull request #2122 from ludovic111/feat/native-material-stage
🤖 Agent: verified: pr_ready --quick green at this head, 33/33 affected harnesses pass locally, CI verify green, A/B shows no frame cost without a pack. A Horizon shader pack's material stage now colours terrain in the game.
2026-10-26 13:38:03 +01:00
Ludovic MarieandClaude Opus 5.5 145c4c877b Quiet commands: the server runs a mod's batch of commands with no chat
A Horizon client sends a client mod's batch of command lines as one
`horizon:quiet_commands` payload, which Horizon's server announces only to
Horizon clients. Each line runs exactly as the player typing it would
(the same checks, their own permission level), with vanilla's suppressed
output: no feedback and no failure in anyone's chat. Batches wait in a
queue: at most 64 lines run per player per tick, and at most 4096 wait.
Each finished batch is one line in the server's log.

tools/quiet_commands_e2e.py checks it on the dedicated server: a non-op's
quiet setblock is refused, an op's runs, and nothing reaches the chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:37:04 +01:00
Ludovic MarieandClaude Opus 5.5 4b3af580d9 mob_ai: drop the Peaceful type scenarios that diverge for other reasons
A slime made without finalizeSpawn keeps 20 health where the probe's has 1,
the probe doesn't report a piglin's brain, and a NoAI bee's flower cooldown
runs one tick apart; none of that is the Peaceful despawn rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:34:52 +01:00
Ludovic MarieandClaude Opus 5.5 a34cad379f mod registry image: copy horizon-shader-api, which horizon-mods now depends on
Every Railway build of the registry has failed since horizon-mods gained the optional horizon-shader-api
dependency ("no matching package named horizon-shader-api"). Built the same context locally: OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:32:27 +01:00
Ludovic Marie a27579dd72 Merge remote-tracking branch 'origin/main' into feat/server-mods-game
# Conflicts:
#	crates/horizon-client/src/game/mods.rs
2026-10-26 13:29:17 +01:00
Kacpherek 23d089aa7c Match vanilla animal breeding rules: rabbits breed, damage ends love, horse and wolf checks, breeding XP rule
Rabbits get BreedGoal and TemptGoal with rabbit food. Damage to an animal
resets love. Horses, donkeys and llamas need canParent (not ridden, tame,
adult, full health) for both parents, a sitting wolf is no partner, and the
breeding experience orb needs the mob_drops game rule.
2026-10-26 13:25:30 +01:00
Ludovic Marie 23c748b2f3 Merge pull request #2127 from ludovic111/fix/ops-feedback-clock
🤖 Agent: server_ops_feedback on the server clock (stall-injector proof by ci-flakes-1); CI parity runs the harness.
2026-10-26 13:17:23 +01:00
Ludovic MarieandClaude Opus 5.5 ddbf2ce211 Trading probe: compare generated offers only
The probe's output is what the harness compares with vanilla; the codec
round trip lives in the unit tests, and its unwrap on a cost whose
components name an unmapped registry entry could abort the probe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:16:22 +01:00
Ludovic MarieandClaude Opus 5.5 e0fea857ce Remote dragon fireball: apply inertia before the move
AbstractHurtingProjectile.tick calls applyInertia first and then moves by
the new delta movement; Vec3.normalize leaves a near-zero vector at zero.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:15:16 +01:00
Ludovic Marie 5a9e07301b Merge pull request #2113 from ludovic111/feat/mod-raycast
🤖 Agent: game.raycast, mod API level 2. CI is green on c23b4e8e4. In game, in-frame and on the server thread, the ui guest's /raycast passes all four checks: glass at 2 blocks, nothing at 1, gold after /setblock, the sky is no hit. The ported Orbital Laser aims with it and fires in both modes. Queued.
2026-10-26 13:13:29 +01:00
Ludovic MarieandClaude Opus 5.5 e2e591858e Fire smoke: a waterlogged neighbour doesn't burn
FireBlock.getIgniteOdds returns 0 for a waterlogged state, so canBurn is
false there and the smoke takes the other branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:11:49 +01:00
Ludovic MarieandClaude Opus 5.5 6a7951e150 mob_ai oracle: create a Peaceful scenario's hostile mobs past canSpawn
EntityType.create returns null for a type that isn't allowedInPeaceful once
the difficulty is Peaceful, so the peaceful_* scenarios threw in the oracle.
Their mobs stand for ones already in the world when the difficulty changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:09:28 +01:00
Ludovic MarieandClaude Opus 5.5 e944bd2a0e Merge main (#2114 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:09:24 +01:00
Alexander Krylov 51425c9c22 Merge remote-tracking branch 'origin/main' into docs/roadmap-help-wanted-links 2026-10-26 15:07:19 +03:00
Alexander Krylov dfefb6d46a Merge remote-tracking branch 'origin/main' into perf/tooltip-component-compare 2026-10-26 15:07:16 +03:00
Alexander Krylov 02f147f137 Merge remote-tracking branch 'origin/main' into perf/uniform-values-reuse 2026-10-26 15:07:14 +03:00
Alexander Krylov 54e4274b87 Merge remote-tracking branch 'origin/main' into perf/debug-line-width-once 2026-10-26 15:07:11 +03:00
Alexander Krylov c2d137ed06 Merge remote-tracking branch 'origin/main' into perf/subtitle-width-once 2026-10-26 15:07:08 +03:00
Alexander Krylov 81e4272126 Merge remote-tracking branch 'origin/main' into perf/sign-first-line 2026-10-26 15:07:05 +03:00
Alexander Krylov 7e047c409c Merge remote-tracking branch 'origin/main' into perf/poses-layer-reuse 2026-10-26 15:07:01 +03:00
Alexander Krylov d3a1ce1d7a Merge remote-tracking branch 'origin/main' into perf/vehicle-set-once 2026-10-26 15:06:56 +03:00
Alexander Krylov 11eb74b0b6 Merge remote-tracking branch 'origin/main' into perf/skip-lookup-once 2026-10-26 15:06:45 +03:00
Alexander Krylov 68f23b9916 Merge remote-tracking branch 'origin/main' into perf/no-double-cracks-and-ticker-copy 2026-10-26 15:06:38 +03:00
Ludovic Marie 43929f3ff8 Merge pull request #2090 from ludovic111/perf/teleport-drop-off-frame
🤖 Agent: pr_ready passed (79 harnesses, 1299 client tests), lan_e2e passed in-frame and on the server thread (portals, levels, distance). Mass-drop frame at RD 32: 26-40 → 7-8.5 ms. Queued for merge.
2026-10-26 13:02:03 +01:00
Ludovic Marie f362f15212 Merge pull request #2114 from ludovic111/fix/harness-await-flakes
🤖 Agent: harness-only change (skip-release). The old harnesses fail under server stalls, the new ones pass; 20 affected harnesses pass locally, and the two changed ones rerun after merging main.
2026-10-26 12:53:58 +01:00
Ludovic Marie 68fa7714bb Merge remote-tracking branch 'origin/main' into feat/native-material-stage
# Conflicts:
#	docs/shaders.md
2026-10-26 12:48:15 +01:00
Ludovic MarieandClaude Sonnet 5.5 f0649da024 parity: server_ops_feedback ends steps on the server's clock, not a 2 s sleep
Built on the Client of the harness-await branch (#2114). Under 2.5 s server stalls the old harness fails (a command's
feedback filed under the next step) and the new one passes 3 of 3.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 12:46:11 +01:00
Ludovic Marie 4fbb0cde70 Merge pull request #1837 from ludovic111/feat/server-mod-test-scripts
🤖 Agent: The test scripts for server mods. CI is green on d64bd7c52, and the PR is now based on main after #1833 merged. I ran pr_ready --quick on this head merged with main (b8027d4b1): fmt, clippy, the horizon-mods and welcome tests (48) and the launcher tests (132) all pass. The only affected harness is lan_integration, which runs in CI. Queued.
2026-10-26 12:43:44 +01:00
Ludovic MarieandClaude Opus 5.5 fcbadbaa30 Merge main into rdbt/fix-1478-firework-bursts-20261026
fireworks.rs keeps main's rocket blast damage next to the client burst;
lan_e2e.py takes main's CHECKS and server-thread default plus 'fireworks'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:37:15 +01:00
kohad1ck b279758c7c Localization: server screens and the new world name follow the language
The Add/Edit Server, Direct Connection and Friends screens and Create New\nWorld built their titles, field labels, defaults and buttons from English\nliterals, so every non-English language showed English there. Route them\nthrough the current language with vanilla's keys:\n\n- ManageServerScreen: manageServer.add.title/edit.title, enterName, enterIp,\n  selectServer.defaultName, gui.done and gui.cancel.\n- DirectJoinServerScreen: manageServer.enterIp (the addServer.enterIp key is\n  not in 26.3), selectServer.select and gui.cancel.\n- WorldCreationUiState: selectWorld.newWorld.\n- Select World: selectWorld.title and selectWorld.versionUnknown.\n- DisconnectedScreen: disconnect.lost and gui.toMenu.\n\nA language missing a key keeps the English fallback, so en_us is unchanged.\n\nCo-Authored-By: ZCode Agent <[email protected]>
2026-10-26 12:35:25 +01:00
kohad1ck 5ec21ff5f1 Font: menus keep their run-time glyphs when the renderer rebuilds its atlas
Menu text drawn with characters the bitmap sheets lack (Chinese, Japanese,
Korean) is rasterized into a reserve of the shared atlas at run time, and the
font caches the atlas rect it got. The loading screen hands over to the game
about two seconds after the title shows by building a new renderer, whose
atlas texture is made from HudAtlas.pixels alone: those never hold a
run-time glyph, while the cache still answers with the old rects, so every
such character already drawn went blank. Latin text, straight from the
sheets, stayed. build_renderer and loading_renderer now forget the cache
before the new texture exists, so the characters that are drawn again are
placed into it, as ui_theme clears its own cache when the atlas changes.

Fixes #1824, fixes #2040.
2026-10-26 12:35:21 +01:00
Ludovic MarieandClaude Opus 5.5 db5c10ba20 Merchant offers: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:35:09 +01:00
Ludovic MarieandClaude Opus 5.5 f4b4f944e3 Capture test: the fixture's Game resets its inventory clicks too
capture(true) now calls reset_inventory_clicks; the fixture compiles the
method body against a stub Game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:33:08 +01:00
Ludovic Marie 6ff335eacf Merge pull request #2112 from ludovic111/feat/native-material-plumbing
🤖 Agent: verified: pr_ready --quick green, 30/30 affected harnesses pass locally, CI verify green. Internal: one WGSL prefixer for the shader-pack stages (no visible change).
2026-10-26 12:32:07 +01:00
c23b4e8e41 Mod API: game.raycast, the block the player looks at (level 2)
`game.raycast(distance)` returns the first block along the player's view
(fluids pass), up to 1024 blocks: through the loaded chunks as the
crosshair's ray goes, then across the distant terrain's ground heights
(`Lod::ground`, from Stink-O's fork). The game casts it only while a running
mod imports the function. It is API level 2, so older Horizons refuse a mod
that calls it with "update Horizon".

Co-authored-by: Stink-O <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:27:06 +01:00
Ludovic Marie b8027d4b19 Merge pull request #1765 from ludovic111/feat/lithostitched-processors
Lithostitched's processors as the game's own types; replace the old Lithostitched conversion
2026-10-26 12:24:09 +01:00
Ludovic Marie 4d98e545eb Merge pull request #2098 from ludovic111/feat/launcher-mod-api-level
🤖 Agent: The launcher half of the mod API levels. CI is green on c8464c6bf, and the PR is now based on main after #2080 merged. Launcher tests pass, the new browser test included. The level mark is read from a real fast build of the game. Queued.
2026-10-26 12:22:55 +01:00
zhykowoandWorkBuddy AI a9453b7c93 Modpack downloads honour NO_PROXY too
The jars an import fetches went through the proxy whatever the host, so a host
that only answers directly failed there while the launcher's own requests to it
went direct. net::agent now takes the URL and applies the same NO_PROXY and
loopback rules the launcher's own traffic gets.

Co-Authored-By: WorkBuddy AI (DeepSeek-V4.1-Flash) <[email protected]>
2026-10-26 19:21:09 +08:00
Eshayat Al-WasiuandCodex b0d378fe99 Apply multiplayer motion packets to mobs for squid animation
Co-Authored-By: Codex <[email protected]>
2026-10-26 17:16:07 +06:00
Ludovic MarieandClaude Opus 5.5 fe19efb917 Predicted break: test BaseFireBlock by its class, as the server does
The world side checks Class::BaseFire; the tag is a data-pack list that can
differ from the class.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:14:59 +01:00
Kacpherek f5ef003bad Server players see the dragon's fireballs in flight
Dragon fireballs were a server-side list like the breath clouds, so a server's players never saw them. Each is now tracked as a minecraft:dragon_fireball; the client keeps it in the same list, flies it on as AbstractHurtingProjectile.tick does, snaps it to the server's moves and draws it with its dragon_breath trail.
2026-10-26 12:09:05 +01:00
Ludovic Marie e34cb7c021 Merge pull request #2080 from ludovic111/feat/mod-api-level
🤖 Agent: Mod API levels. CI is green on b12c89650. Locally: horizon-mods lib 31, api_reference 4 and sandbox 26 pass. A dummy component that calls a function from a newer level is refused with "update Horizon". Level 1 is render.set-minimap and the players.* functions; level 0 matches 0.2.67 to 0.2.69. Queued.
2026-10-26 12:06:13 +01:00
Kacpherek 9ef7a6fa25 Server players see the dragon's breath clouds
The breath clouds are kept as a list, not entities, so the server thread drew their particles into its own copy and players never saw them. Each cloud is now tracked as an area_effect_cloud (radius, waiting, particle), and a server's client draws it as AreaEffectCloud.clientTick does.
2026-10-26 12:05:16 +01:00
Ludovic Marie 5d04a5bee8 Merge remote-tracking branch 'origin/main' into fix/harness-await-flakes 2026-10-26 12:04:32 +01:00
Ludovic Marie b66861c721 Merge pull request #2106 from ludovic111/ci/nightly-schedule-guard
🤖 Agent: nightly schedule guard by Paris offset, lan_integration timeout; YAML checked.
2026-10-26 11:59:55 +01:00
Kacpherek 858eeba1b8 Resource packs: hand the window surface to the reloaded renderer instead of creating a new one
Applying packs dropped the window surface and created a new one for the renderer built on the same device. On Wayland with Mesa (RADV) the new surface came back lost (ERROR_SURFACE_LOST_KHR), the new renderer failed, and restoring the old one failed too, which crashed the game.
2026-10-26 11:59:33 +01:00
Ludovic Marie 27f63ce50c Merge pull request #1975 from kacpherek/fix/1523-tp-during-arrival
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:55:20 +01:00
Ludovic Marie 8682fb845d Merge pull request #1971 from kacpherek/fix/1477-firework-player-damage
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:52:34 +01:00
Ludovic MarieandClaude Opus 5.5 189b2c6b3c nightly: pick the 03:07 Paris schedule by offset, not by the clock at start; room for lan_integration
GitHub started both 10-26 schedules over an hour late, so the hour check skipped both and no audit ran.
lan_integration takes ~2,400 s under load, past the 1,800 s per-harness limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:52:09 +01:00
Ludovic Marie c048292f3f Merge pull request #1909 from rdbtCVS/t3/server-thread-integration-clean
🤖 Agent: gate verified on e3e08911b (main merged). fmt, clippy -D warnings and horizon-client tests pass. lan_e2e with the server-thread host: 131/133, and cursor/falling pass on rerun in both modes. 14 in-frame checks pass. agent_control_e2e: only manage fails, and it fails the same way on main. 82 of the 83 affected harnesses pass. lan_integration flakes under load and is now named-only (nightly). Queued.
2026-10-26 11:47:59 +01:00
Ludovic MarieandClaude Opus 5.5 ee83d8f247 Chat test: set the abilities through set_abilities (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:47:27 +01:00
Ludovic Marie c8464c6bff Merge branch 'feat/mod-api-level' into feat/launcher-mod-api-level 2026-10-26 11:46:43 +01:00
Ludovic MarieandClaude Opus 5.5 b12c89650e Mod API levels: server mods' players functions are level 1; the registry's lockfile
Main gained players.online, tell and broadcast (#1833) after 0.2.69, so
they take level 1 with render.set-minimap. horizon-mod-registry's own
Cargo.lock picks up wasmparser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:46:27 +01:00
Kacpherek 990786901a Let empty boats and minecarts enter Nether portals 2026-10-26 11:46:16 +01:00
Kacpherek 1f51e3d18c Server players: chest minecart and chest boat menus stay open
A vehicle's items are kept under a placeholder key far outside the world, and the guest container tick checked the player's reach to that key as a block, so the menu closed the tick it opened. Vanilla checks reach to the vehicle (ContainerEntity.isChestVehicleStillValid).
2026-10-26 11:46:10 +01:00
Kacpherek 49b167ce22 Item frames: shade the frame model by face and light glow frames from 5 2026-10-26 11:45:56 +01:00
Ludovic Marie 0a9a7c0d06 Merge remote-tracking branch 'origin/main' into feat/mod-api-level 2026-10-26 11:45:15 +01:00
Ludovic Marie d104a350cd Merge pull request #1980 from kacpherek/fix/place-sound-refused
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:45:09 +01:00
Kacpherek 1e6884c591 Show the poem and credits on a player's first End exit in server-thread mode 2026-10-26 11:43:37 +01:00
Ludovic MarieandClaude Opus 5.5 cdbe1edcda Text displays: keep the background at vanilla's -1..width, -1..height
TextDisplayRenderer.submitInner calls submitTextBackground(-1, -1, width,
height), and Font.prepareBackground's effect glyph spans x0..x1, y0..y1, so the
fill is width + 1 by height + 1 from (-1, -1), as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:42:48 +01:00
Ludovic MarieandClaude Opus 5.5 2bfa7f57ba Merge main into fix/world-text-parity
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:42:48 +01:00
Ludovic Marie 6cd4238930 Merge pull request #1979 from kacpherek/fix/1899-swim-ceiling
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:42:32 +01:00
Kacpherek 76e2c84103 Recipe book: a click on a category tab reaches the book before the outside test 2026-10-26 11:41:21 +01:00
Ludovic MarieandClaude Opus 5.5 7abae79d41 Merge main into fix/prepare-atlauncher
sounds.rs: main's Assets::load reads the launcher's files through game_files.
The rewritten-manifest test takes main's message; main's new test passes
GameFiles. ATLauncher is also looked for where the Horizon launcher looks
(~/Library/Application Support/ATLauncher, the Flatpak's data/atlauncher).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:41:03 +01:00
Ludovic Marie 3c1ab736d5 Merge pull request #1978 from kacpherek/fix/1881-death-camera-tilt
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:39:58 +01:00
socksproxandDevin 0a2de159fd Fire: ambient smoke particles and no mob-break crumbs
BaseFireBlock.animateTick adds large smoke above a fire (three puffs in
its top half), or two puffs at each burnable neighbour's face when the
fire sits on nothing burnable or sturdy. SoulFireBlock.canBurn is
always true, so soul fire always takes the top-half branch. The
burnable table mirrors FireBlock.canBurn (fire.igniteOdds above zero)
from block-behaviours.json.

Mobs breaking blocks (MobEvent::BreakBlock) map to Level.destroyBlock,
whose Block.spawnDestroyParticles BaseFireBlock overrides to nothing:
a wither or another mob destroying fire no longer emits break
particles. Player breaks already route through player_destroy (1009);
the client-side predict_break particles are PR #2082's.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 11:39:08 +01:00
Ludovic MarieandClaude Opus 5.5 f19390bae1 Sonic boom: take the LAN host in the same let-else as the particle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:39:07 +01:00
Ludovic MarieandClaude Opus 5.5 78bfe74465 Container clicks: the stuck-drag test joins the module's tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:38:39 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol d25d0f025b Merchant offers: preserve required potion components
Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 10:38:26 +00:00
Ludovic MarieandClaude Opus 5.5 fb74b6038f Chat: system and player lines share the window's chat code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:38:17 +01:00
Ludovic MarieandClaude Opus 5.5 bde76794ee Mobs: no rain or thunder outside the overworld
Level.canHaveWeather is false in the Nether and the End, so their rain and
thunder levels stay at 0. Set that once in the mob world instead of checking
the End at two of the rain sites: bees, dolphins, wolves and happy ghasts read
the same flag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:37:43 +01:00
Ludovic Marie a31a435525 Merge pull request #1976 from kacpherek/fix/1526-gateway-cooldown
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:37:21 +01:00
Ludovic MarieandClaude Opus 5.5 2918f31811 Poses::reset clears the scratch pose too
A missing part's writes land in `dummy`; a fresh Poses started it at zero,
so a reused one must too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:36:50 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol 52a0a5a89c Fix firework bursts on server clients
Send the rocket explosion event before tracked removal and render it with the synchronized firework item. Cover colored bursts and later trail frames in both hosting paths.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 10:35:33 +00:00
Ludovic MarieandClaude Opus 5.5 c813261574 Horizon shader packs: the material stage runs in the game
A pack's `fn material(input: MaterialInput) -> Surface` now gives the lit
colour of every terrain surface: sections and distant tiles, solid,
cutout and translucent, in both the instanced and the flat draws. Terrain
keeps Horizon's meshes, culling and draw lists; only its pipelines change.
Their shader is the pack's stage, a prefixed copy of terrain.wgsl's
terrain section, and entries that hand the pack the texture, the vertex
tint, the light levels and the lightmap's colour apart, then apply the
alpha test and fog as `fs_solid` and `fs_translucent` do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:35:15 +01:00
Ludovic Marie 7650451b4f Merge pull request #1974 from kacpherek/fix/1500-vehicle-storage
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:34:26 +01:00
Ludovic MarieandClaude Opus 5.5 3c22ff0849 Launcher: don't install a mod version the game can't run
The game program carries its mod API level in a mark
(horizon-mod-api-level:NNNN;), which the launcher reads once per
installed version; a program from before the mark (0.2.68 and older)
serves level 0. The Horizon browser marks versions above the level of the
game the player runs (the selected version, else the newest installed)
"needs a newer Horizon than the game you play", and installs the newest
version that game can run, or says to update it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:33:30 +01:00
Ludovic Marie c0171fa6bc Merge pull request #1973 from kacpherek/fix/1497-machine-menu-changes
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:31:24 +01:00
Ludovic Marie 707348fcd5 Merge pull request #1972 from kacpherek/fix/1493-shulker-guest-homing
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:28:33 +01:00
Kacpherek 5389f72b9a Creative: work out the shown items again after a resource or language reload
A resource pack can rename items without changing the Search label the cache was keyed on, so a search kept its old results until the query or tab changed.
2026-10-26 11:24:41 +01:00
Ludovic Marie 8a458496c7 Merge pull request #1984 from rdbtCVS/fix/1956-item-fluid-current
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:23:24 +01:00
Ludovic Marie 3144276935 Merge pull request #1970 from kacpherek/fix/1328-respawn-after-trip-death
🤖 Agent: Merged after the contrib-gate-16 review: checked against 26.3, the in-game checks and lan_e2e in both modes, and the 79 affected harnesses on the integration tree (see the gate comment). Thanks!
2026-10-26 11:22:15 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol 8c6ec16c99 Fix guardian charging beams on server clients
Sync guardian moving and active attack-target metadata, resolve the actual targeted player, and tick the client's visual charge clock. Cover charge completion and target loss for guardians and elders. Fixes #1491.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 10:21:50 +00:00
socksproxandDevin 6b8fde092f Keep 26.3's name-tag and text-display values
Review caught three regressions against the pinned client jar: submitInner
lines stay lineHeight+1 (10 px), submitNameTag fades discrete and
see-through names with the same 0x7F, and lightCoordsWithEmission floors
both light channels at 2.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 11:20:39 +01:00
levzzzandHermes Agent 7a2de4e328 Render bundle contents in station inventory tooltips
Keep hovered item stacks through draw_station and use draw_stack_tooltip, while retaining text-only station hints. Covers anvil and brewing-stand player inventory slots and stonecutter result previews.\n\nAdd an offscreen render regression which fails on the old binary for both reported screens and passes on the fix in server-thread and in-frame modes.

Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-26 15:18:17 +05:00
Ludovic MarieandClaude Opus 5.5 08f4fd5c8e Teleports free their dropped columns off the frame and in batches
A teleport far away (or a storage that moves by more than its size) drops
every column in one frame: 3,725 at RD 32. That frame freed every column's
sections, then removed each section from the renderer one at a time, with
a few hash lookups per section and a free-list tree update per mesh range.

- The dropped columns, and a left dimension's whole chunk map, are freed on
  a short-lived thread of their own ("Chunk freeing").
- Renderer::remove_columns removes whole columns: one lookup per column for
  the tables kept by column, and the meshes' ranges go back to the arena in
  one sorted merge per page (FreeList::give_all) instead of a tree update
  each. Per-section Renderer::remove and Occlusion::remove had no other
  callers and are gone.
- The renderer's block-entity and detail-column tables use FxHashMap.

Mac mini (M4 Pro), RD 32, explored world, server thread, 3 alternated runs,
two long teleports each: the drop took 26.1-39.7 ms, now 6.9-8.5 ms.
Entering the Nether: 9.7-13.4 ms, now 4.0 ms or under 6 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:10:25 +01:00
Ludovic Marie 6c995bfa15 Merge remote-tracking branch 'origin/main' into feat/mod-api-level
# Conflicts:
#	crates/horizon-mods/Cargo.toml
#	crates/horizon-mods/src/index/tests.rs
#	launcher/Cargo.lock
2026-10-26 11:09:19 +01:00
Kacpherek 6b60056637 Thrown projectiles: fly on the client between server updates, eye shatter particles, potion motion order
On a server, arrows, snowballs, eggs, ender pearls, potions and eyes of ender only eased towards the tracker's position, which comes every 10 to 20 ticks, so they moved in jerks. Like vanilla (Entity's NO_OP InterpolationHandler) the server's position now replaces theirs at once and they fly on by themselves each tick.

A shattered eye of ender now sends level event 2003 with its item crumbs and portal rings.

Thrown potions applied gravity and drag after the move; ThrowableProjectile applies them before.
2026-10-26 11:07:37 +01:00
Ludovic Marie 88b7bfafc0 Merge pull request #1833 from ludovic111/feat/server-mods-0.3
🤖 Agent: merged main (12 files conflicted with ui, render and input). The runtime now keeps the server/client Hooks split beside the ui-events export, and the Snapshot carries both the GUI fields and the server's players. Locally: the horizon-mods sandbox and unit tests, cargo-horizon-mod tests, and clippy on the client with mods all pass. CI verify and parity pass at b90ab85f4. Bottom of the server-mods stack (#1837, #1894, #1895, #1901 follow).
2026-10-26 11:07:35 +01:00
Ludovic MarieandClaude Sonnet 5.5 34d8d94022 parity: server harnesses wait on the server's clock and replies, not on sleeps
server_knockback, locator_bar, server_dimension_players and their one-player users now end a step by the server's
game clock (a sync round trip, N ticks, a last sync) the way server_player_targets does, so a server that stalls
under load still gets all its ticks inside the step. server_knockback's frozen section waits for the stepped tick,
the stopped clock and the landed hit; dedicated_server waits for the player's leaving before /list.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 11:06:02 +01:00
Kacpherek 311c0f7392 Stop endermen teleporting nonstop in the End
The End has a fixed time and no precipitation, so endermen there must not
flee daylight or take rain damage.
2026-10-26 11:05:49 +01:00
Ludovic MarieandClaude Opus 5.5 83f0d5bf58 Worldgen data loads its three biggest parts in parallel
GeneratorData::load read everything on the server thread while the chunk
workers waited: the block tables, the structure templates and the block
behaviours now load on threads of their own, and BlockBehavior::load reads
its cached-support table beside the other two. The behaviour exports
(gameplay-facts.json is 55 MB) parse from bytes in memory instead of
through a reader.

ThinkPad (2 cores), 3 alternated runs: 0.63 -> 0.45 s with the behaviour
snapshot cached, 2.40 -> 2.07 s on a first start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:04:57 +01:00
SenjinandClaude Opus 5.5 728c6a62b3 HDR: the emission mark in the vertex colour's alpha, not a layer bit
EMISSIVE_LAYER took bit 14 of TerrainVertex::layer, halving the sprite
layers terrain vertices can index (32768 to 16384) and leaving no flag
bit free. The colour's alpha byte was always 0xFF: its low four bits now
hold 15 minus the light level the face emits (EMISSION_BITS), so 0xFF
still means none and the other four bits stay free. terrain.wgsl takes
only the colour's rgb and passes the level on; Java shader packs get
alpha 1; the section mesh fixture restores vanilla's 0xFF.

The HDR pass still lifts any emitting face the same way; the level is
there for scaling the lift by it later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:03:16 +01:00
SenjinandClaude Opus 5.5 9b863d6093 HDR output: scRGB swapchain, HDR metadata and emissive highlights
HDR (Graphics > Display, off by default) presents to an scRGB swapchain
where the surface offers it. The frame is drawn as in SDR, in vanilla's
gamma space; a last pass decodes it at the Paper White and lifts light
sources (blocks that emit light, lava, the sun and the moon) evenly
towards the Peak Brightness.

The mesher marks emitting faces with EMISSIVE_LAYER (bit 14 of the
layer); the frame's alpha carries the mark to the HDR pass, which lifts
only pixels that still show the world before the hand and HUD.

horizon-hdr sends HDR metadata through VK_EXT_hdr_metadata, which wgpu
doesn't: without it KWin clips scRGB content above 1.0 (80 nits).

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:03:16 +01:00
Kacpherek c80b3b4950 Multiplayer: no break particles or sound when a player puts out fire
The predicted break played the block's break sound (wool for fire) and its destroy particles. BaseFireBlock skips both (spawnDestroyByEntityParticles does nothing); players only hear the server's extinguish sound.
2026-10-26 10:58:41 +01:00
Ludovic Marie 2a0f4e39a8 Merge pull request #2036 from ludovic111/feat/launcher-shader-packs
🤖 Agent: I looked at the launcher's Shader packs tab in dark and light (Mac mini, mktemp homes, a local registry with the sample pack): Soft Glow is listed from Horizon with Works, Page and Install, and the look matches the other tabs. pr_ready --quick is green, with the launcher's 130 tests.
2026-10-26 10:57:13 +01:00
Ludovic MarieandClaude Opus 5.5 acbcaad743 Horizon shader packs: one WGSL prefixer for copies of the renderer's shaders
The sky stage runs a copy of terrain.wgsl's sky beside the pack's code
with every name prefixed `horizon_sky_`. The prefixer moves to
`gfx/wgsl_prefix.rs` and now also renames module variables (bindings)
and overrides, so the material stage can copy the terrain section the
same way. The sky's shader is unchanged apart from where `globals` is
declared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 10:56:39 +01:00
Kacpherek e7f41e7d13 Performance preset: keep animated textures on
The preset turned them off, which froze fire, water, lava and portals. Players reported fire with no animation (#2064). The option stays in Advanced Graphics for anyone who wants it off.
2026-10-26 10:51:26 +01:00
Ludovic MarieandClaude Opus 5.5 e3e08911ba parity: run lan_integration only when named and in the nightly audit
The harness runs the whole lan_e2e suite: ~40 minutes on a loaded runner,
and single checks flake under load (barter, riding, shulker_bullets in one
run; cursor, falling in another, all passing on rerun). Selected by
--changed it would run on most gameplay pull requests and on parity-main.
tools/parity_hosts.json gains a named_only table that every automatic
selection (--changed, --failed, the default, --flake-check) leaves out;
naming the harness or --all (the nightly audit) still runs it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 10:48:58 +01:00
Niraj Khetre 290e2e1ac7 The ender dragon's hits and breath name it in the death message
The dragon's wing and head hits and its breath clouds hurt the player
without recording the dragon as the causing entity, and the dragon is
not one of the mobs note_attacker looks up. death.attack.mob and
death.attack.indirectMagic then got only the victim's name, so the
template showed through: "%1$s was killed by %2$s using magic".

Record the dragon before its damage lands (EnderDragon.hurt and
knockBack use mobAttack(dragon); its breath clouds apply
indirectMagic(cloud, dragon)).

Fixes #2062

Assisted-by: Claude Opus 5.5
2026-10-26 15:14:37 +05:30
Ludovic Marie 87ff54e702 Merge remote-tracking branch 'origin/main' into feat/launcher-shader-packs
# Conflicts:
#	docs/shaders.md
2026-10-26 10:40:12 +01:00
Ludovic MarieandClaude Opus 5.5 8cc8c6180d Mod API levels: refuse a mod made for a newer Horizon, list each version's level
Every function the game offers mods has the API level it arrived at
(crates/horizon-mods/src/api_level.rs): level 0 is the contract of
Horizon 0.2.67 and 0.2.68, level 1 adds render.set-minimap. A mod needs
the level of the newest function it imports, read from its component.

- The game refuses a mod that calls a function it doesn't have with
  "made for a newer Horizon (it uses ...): update Horizon to run it"
  instead of wasmtime's linking error.
- The registry index lists each mod version's level (api_level, read
  from the file by CI); check_file compares it when the entry has one,
  and the moved extras' installer takes the newest version this game
  can run.
- cargo horizon-mod check prints the level.
- The api_reference test fails until a new WIT function has a level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 10:39:47 +01:00
Ludovic Marie 852e7cd524 Merge pull request #1855 from ludovic111/feat/native-shaders-registry
🤖 Agent: verified: pr_ready --quick green (launcher and horizon-mods tests), CI verify and check green, merges cleanly with main. The registry takes Horizon shader packs, checked by the game's own shader check.
2026-10-26 10:39:24 +01:00
Ludovic Marie 28ef79ed66 Merge pull request #1863 from ludovic111/feat/native-shaders-sky
🤖 Agent: verified: pr_ready --quick green, 80/80 affected harnesses pass locally, CI verify green, A/B shows no frame cost without a pack. A Horizon shader pack's sky stage now draws in the game.
2026-10-26 10:36:58 +01:00
Ludovic Marie f52d053817 Merge pull request #2063 from ludovic111/feat/moved-minimap
🤖 Agent: verify passed. Checked against the real registry now that Minimap 1.0.0 is published: the migration e2e passes 34/34 on the server thread and 34/34 in-frame. The minimap case starts at bottom left, 128, round, 0.5 blocks per pixel, turning: the mod is installed and on, its settings carried, the map shows, and the Extras page has no minimap rows. pr_ready --quick passes (clippy, 1295 horizon-client tests).
2026-10-26 10:30:49 +01:00
levzzzand[CC] <deepseek-v4.1-flash> 941f148957 Loot: a single-level enchantment draws no level
Review of #2037 (rdbtCVS): `EnchantRandomlyFunction.enchantItem` calls
`Mth.nextInt(random, minLevel, maxLevel)`, which returns `min` without
drawing when `min >= max`. The arm here drew unconditionally, so choosing
Mending (or any one-level enchantment) shifted every roll after it.

`horizon_world::loot`'s copy and `merchant.rs`'s already guard this; only the
loot dispatcher in `items.rs` did not.

Vanilla with `LegacyRandom(11)`: a Mending book followed by
`set_count` uniform 1..64 is 46; the extra draw made it 28.
`a_single_level_enchantment_draws_no_level` pins that (and the stored
Mending level).

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 14:30:45 +05:00
Kacpherek 0f6569070d Draw a GUI item's vertices once while it stays in place
Two costs a frame were repeated for every item a screen draws. ItemModels::components cloned the item's whole default component map (a JSON map of every default component) on every resolve, though most stacks carry no patch; it now borrows the defaults and copies only to apply a patch. And gui::draw submitted, culled, sorted and clipped every quad of a static item again each frame (hundreds of edge quads for a generated item, all facing sideways in the GUI), though the result for the same layers at the same position is the same; the HUD vertices of a static item are now kept per GUI position, keyed by its baked models, tints, transforms, the atlas and the exact position, and appended as they were. Items with a glint or a special model renderer are drawn as before. With the creative inventory open the HUD's share of a frame went from 0.66 ms to 0.23 ms.
2026-10-26 10:24:07 +01:00
Kacpherek 515c0bdb7d Work out the level item models read once a frame
draw_item_model built the client level state (dimension, game time, sun angle, moon phase, respawn) for every stack it drew, sampling the sky each time: about seventy times a frame with the creative inventory open, a fifth of the time spent on its items. Within a frame the level is the same for all of them, so it is kept for the frame (keyed by the frame, game time, day time and dimension).
2026-10-26 10:24:07 +01:00
Kacpherek fb9b26a06c Work out the creative grid's stacks once per tab or search, not every frame
With the creative inventory open, every frame cloned all the tabs with their stacks (twice), and items() cloned the selected tab's stacks again on each of its three or four calls a frame; on the search tab each call ran the whole search again, building every stack's localised name. That took the frame rate from about 1,900 to 980 with the inventory open and 340 while searching (#1953). Vanilla fills ItemPickerMenu.items when the tab or the search changes (selectTab, refreshSearchResults): the grid's stacks are now kept until the tab, the search text or the language changes, and the screen draws the tabs without copying their stacks.
2026-10-26 10:24:07 +01:00
lazy-importerandClaude Opus 5.5 ad7203fc8a Model item_name component on items and tooltips
In vanilla 1.20.5+, servers and datapacks customize base item names using
the `minecraft:item_name` data component rather than `minecraft:custom_name`.
Vanilla's `ItemStack.getHoverName()` checks `custom_name` first, then
falls back to `getItemName()`, which retrieves `DataComponents.ITEM_NAME`.

Previously, Horizon did not model `item_name` in `StackData`, causing items
with custom base names from servers or resource packs to fall back to
their default translation keys instead of the server-provided name.

This adds `item_name` to `StackData`, decodes and encodes it across
network item sync, and prioritizes it in `name_line` and hover tooltips.

Fixes #2039.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 10:21:00 +01:00
JoansitohandClaude Sonnet 5.5 fa8291fc97 Treat a line break as a word boundary in text fields.
Ctrl+Backspace on a book page with the caret after `two` in `one\ntwo` removed both lines because the word helper only knew spaces.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 10:06:48 +01:00
Ludovic Marie 593b99a68b Merge branch 'feat/server-mod-test-scripts' into feat/server-mods-game
# Conflicts:
#	crates/horizon-client/src/game/mods.rs
2026-10-26 10:01:50 +01:00
Ludovic Marie d64bd7c528 Merge branch 'feat/server-mods-0.3' into feat/server-mod-test-scripts
# Conflicts:
#	crates/horizon-mods/src/testing.rs
2026-10-26 09:56:41 +01:00
Ludovic Marie 45bc45b396 Merge pull request #2057 from ludovic111/perf/remote-chunks-off-render
🤖 Agent: pr_ready passed (78 harnesses), lan_e2e in-frame and server-thread passed; frame p99 4.96→3.95 ms during a RD 32 flight. Queued for merge.
2026-10-26 09:56:18 +01:00
Ludovic Marie 6f0c0c13b6 Merge pull request #2051 from ludovic111/feat/native-shader-frame
🤖 Agent: verified: pr_ready --quick green, 38/38 affected harnesses pass locally, CI verify green, A/B shows no frame cost. Internal: a Horizon shader pack's frame uniform is shared by its stages (no visible change).
2026-10-26 09:54:11 +01:00
Ludovic Marie b90ab85f44 Merge remote-tracking branch 'origin/main' into feat/server-mods-0.3
# Conflicts:
#	Cargo.toml
#	crates/cargo-horizon-mod/src/check.rs
#	crates/horizon-client/src/game/mods.rs
#	crates/horizon-mod-sdk/src/lib.rs
#	crates/horizon-mod-sdk/wit/horizon.wit
#	crates/horizon-mods/src/manifest.rs
#	crates/horizon-mods/src/runtime.rs
#	crates/horizon-mods/src/testing.rs
#	crates/horizon-mods/tests/sandbox.rs
#	docs/api/mods-reference.md
#	docs/api/mods.md
#	docs/mods.md
2026-10-26 09:51:00 +01:00
Ludovic Marie b110608a93 Merge remote-tracking branch 'origin/main' into feat/moved-minimap 2026-10-26 09:43:44 +01:00
lazy-importerandClaude Opus 5.5 c675329208 Reset boss overlay on server reconfiguration
When switching sub-servers on a proxy network (Velocity/BungeeCord), the
network thread enters configuration mode and emits Event::Reconfigured.
Vanilla's ClientPacketListener.handleConfigurationStart clears the client
level, which calls Hud.onDisconnected() and resets BossHealthOverlay.
Without this reset, bossbars from previous servers remained visible on
screen.

Fixes #2034.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:42:35 +01:00
Ludovic MarieandClaude Opus 5.5 382380fc8b Minimap moved to the mod browser: remove the built-in minimap
The official Minimap mod (mods/official/minimap) shows the extra's map
through render.set-minimap. The extra's switch and its five Extras rows
go; the game still draws a mod's map from the same tiles. A player who
had the minimap on gets the mod installed and turned on at the first
start, with the corner, size, shape, zoom and turning carried into its
storage, and one chat line. horizon-extras.txt keeps the old keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:38:52 +01:00
Ludovic Marie 9e447a7e92 Merge pull request #2058 from ludovic111/feat/official-minimap-mod
🤖 Agent: The official Minimap mod (mods/official/minimap): the minimap extra as a mod with its five options. Pixel-identical to the extra in the map region (0.000 to 0.002) with default and other settings, at the same frame cost. Verified: pr_ready (2179 workspace tests, clippy), the settings page in game, and the published mods loading beside it in both modes.
2026-10-26 09:34:51 +01:00
FusselTVandClaude Opus 5.5 2143c26a2e Saves: write points of interest
Horizon finds villager POIs from the blocks and kept no poi folder, so a
world it made had none. Vanilla rebuilds a missing section on load, but
until a chunk is loaded nothing knows its beds, job sites, bells, nests,
portals or lodestones.

Saving a chunk now writes its POI records the way PoiManager saves its
sections: each section with POI blocks, valid, one record per block of
PoiTypes with its max tickets, free_tickets left out at 0. A chunk that
lost its last POI and had a record gets an empty one, since vanilla
trusts a valid section.

The records are built on the region writer thread, which reads the
saved record there, so unloading chunks reads no files on the tick
thread. A straw bed is no home (PoiTypes.BEDS is the sixteen colours);
/locate poi uses the same table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 c67a963da7 Saves: write the structure starts a chunk holds
A generated chunk keeps the starts it holds and its save writes them
into structures.starts with their current state, as vanilla saves the
StructureStart objects its chunks share. Proto chunks get their
references and starts too, so vanilla and Paper find the structures of
the chunks they finish at the edge of a generated area.

A start with a piece that cannot be saved is left out, with a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 fc0b73954f x
A container whose items no longer match its roll is saved without the
table, also when it was loaded with one, so a chest emptied after a
load does not fill again on the next one. A zero LootTableSeed is left
out, as trySaveLootTable does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 ad50996f7c Structures: save the remaining legacy pieces as vanilla does
Desert and jungle temples, swamp huts, buried treasure, ocean
monuments, mineshafts, fortresses and strongholds save the tag their
StructurePiece subclass writes, with the state placement leaves: the
height a scattered feature settled on, the buried treasure chest,
placed chests, traps and spawners, and the swamp hut's witch and cat.
With these every StructurePieceType is saved.

test_structure_start_nbt_parity gets one site of each of these kinds,
and a badlands mineshaft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 b7b36836af Structures: save template pieces as vanilla does
Igloo, shipwreck, ocean ruin, ruined portal, nether fossil, End City
and mansion pieces save the tag TemplateStructurePiece and its
subclasses write, with the state placement leaves: the height a
shipwreck or ocean ruin settled on and the igloo's moved box. Ruined
portals save all six properties, End City pieces their genDepth tag.

test_structure_start_nbt_parity gets one site of each of these kinds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 9a0fcda3a9 Proto chunks: save placed block entities with their id
Block entity data a structure places can lack its type: a brushable
block's is only its loot table. Finished chunks get the id from the block,
saved proto chunks did not. Vanilla and Paper skip a pending block entity
without one when they finish the chunk ("Skipping block entity with
invalid type"), so suspicious sand and gravel at the edge of a
pre-generated area lost their archaeology loot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 df3ac51447 Trial spawners: no empty spawn_data on save
The block entity canonicaliser added spawn_data {entity: {}} to every
trial spawner that had none. Vanilla only writes spawn_data once the
spawner has picked its next mob (TrialSpawnerStateData.Packed reads it as
Optional.empty). A spawner saved while mob spawning was off, as worlds
pre-generated in peaceful or with spawner_blocks_work false are, kept the
empty entity for good: getOrCreateNextSpawnData returns it, it names no
mob, and the spawner never spawns, in Horizon and in vanilla or Paper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 c776d6586e Structures: save jigsaw starts as StructureStart.createTag does
Horizon kept no saveable form of its structure starts, so its saves carry
an empty structures.starts and every structure it generates is invisible
to vanilla and Paper (locate, structure spawns, location predicates,
advancements) and cannot be continued past the generated area.

StructureStart::save and Piece::save write the tag vanilla writes. This
covers the pieces of jigsaw pools (villages, pillager outposts, ancient
cities, trail ruins, trial chambers, abandoned camps, bastions) with
their pool element, junctions and liquid settings. Junctions keep
delta_y and dest_proj, pool elements their processors as written.
LegacyPiece::save defaults to None; a start with such a piece is not
saved, as before.

test_structure_start_nbt_parity compares the tags with the pinned
server's own for one site of every jigsaw structure kind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 1bbe64265c Saves: Nether and End chunks keep their own height
Chunk holds the Overworld's 24 sections in every dimension, and the save
wrote them as they are: yPos -4, sections -4 to 19 and heightmaps counted
from y=-64. Vanilla and Paper trust those heightmaps, so a Horizon-made
Nether reads its surface at y=192, above the bedrock roof, and the End 64
blocks too high. Re-saving a vanilla Nether chunk rewrote its yPos too.

LevelShape (skylight, min_y, height per dimension folder) replaces the
save path's sky_light flag. yPos, the saved sections, the PostProcessing
lists and the heightmaps follow it, as SerializableChunkData.write does.

LAN clients in the Nether and End now get the chunk's heightmaps; the
heightmap check failed there and they were sent empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:33 +01:00
zhykowoandWorkBuddy AI 3e79b22c43 Launcher downloads go through the proxy the player sets
Everything the launcher fetches now goes through one HTTP proxy when there is
one: Horizon's releases and the launcher's own update, Minecraft's files and
Java from Mojang, Modrinth, CurseForge, the mod registry, pictures, Microsoft
sign-in, Tailscale's API, and the jars a modpack import downloads. It is
Settings' Proxy row (`proxy` in settings.json), or `horizon-launcher settings
proxy http://host:port`; without one, HTTPS_PROXY, HTTP_PROXY and ALL_PROXY
apply, with NO_PROXY naming the hosts to reach directly.

A local address never goes through it, and the Servers page's calls to a game
server's API stay direct: they are on loopback and asked every second.

horizon-port-pack builds its own agents, so the launcher hands it the address
(net.rs); run on its own, it reads the same variables.

Co-Authored-By: WorkBuddy AI (DeepSeek-V4.1-Flash) <[email protected]>
2026-10-26 16:28:24 +08:00
Ludovic MarieandClaude Opus 5.5 4498df8be5 Merge feat/official-minimap-mod into feat/moved-minimap
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:24:17 +01:00
levzzz d1c1af598b Bundles show what they hold
Hovering a bundle showed only its name and `BundleItem.appendHoverText`'s
"Can hold a mixed stack of items": nothing drew the contents, so a player
could not see what was inside or how full it was (#2001).

`ClientBundleTooltip` is ported to `bundle::tooltip_layout`: the 96 pixel
column, the four-wide grid filled bottom-up and right-to-left from
`getNumberOfItemsToShow` (eleven stacks and a `+N` when it overflows), the
progress bar at `Mth.mulAndTruncate(weight, 94)` with the full sprite and the
`item.minecraft.bundle.empty`/`.full` labels, and an empty bundle's wrapped
description. The game draws it under the stack's own tooltip
(`Game::draw_stack_tooltip`), which the inventory, creative, container,
beacon and trading hovers now use.

Verified in game: a bundle with 64 stone shows one slot and a full red bar
labelled "Full!"; one with 32 stone and 8 dirt shows two slots (32 stone
first, as the next out) over a half-filled blue bar; an empty one shows the
description and "Empty".
2026-10-26 13:22:31 +05:00
Ludovic Marie 57c5f81299 Merge pull request #2028 from ludovic111/feat/shader-packs-page
🤖 Agent: verified: pr_ready --quick green, 38/38 affected harnesses pass locally, CI verify green, paired A/B shows no frame cost. Options > Horizon Settings > Shader Packs lists and picks packs of either kind.
2026-10-26 09:22:21 +01:00
Ludovic Marie ce46917121 Merge pull request #2056 from ludovic111/feat/mod-minimap-api
🤖 Agent: Mod API render.set-minimap: a mod picks the corner, size, scale, shape and turning, and the game draws the map from the minimap extra's tiles. It is a new function, so published mods keep loading. Verified: pr_ready (1353 tests, clippy, launcher), the sandbox test, and in game beside the published mods in both modes.
2026-10-26 09:12:15 +01:00
Ludovic Marie 09d61ec866 Merge pull request #2030 from ludovic111/feat/moved-zoom
🤖 Agent: Zoom moved to the mod browser. The built-in zoom is removed, and a player who had it on gets the published Zoom mod with their settings and key. Verified against the real registry, in both modes (54/54), plus clippy and the client tests.
2026-10-26 09:09:19 +01:00
Ludovic MarieandClaude Opus 5.5 427b48aa42 Server chunk packets decode on their own thread, not in the frame
A server's chunk columns (single player on its server thread, LAN,
servers) were decoded on the render thread: sections, palettes, biomes
and light, about 0.1 ms each on an M4 Pro. Once the backlog was overdue,
a frame decoded every waiting column at once. Now each chunk packet goes
to a decoder thread as soon as it arrives, and the frame that reaches it
in order only inserts the column. A column still decoding holds back what
follows it, overdue or not, so a frame never waits for the decoder. A
dimension or registry change since a column was decoded makes the frame
decode it again with the new format.

Forgetting a column removed its pending sections with a pass over all of
them. A moved or shrunk storage forgets thousands of columns in one frame,
so it now removes them by key and drops their block entities in one pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:05:47 +01:00
Ludovic Marie 2b54c8a4ab Merge pull request #1812 from ludovic111/fix/trapped-chest-signal-barrel-open
🤖 Agent: main merged into the branch (merge commit). fmt, clippy -D warnings and the horizon-client tests pass; the 30 harnesses parity.py --changed picks for the game-binary files pass (30/30 on an integration of #1896 #1809 #1812); verify passed on this head. The earlier parity failure was the stale-head runner setup (.local/horizon-client link).
2026-10-26 08:57:39 +01:00
Ludovic Marie ff26716cd1 Merge pull request #1809 from ludovic111/fix/portal-effect-on-server-clients
🤖 Agent: main merged into the branch (merge commit). fmt, clippy -D warnings and the horizon-client tests pass; the 30 harnesses parity.py --changed picks for the game-binary files pass (30/30 on an integration of #1896 #1809 #1812); verify passed on this head. The earlier parity failure was the stale-head runner setup (.local/horizon-client link).
2026-10-26 08:55:30 +01:00
Ludovic MarieandClaude Opus 5.5 71126d3e13 Merge feat/mod-minimap-api (with main) into feat/official-minimap-mod
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:55:08 +01:00
Ludovic MarieandClaude Opus 5.5 641ff51089 Merge main into feat/mod-minimap-api
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:55:02 +01:00
Ludovic Marie 45bd296f14 Merge pull request #1896 from ludovic111/fix/creative-drop-on-server
🤖 Agent: main merged into the branch (merge commit). fmt, clippy -D warnings and the horizon-client tests pass; the 30 harnesses parity.py --changed picks for the game-binary files pass (30/30 on an integration of #1896 #1809 #1812); verify passed on this head. The earlier parity failure was the stale-head runner setup (.local/horizon-client link).
2026-10-26 08:53:22 +01:00
levzzzand[CC] <deepseek-v4.1-flash> 87e3df5331 Container clicks: a closed screen drops its press state
`AbstractContainerScreen` keeps `isQuickCrafting` and the drag it belongs
to on the screen. Horizon keeps both on `Game` (`inv_clicks`, `inv_drag`,
`last_inv_click`), and nothing cleared them when a screen closed: a press
whose release never reached the screen — the pointer left the window, or the
screen closed between the press and the release — left a quick-craft drag
running, and `Clicks::press` answers `None` for every later press, so clicks
went dead on every screen after it.

`Game::reset_inventory_clicks` now runs when a container closes and when the
pointer is grabbed again (the inventory's screens are behind us), which is the
lifetime vanilla gives that state.

Refs #1737

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 12:52:42 +05:00
rdbtCVSandCodex GPT-6.1 Sol cd7b6e025e Fix recipe book overlays surviving remote screen changes
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-10-26 07:49:37 +00:00
vlesmentieandClaude Opus 5.5 955b6c2134 The server thread's client draws the Ender Dragon
On a server's client (single player on its server thread, LAN guests,
dedicated servers) the dragon was tracked but never built: spawn_entity
only makes mobs from mob_types(), which has no dragon, and
dragon_geometry draws Game::dragon. The crystals had their own remote
constructor; the dragon had none.

The client now keeps the server's dragon in Game:🐉
handleAddEntity builds it (full health, hovering, as its constructor
does), SetEntityData applies DATA_HEALTH_ID and DATA_PHASE,
SetEntityMotion its velocity, and its moves interpolate like other
living entities. tick_remote_dragon is the client side of
EnderDragon.aiStep: wing flaps and their sound, the growl,
checkCrystals' nearest-crystal search for the healing beam, the flight
history and the parts; a dead dragon rises and bursts as in tickDeath.
Hurt animations flash it, removal and level changes drop it, and the
player's attacks pick its parts and send the server their ids.

The in-frame fight is unchanged; its nearest-crystal search moved into
nearest_crystal_to, shared with the client.

Fixes #1530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 04:45:47 -03:00
rdbtCVSandCodex GPT-6.1 Sol 66f32a7a3a Persist forced chunks across world restarts
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-10-26 07:45:35 +00:00
Ludovic MarieandClaude Opus 5.5 cad817c82d Horizon shader packs: the sky stage runs in the game
A pack's `sky` function gets vanilla's colour along each view ray and draws
where the vanilla sky dome draws, under the sun, moon, stars and clouds. The
pipeline's shader is the pack's checked stage (`CheckedStage::source`, new)
followed by a copy of terrain.wgsl's sky with every name prefixed
`horizon_sky_`, so none can meet a pack's; terrain.wgsl's `fs_sky` now calls
`sky_along_ray`, the same code as before.

A running pack is one `NativePack`: the frame every stage reads (written once
a frame), its sky and post stages and its pass timer. In a frame F3 times, the
sky draws in a pass of its own so the overlay can show its time; otherwise it
stays in the world's pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:42:57 +01:00
Ludovic MarieandClaude Opus 5.5 a68c2f587d Horizon shader packs: one frame uniform per pack, apart from its post passes
`NativePack` holds what a running pack needs: the `frame` every stage
reads (`NativeFrame`, written once a frame before the world draws), its
post passes and the timer of its passes. `NativePost` no longer owns the
frame buffer or the timer, so the sky stage can read the same frame and
be timed by the same timer. No change in what draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:42:46 +01:00
Ludovic Marie d31832eaf4 Merge pull request #1996 from ludovic111/perf/reach-planning
🤖 Agent: merged main in. The old head's parity failure was server_dimension_players, which passes on main's parity-main. tests/test_parity_reach.py OK.
2026-10-26 08:40:52 +01:00
Ludovic MarieandClaude Opus 5.5 6c3473c6e0 Official mod: Minimap
The minimap extra as a mod (mods/official/minimap): the extra's five
options on its settings page, kept in its storage with the extra's values
(corner, size, shape, zoom, rotate), and the map drawn by the game
through render.set-minimap, so it looks and costs the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:40:03 +01:00
Ludovic MarieandClaude Opus 5.5 e52f0fdcc2 Merge main into feat/moved-zoom
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:39:51 +01:00
Ludovic Marie 3f5a323fd9 Merge pull request #2026 from ludovic111/feat/shader-pass-times
🤖 Agent: verified: pr_ready --quick green, 32/32 affected harnesses pass locally, CI verify green, paired A/B shows no frame cost without a pack. F3 now shows a shader pack's GPU time per pass, for both pack kinds.
2026-10-26 08:33:05 +01:00
rdbtCVSandCodex b6af258465 Fix warden sonic boom particle delivery to connected players
Send each sonic ray point through the existing particle packet path with vanilla recipient filtering. Cover nearby and distant clients, damage, and local rendering in the LAN harness. Fixes #1490.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-10-26 07:29:40 +00:00
Eshayat Al-WasiuandOpenAI GPT-6.1 Sol f4b04218f1 Implement cushion placement and synchronize placed cushions
Add cushion entities, shape-based placement and support checks, colored rendering, server tracking, sitting, destruction and save/load support. Compare placement rules against vanilla and exercise host/guest interactions.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 13:29:16 +06:00
Ludovic MarieandClaude Opus 5.5 24ab642e7b Mod API: render.set-minimap, a map the game keeps in a corner
What the official Minimap mod needs: a mod picks the corner, side, scale,
shape and turning, and the game draws the map as the minimap extra does,
from the same tiles (vanilla's map colours, the floor under roofs) within
its own frame budget. One map at a time, the first loaded mod's. A new
function and type, so mods built before it keep loading.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:20:30 +01:00
Ludovic Marie c58af30ca6 Merge pull request #2043 from ludovic111/ci/parity-main-runner-comment
🤖 Agent: comment only; the runner labels were moved via the API.
2026-10-26 08:20:07 +01:00
Eshayat Al-WasiuandOpenAI GPT-6.1 Sol 606efe5af0 Animate remote squid bodies and tentacles from server velocity
Run the client branch of Squid.aiStep, preserve authoritative movement and synchronize tentacle-cycle resets with entity event 19.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 13:16:54 +06:00
Eshayat Al-WasiuandOpenAI GPT-6.1 Sol d422564ab3 Respect tool restrictions on Creative block destruction
Check the tool component before server destruction, local mining and remote prediction. Cover vanilla item defaults, overrides and mode-switch cancellation.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 13:16:45 +06:00
Alexander KrylovandHermes Agent 4a7173918b Roadmap: finished help-wanted items link the pull requests that did them
check still found five Help wanted items whose only links were closed
issues. Each was finished by a merged pull request, so the item now links
that pull request and waits in Now until a release carries it.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 10:12:08 +03:00
Ludovic MarieandClaude Opus 5.5 6ace65fc18 ci: parity-main's runner comment says where it runs now
ludovics-mac-mini-2 holds only the horizon-parity-main label; the release
runner no longer has it, since a release waited behind each new
parity-main run there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:11:46 +01:00
Ludovic Marie 301b006ef4 Merge pull request #1816 from ludovic111/feat/mod-test-runner
🤖 Agent: cargo horizon-mod test runs a mod's test scripts with no game. Main is merged in, and the runner fills the new snapshot fields (input, render, frame stats) with the trial run's screen size. Verified: clippy, the cargo-horizon-mod, coords-hud and horizon-mods tests (57), launcher, and cargo horizon-mod test on coords-hud (2 scripts).
2026-10-26 08:07:39 +01:00
Ludovic Marie 2d57f97869 Merge pull request #2033 from ludovic111/perf/mob-ai-tick
🤖 Agent: verified: parity for the 75 harnesses parity.py picks passes locally, pr_ready --quick passes on the final head, lan_e2e mobs/hazards pass in-frame and on the server thread. Same results; −1.3% of the dedicated server's tick-thread cycles at 10 bots.
2026-10-26 08:00:18 +01:00
Ludovic MarieandClaude Opus 5.5 8e71cb4754 Test runner: fill the snapshot's new fields, with the trial run's screen size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:57:49 +01:00
Ludovic Marie c3b29a2652 Merge pull request #2023 from ludovic111/feat/official-zoom-mod
🤖 Agent: The official Zoom mod (mods/official/zoom): the zoom extra as a mod, pixel-identical and at the same frame cost. Verified with main merged: clippy, cargo horizon-mod check, and in game in both modes. Publishing follows in horizon-mods.
2026-10-26 07:57:29 +01:00
Alexander KrylovandHermes Agent 73bbbd3ee8 Tooltips: compare the hidden component id without a UTF-16 copy
TooltipDisplay.shows built a JavaString from the id on every call just to
test membership in the NBT list. A hovered tooltip asks this a dozen times
per frame, so the hover allocated a dozen short vectors per frame for a read.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 09:55:58 +03:00
Ludovic Marie 3163c7867d Merge pull request #1995 from ludovic111/feat/moved-hud-extras
🤖 Agent: The FPS counter and Keystrokes moved to the mod browser. A player who had one on gets the published official mod installed on the first start, with their settings and a chat line. Verified against the real registry, in both modes (34/34), plus clippy and the client tests.
2026-10-26 07:55:07 +01:00
Alexander KrylovandHermes Agent b59d9ada27 Shaders: the frame uniforms keep their buffer between frames
update rebuilt the values vector every frame for a fixed-length list: one
allocation and one free per frame for nothing. eval_custom already treats
the same vector as a buffer to truncate and extend.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 09:49:41 +03:00
Ludovic MarieandClaude Opus 5.5 f7d7c7c70a Merge main into feat/mod-test-runner
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:47:58 +01:00
vlesmentieandClaude Opus 5.5 fdba6b4153 Campfire timers mark their chunk for saving
CampfireBlockEntity.cookTick and cooldownTick call setChanged whenever
a timer moves (cookTick while any slot holds food, cooldownTick while
any progress is above zero). tick_campfire only marked the chunk when
an item finished, so after a save partway through cooking, an unload
saved nothing and the progress since went back to the saved value.
The chunk is now marked modified on those ticks, with no update to the
clients, who don't see the timers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:43:02 -03:00
Ludovic MarieandClaude Opus 5.5 108ced5793 launcher: a Shader packs tab that installs Horizon's own shader packs
The Mods page gets a fourth kind, Shader packs, which searches Horizon's
registry and installs a `.hshader` into `shaderpacks/` once
`index::check_file` has run the game's own `horizon_shader_api::check`.
Modrinth and CurseForge answer that tab with a pointer to the registry:
their shader packs are the shaders/ format and are not offered.

The sample index lists a shader pack (`glow`, made with
`horizon-shader new` and `pack`), so the registry build, check_file and
the launcher's install are tested on a real file. A file of the same
name the registry didn't put in place is no longer called a mod.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:41:24 +01:00
Ludovic Marie 7cb390fbff Merge pull request #2032 from ludovic111/fix/ctl-options-set-mods
🤖 Agent: horizon-ctl options-set no longer turns every mod off: Settings::with_option keeps the mod list with Horizon's other files. Verified: unit test, clippy, client tests, and in game in both modes (10/10).
2026-10-26 07:29:59 +01:00
Ludovic MarieandClaude Opus 5.5 c8d75a7892 Merge main into feat/official-zoom-mod
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:24:16 +01:00
Ludovic MarieandClaude Opus 5.5 828a2519e1 Merge main into feat/moved-hud-extras
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:23:36 +01:00
Ludovic MarieandClaude Opus 5.5 053291e339 Mob scans skip sections that can't hold what they look for
A mob's fluid state and its supporting block each read every cell around
its box, every tick. World::may_hold answers from the palettes of the
sections the box reaches: with no fluid listed there the fluid scan stops
before reading a cell, and with no block that reaches out of its cell
(large collision shapes, moving pistons) the supporting block search
reads only the cells meeting its area, as BlockCollisions' cursor does.
Results are the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:22:42 +01:00
Ludovic MarieandClaude Opus 5.5 130f1dc6a5 horizon-ctl options-set keeps the mod list
options-set rebuilt Settings from options.txt and carried over Horizon's
graphics, controller and extras files, but not the mod list: every mod
read as off afterwards, and the next save wrote an empty
horizon-mods.txt. Settings::with_option now does the rebuild and keeps
all four.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:13:21 +01:00
Ludovic MarieandClaude Opus 5.5 a1f1501305 launcher: Cargo.lock follows horizon-mods' shader check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:11:30 +01:00
Alexander KrylovandHermes Agent dd45bf7740 F3: measure each line once per frame
The overlay measured every line to place it and measured it again for its
background box, about thirty extra glyph walks per frame on a full screen.
The width that placed the line is carried to the background pass.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 09:08:18 +03:00
Ludovic Marie cbdbf068bf Merge main into feat/native-shaders-registry 2026-10-26 06:59:07 +01:00
Alexander KrylovandHermes Agent 9dc72bf3ac Subtitles: measure each line once per frame
The max-width pass built a Text for every audible subtitle and dropped the
width it measured, then the draw loop built the same Text again to measure
it a second time. The row loop uses the number the first pass already had.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 08:58:50 +03:00
Alexander KrylovandHermes Agent 1e9644cc15 Signs: cut the first line on byte offsets, not through a char copy
first_line collected the whole line into a Vec<char> and then re-collected
a prefix of it, eight times per sign per frame, front and back. Every cut
point it needs is already a byte offset in the input.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 08:51:52 +03:00
Ludovic Marie ee6dfd2b6c Merge pull request #2016 from ludovic111/feat/mod-ui-key
🤖 Agent: Players rebind a mod's keys on its Options > Mods page (the game's own key rows, saved in horizon-mods.txt), with no WIT type change, so the published mods keep loading. Verified with main merged: clippy, the client, mods and SDK tests, and the in-game check in both modes (24/24).
2026-10-26 06:50:11 +01:00
Ludovic MarieandClaude Opus 5.5 70b832d658 Options > Horizon Settings > Shader Packs: pick a pack of either kind
The page lists the packs in shaderpacks/: Horizon's own, marked (Horizon) and
named by their manifest, then packs in the shaders/ format. One runs at a
time: clicking a pack picks it, clicking the picked one turns it off, and
Open Pack Folder opens the folder. The pick is saved in horizon-graphics.txt
under a new key (pickedShaderPack), so the shaderPack value older versions
saved stays ignored. A new pick drops the running pack and loads the new one
in the next frame; HORIZON_SHADER_PACK still wins, for development.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:49:41 +01:00
Ludovic MarieandClaude Sonnet 5.5 c1814283e0 Merge origin/main into fix/creative-drop-on-server
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 06:49:26 +01:00
Ludovic MarieandClaude Sonnet 5.5 4512105e85 Merge origin/main into fix/trapped-chest-signal-barrel-open
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 06:48:57 +01:00
Ludovic MarieandClaude Opus 5.5 c3763ff1b3 F3 shows the shader pack's GPU time per pass, for either kind of pack
While the F3 overlay is open, the running pack's passes are timed on the GPU,
a shaders/ pack's or a native pack's, and the overlay lists the pack's total
and its costliest passes (the median of the last 15 timed frames). The timer
never waits: a timed frame's stamps are resolved once the GPU has finished it
and read back once mapped, then the next frame is timed. A native pack's first
pass counts from a mark that ends with the world's draws, so it does not carry
the time it waited for them. HORIZON_SHADER_PACK_TIMING still logs the medians.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:48:57 +01:00
Ludovic MarieandClaude Sonnet 5.5 0958cb297a Merge origin/main into fix/portal-effect-on-server-clients
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 06:48:55 +01:00
Ludovic Marie 5bcc87983e Merge pull request #1861 from ludovic111/feat/native-shaders-post
🤖 Agent: native shader packs' post stage in the game with hot reload; pr_ready, 66 affected harnesses and a paired A/B (no cost without a pack) passed.
2026-10-26 06:45:33 +01:00
Ludovic MarieandClaude Opus 5.5 a6c8e9070a Zoom moved to the mod browser: remove the built-in zoom
The official Zoom mod (mods/official/zoom) does what the extra did. The
extra's zoom, its key and wheel hooks and its Extras rows go; the camera
keeps a mod's zoom (render.set-zoom). A player who had zoom on gets the
mod installed and turned on at the first start, with the starting zoom,
smooth zoom and slower turning carried into its storage and a changed
key into horizon-mods.txt, and one chat line. horizon-extras.txt keeps
the old keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:42:14 +01:00
Alexander KrylovandHermes Agent 77def1f2dd Mob models: a skipped part still draws its children
The early return skipped the child loop as well, while skipDraw only hides
the part's own cubes. A rolled armadillo skips its body and still renders
the parts below it.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 08:40:00 +03:00
levzzz c68e55438f Loot: the enchantment functions reach every table that rolls
`/loot give @s loot minecraft:chests/end_city_treasure` handed out bare gear: the
loot dispatcher in `items.rs` (the path `/loot`, bartering, raids, dispenser
tables, advancements and mob drops use) knew no `minecraft:enchant_with_levels`
or `minecraft:enchant_randomly`, so both fell through its arm untouched. Only the
chunk-generation path, which uses `horizon_world::loot`, enchanted anything —
which is why #1911 and #1437 looked fixed.

The dispatcher now runs both, as vanilla does: `EnchantWithLevelsFunction` picks
from `EnchantmentHelper.enchantItem`'s results (a book becomes one enchanted
book), `EnchantRandomlyFunction` picks one compatible enchantment at a level
between its min and max, and both resolve `options` as a tag, a list or one id,
else the whole registry. `Items` carries that registry now
(`Items::enchantments`), and `StationData` shares the same copy instead of
loading a second one.

Verified in game: six `/loot give … end_city_treasure` rolls gave 10 pieces of
gear, all 10 enchanted (unbreaking 3, mending 1 + protection 4, …), where all 10
were bare before.

Refs #1660
2026-10-26 10:34:21 +05:00
Ludovic Marie 344ef96894 Merge pull request #2025 from ludovic111/ci/parity-main-bisect-confirm
🤖 Agent: bisect fix; tests/test_parity_main.py passes (4 tests).
2026-10-26 06:20:14 +01:00
Ludovic Marie 2f46365df1 Merge remote-tracking branch 'origin/main' into feat/mod-ui-key 2026-10-26 06:15:25 +01:00
Ludovic Marie 886ecd9b20 Merge remote-tracking branch 'origin/main' into perf/reach-planning 2026-10-26 06:13:02 +01:00
Ludovic MarieandClaude Opus 5.5 2c5dac354e parity-main: name a merge only when HEAD fails again and the base passes
The bisect ended on the newest merge when the failed harnesses pass
everywhere now (a flake, or a runner that was missing the sounds), and
on the oldest when they fail everywhere (the runner), and commented on
that pull request either way. It now reruns them at HEAD and at the base
first and names nobody unless HEAD fails and the base passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:11:01 +01:00
Ludovic MarieandClaude Opus 5.5 bea3165267 Merge main into feat/mod-test-runner
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:03:11 +01:00
levzzzand[CC] <deepseek-v4.1-flash> 276f8d49c1 Chat: a LAN guest's line follows the host's Chat option
Review of #1932 (rdbtCVS): with `--in-frame`, opening a world to LAN and
setting Commands Only still showed a guest's ordinary chat on the host. The
hub's `HostEvent::Chat` went through `system_message`, which asks for
`receive_system_messages` — true at Commands Only — so the line passed.

`Game::player_message` shows a chat line as `ChatSource::Player`, which is
the permission `ChatListener` checks for player chat; `lan_log_event`
routes `HostEvent::Chat` there. `/say` and `/me` arrive as chat messages
too (say_command, emote_command), so they follow the same permission as they
do for the guests' own packets; command feedback such as `/list` is a system
message and keeps the system-message permission.

Reproduced on the pre-fix build: host at Commands Only, guest sends
`baseline-visible` → the host shows `<Guest9> baseline-visible`. With this
change the same line stays hidden at Commands Only and shows at Shown.

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 10:03:06 +05:00
Ludovic MarieandClaude Opus 5.5 90a9d9c612 Merge main into feat/moved-hud-extras
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:02:48 +01:00
Ludovic Marie 7ecb43ccc8 Merge pull request #1982 from ludovic111/feat/official-hud-mods
🤖 Agent: Horizon's official FPS Counter and Keystrokes as mods in mods/official/, same options and look as the extras (pixel-identical, frame cost within noise). Main merged in (wasmtime 48). Verified: pr_ready, clippy and tests of both mods, horizon-mods tests, both mods' settings pages in game.
2026-10-26 06:02:08 +01:00
Ludovic MarieandClaude Opus 5.5 d113131f7e Merge feat/official-zoom-mod into feat/moved-zoom
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:55:10 +01:00
Ludovic MarieandClaude Opus 5.5 82deb28954 Merge main into feat/mod-ui-key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:54:27 +01:00
levzzzand[CC] <deepseek-v4.1-flash> bf2961834b Item cooldowns: tick them on the server client's path too
Review of #1933 (rdbtCVS): `Game::tick` returns through `tick_remote` whenever
`remote` is set — every multiplayer session and the default single player — so
the `tick_world` call that advanced the entries never ran there. A thrown
pearl's slot stayed at full shade until the server's duration-0 packet arrived
instead of fading over the second.

`tick_remote_level` advances them now (Player.tick's ItemCooldowns.tick); the
groups that end are dropped silently, as the client's plain ItemCooldowns
does.

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 09:52:00 +05:00
Ludovic MarieandClaude Opus 5.5 8fdb27f949 Zoom: the game lists its key on the settings page, as "Zoom key: Z"
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:47:15 +01:00
Ludovic Marie e372fce7ab Merge branch 'feat/mod-ui-key' into feat/official-zoom-mod 2026-10-26 05:35:16 +01:00
Ludovic MarieandClaude Opus 5.5 1aa6f21620 Horizon shader packs: the post stage runs in the game, with hot reload
A native pack named by HORIZON_SHADER_PACK (a folder with horizon-shaders.toml
or a .hshader in shaderpacks/) is checked, then its post passes run over the
world's own target, the one a shaders/ pack reads: no second target and no
extra pass, and without a pack the frame only tests one Option. The pack is
looked at twice a second; a saved file is checked and rebuilt in place, and a
version that fails is refused in the chat while the last good one keeps
drawing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:35:00 +01:00
Ludovic MarieandClaude Opus 5.5 3e3c0cebb5 Mod keys: rows the game adds to the settings page, not a WIT widget
A new case in ui's widget variant stops every ui mod built before it from
loading: the runtime links a component only when each imported
function's types match exactly ("expected variant of 5 cases, found
4"), so the published FPS Counter and Keystrokes would break. Instead,
the game lists a mod's keys first on its page in Options > Mods, as Key
Binds rows, and a mod with keys gets a page even without ui. No WIT
type changes. docs/api/stability.md said a new case the mod sends isn't
breaking; it is, and now says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:34:49 +01:00
Ludovic MarieandClaude Opus 5.5 3df60c21c2 tests: compare resolved paths in the prepared-report fallback test
macOS temp dirs live under /var, a symlink to /private/var, so the
resolved report path never equalled the unresolved fallback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:28:30 +01:00
Ludovic Marie 8cf51888c6 Merge pull request #1757 from ludovic111/feat/structure-saved-starts
🤖 Agent: structures stack brought up to date with main (merge commits); queued with verify passed, the merger waits for parity on this head.
2026-10-26 05:22:52 +01:00
rdbtCVSandOpenAI Codex 03a3c48a71 Fix Peaceful removal of existing hostile mobs
Apply the vanilla Mob.checkDespawn Peaceful gate before persistence and player distance checks. Extend the mob AI parity harness with difficulty, persistence, name-tag and NoAI regressions and allowed-type controls.

Fixes #1942

Co-Authored-By: OpenAI Codex (GPT-6.1 Sol) <[email protected]>
2026-10-26 04:18:54 +00:00
Ludovic Marie b485c43928 Merge pull request #2017 from ludovic111/ci/vanilla-data-new-shared-entries
🤖 Agent: CI script fix, tested on scratch folders. The release runner had no sounds, so parity-main's audio harnesses failed.
2026-10-26 05:18:40 +01:00
rdbtCVSandOpenCode f70b39290d Merge origin/main into t3/fix-critical-hit-particles
Include main's latest mod-input changes before validation.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-26 04:14:52 +00:00
rdbtCVSandOpenCode 70f1b71de1 Merge origin/main into t3/fix-critical-hit-particles
Keep both critical-hit and sculk container regressions.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-26 04:14:11 +00:00
Ludovic Marie d8aaf367eb Merge pull request #2013 from ludovic111/feat/mod-api-zoom
🤖 Agent: Mod API (experimental): render.set-zoom and input.claim-wheel, held mod keys let go when a screen opens, mod keys on mouse buttons. The consume choice is in stability.md. Verified: pr_ready (1300 tests), sandbox tests, in game 22/22 in-frame and on the server thread.
2026-10-26 05:12:07 +01:00
vlesmentieandClaude Opus 5.5 3d401e55f4 Merge origin/main into feat/campfire-cooking
Keeps both new block entities: BlockEntity::Campfire (this branch) and
BlockEntity::Skull (main), in containers.rs's enum, id() and the menu
lists in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:10:29 -03:00
Ludovic MarieandClaude Opus 5.5 5335354658 CI: runners clone what the shared source gained after their first copy
The release runner, now parity-main's, cloned the shared source before
the sounds were added to it; vanilla-data.sh only copied on the first
run, so ambient_sound, audio_attributes and music failed there with
"Sound assets missing". Each run now clones any entry the runner's copy
lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:10:19 +01:00
Ludovic MarieandClaude Opus 5.5 0cb1d46393 Merge main into feat/official-hud-mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:09:45 +01:00
Ludovic MarieandClaude Opus 5.5 53289d3565 Official mod: Zoom, the zoom extra as a mod
mods/official/zoom: hold the key (Z) to zoom in at the starting zoom,
the wheel changes it while held (x1.25 a step, 1.25 to 10), smooth
easing and slower turning as options. Same settings page as the extra,
with the key widget for the key. Built on render.set-zoom,
input.claim-wheel and ui's key widget.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 05:08:36 +01:00
Ludovic Marie ab92ab346c Merge remote-tracking branch 'origin/main' into cg15/1909 2026-10-26 05:06:59 +01:00
Ludovic Marie 88d061ec9b Merge pull request #1955 from rdbtCVS/t3/migrate-parity-server-thread
🤖 Agent: Thanks rdbt! The command, dispenser and sculk fixtures now run on a server Game. Checked:
2026-10-26 05:06:41 +01:00
Ludovic Marie 4231905b1e Merge pull request #1967 from socksprox/fix/mannequin-npcs
🤖 Agent: Thanks socksprox! Mannequins render as the player model with their profile skin. I pushed d3af1e04e: a mannequin is a LivingEntity, not a Mob, so it runs no goals and never despawns. Checked in game: a summoned mannequin stays put for 400 ticks on the server thread and falls to the ground in-frame (it wandered 14 blocks before). Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 05:00:59 +01:00
Ludovic Marie 1114b4d734 Merge pull request #1933 from levzzz5154/fix/item-cooldowns
🤖 Agent: Thanks levzzz5154! Matches ItemCooldowns and the slot sweep in Gui.renderSlot; thrown, eat and shield pass on both hosts. I merged main and dropped orphaned doc comments. Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:58:42 +01:00
Ludovic Marie c31e8b2381 Merge pull request #1932 from levzzz5154/fix/chat-visibility
🤖 Agent: Thanks levzzz5154! Matches ChatComponent's ChatVisiblity filter for the Full, Commands only and Hidden options. I pushed 1e6f05ed5 earlier: every line keeps its source, so changing the option refilters the history both ways (new test). Verify had failed because main's mod output (#1875) calls Chat::add without a source. I merged main, and 2256941af marks a mod's lines as the client's own, which the Chat option keeps. Clippy and the chat tests pass after the merge. Before it, the wave 2 batch passed its 127 affected harnesses, and lan_e2e chat passed with the host in-frame and on its server thread.
2026-10-26 04:54:32 +01:00
Ludovic Marie 2e05b11a4d Merge remote-tracking branch 'origin/feat/official-hud-mods' into feat/official-zoom-mod 2026-10-26 04:53:36 +01:00
Ludovic Marie 23d4bc0a69 Merge pull request #1905 from rdbtCVS/fix/server-player-sculk-events
🤖 Agent: Thanks rdbt! Every server player now emits its own movement game events, with per-player sculk history; the new server_player_sculk harness passes. I merged main (the guest swap code). Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:52:23 +01:00
Ludovic Marie f1ab726503 Merge pull request #1897 from vlesmentie/fix/pointing-hand-cursor
🤖 Agent: Thanks vlesmentie! Matches vanilla 26.3's AbstractWidget.handleCursor: buttons show the pointing hand. lan_e2e's cursor check passes. Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:49:09 +01:00
Ludovic Marie 061ae61465 Merge pull request #1963 from ludovic111/feat/mod-api-extras
🤖 Agent: Mod API calls the official HUD mods use: render corner panels, text width and frame stats; input mappings and clicks per second; a ui button widget. Main merged in (after #1914's widget refactor). Verified: horizon-mods tests, clippy with and without mods, horizon-client tests (1121 lib), Keystrokes' settings page in game.
2026-10-26 04:45:01 +01:00
Ludovic Marie 5320391ff6 Merge pull request #1904 from rdbtCVS/fix/remote-fireball-motion
🤖 Agent: Thanks rdbt! Matches the client's LargeFireball tick between tracker updates; the new remote_fireball harness passes and lan_e2e's fireball check sees it on both sides. Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:42:27 +01:00
Ludovic MarieandClaude Opus 5.5 7e4012c83e Mod API: a key widget, so players bind a mod's keys (ui, input)
A ui page's key widget shows one of the mod's keys as Key Binds does
("Zoom: Z"). A click waits for the next key or mouse button, which
becomes the key; Esc binds none. The game keeps the choice in
horizon-mods.txt (key.<mod>.<name>:key.keyboard.z), applies it when
the mods load and to keys registered later, and lets a held key go when
it is rebound. register-key's key is now the default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 04:41:12 +01:00
Ludovic Marie c94c4e140f Merge pull request #1915 from vlesmentie/fix/big-dripleaf-tilt
🤖 Agent: Thanks vlesmentie! Matches BigDripleafBlock.entityInside and canEntityTilt (the 0.6875 height check): a big dripleaf tilts under a player standing on it. Tested with the wave 2 batch: fmt, clippy and the horizon-client and horizon-world tests pass, and all 127 affected harnesses pass. The chunk_generation and decorated_region reruns took 8 minutes with no chunk differing. lan_e2e contacts and the rest of wave 2's checks pass with the host in-frame and on its server thread.
2026-10-26 04:39:41 +01:00
Ludovic Marie 3d8cf1f53a Merge pull request #1710 from ludovic111/fix/remote-tnt
🤖 Agent: main merged into the branch (merge commit); verify passed on this head, the merger waits for parity.
2026-10-26 04:36:46 +01:00
Ludovic Marie 5a79c8dede Merge pull request #1997 from ludovic111/fix/server-ops-feedback-save-wait
🤖 Agent: ready. server_ops_feedback waits for vanilla's and Horizon's 'Saved the game' before the save step's 2 s, so a loaded runner (the desktop's full trial) no longer cuts the transcript. Harness only. verify passed; parity ran server_ops_feedback on the Mac: PASS 120 s.
2026-10-26 04:34:33 +01:00
Ludovic Marie 2979303263 Merge pull request #1977 from ludovic111/perf/collision-scan-edges
🤖 Agent: requeued after a runner-setup parity failure (subtitle: missing sounds.json on the runner); 30/31 harnesses passed, the 56 selected passed locally. Queued by perf-next-2.
2026-10-26 04:33:56 +01:00
Ludovic Marie ce266e60be Merge pull request #1741 from ludovic111/feat/styled-menu-titles
🤖 Agent: server dialogs stack brought up to date with main (merge commits); verify passed on this head.
2026-10-26 04:31:44 +01:00
Ludovic Marie e410a3977b Merge pull request #1735 from ludovic111/feat/server-dialog-inputs
🤖 Agent: server dialogs stack brought up to date with main (merge commits); verify passed on this head.
2026-10-26 04:29:32 +01:00
Ludovic Marie d177a96be6 Merge pull request #1916 from ludovic111/feat/shader-packs-shadow-casters
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:27:16 +01:00
Ludovic Marie 452c444933 Merge pull request #1918 from vlesmentie/fix/water-cauldron-extinguish
🤖 Agent: Thanks vlesmentie! Matches LayeredCauldronBlock.entityInside / handleEntityOnFireInside: a burning player is put out and the cauldron loses a level. The cauldron harness passes, and lava and contacts pass on both hosts. Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:24:50 +01:00
Ludovic Marie a4eefb0730 Merge remote-tracking branch 'origin/main' into cg15/1955
# Conflicts:
#	tools/lan_e2e.py
2026-10-26 04:24:12 +01:00
Ludovic Marie f72a63ca01 Merge pull request #1869 from ludovic111/fix/prepare-rewritten-version-file
🤖 Agent: verified: the fix for #1772 plus #1947 merged in. Its parity job prepares with this preparer and checks that the output is still OUTPUT_VERSION 1, so with #1946 players keep their prepared resources. Merging.
2026-10-26 04:24:12 +01:00
vlesmentieandClaude Opus 5.5 5febf0d957 Campfires cook food
Horizon had no CampfireBlockEntity, so right-clicking a campfire with raw
food did nothing. Now:

- BlockEntity::Campfire keeps four slots with their cookingProgress and
  cookingTime, saved as Items, CookingTimes and CookingTotalTimes; both
  campfires share the minecraft:campfire block entity type. It is not a
  Container (only Clearable), so hoppers don't reach in.
- CampfireBlock.useItemOn: a campfire_cooking input goes into the first
  free slot (placeFood) with its recipe's time; the use is consumed
  whether or not there was room, the item is used up outside creative,
  interact_with_campfire is awarded and a block_change goes out. A client
  of a server leaves it to the server.
- cookTick (lit): each item's progress rises; a finished item's result
  drops at the block (Containers.dropItemStack), the slot empties and a
  block_change goes out. cooldownTick (unlit): progress falls by 2.
- Breaking the campfire drops its items (preRemoveSideEffects).
- The update tag (Items) reaches a server's clients, and CampfireRenderer
  draws the items on the fire.

Fixes #1988.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:24:10 -03:00
Ludovic MarieandClaude Opus 5.5 2256941af7 Chat: mod output lines are the client's own
Main's mod output calls Chat::add without the new source; a mod's line is
written by this client, so the Chat option keeps it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 04:23:25 +01:00
Ludovic Marie 6e15257873 Merge remote-tracking branch 'origin/main' into cg15/1932
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-26 04:23:24 +01:00
Ludovic Marie 1d83584da4 Merge pull request #1900 from ludovic111/feat/shader-packs-shadow-entities
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:22:09 +01:00
Ludovic Marie 8d6754226e Merge pull request #1934 from socksprox/feat/player-head-skins
🤖 Agent: Thanks socksprox! Player heads now show their owner's skin, fetched like vanilla's SkullBlockRenderer from the profile's textures property; TEXTURE_URIS limits it to textures.minecraft.net, so there's no new host.
2026-10-26 04:19:47 +01:00
Ludovic Marie 2ae2e57c66 Merge pull request #1449 from ludovic111/chore/wasmtime-48
🤖 Agent: main merged into the branch (merge commit); verify passed on this head.
2026-10-26 04:19:14 +01:00
Ludovic MarieandClaude Opus 5.5 de294e34bb rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 04:19:07 +01:00
Ludovic Marie fed2bdb304 Merge pull request #1892 from vlesmentie/fix/chicken-flap-remote
🤖 Agent: Thanks vlesmentie! Matches Chicken.aiStep's flap on the client of a server. I merged main and slimmed the test to reuse an existing mob kind. Tested together with the other wave 2 contributor PRs: fmt, clippy -D warnings, and the horizon-client, horizon-world and horizon-protocol tests pass. Of the 127 harnesses the batch affects, 124 pass; the other three are a player-head mismatch fixed in #1934 and two worldgen timeouts on a loaded machine with no chunk differing. lan_e2e chat, fireball, cursor, looks, thrown, eat, shield, lava, contacts, projectiles and mobs pass with the host in-frame and on its server thread.
2026-10-26 04:17:55 +01:00
Ludovic Marie 4aca4e151a Merge pull request #1734 from ludovic111/feat/server-dialog-screen
🤖 Agent: server dialogs stack brought up to date with main (merge commits); verify passed on this head.
2026-10-26 04:17:30 +01:00
Ludovic Marie 1faafb9f90 Merge pull request #1888 from ludovic111/feat/shader-packs-shadow-terrain
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:16:44 +01:00
Ludovic Marie 6bbf40e345 Merge pull request #1753 from ludovic111/feat/structure-blocks-harness
🤖 Agent: structures stack brought up to date with main (merge commits), verify and parity passed on this head.
2026-10-26 04:13:52 +01:00
Ludovic Marie 4d460bfb3a Merge pull request #1887 from ludovic111/feat/shader-packs-shadow-buffers
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:10:49 +01:00
Ludovic MarieandClaude Opus 5.5 c89434557b Player heads without a skin show slim steve, as vanilla
SkullBlockRenderer's SKIN_BY_TYPE is DefaultPlayerSkin.getDefaultTexture(),
DEFAULT_SKINS[6] = entity/player/slim/steve; the block_entity_render harness
found Horizon drawing nothing for heads without a profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 04:09:25 +01:00
Ludovic Marie d208121c8e Merge pull request #1886 from ludovic111/feat/shader-packs-shadow-programs
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:08:13 +01:00
Ludovic MarieandClaude Sonnet 5.5 55c961b889 Merge origin/main into fix/remote-tnt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 04:07:18 +01:00
Ludovic Marie 1880e12f2b Merge pull request #1885 from ludovic111/feat/shader-packs-shadow-camera
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 04:05:36 +01:00
Alexander KrylovandHermes Agent d41aa95dbd Mob models: one Poses per mob per frame, not one per equipment layer
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 06:04:06 +03:00
Ludovic MarieandClaude Sonnet 5.5 7d4792fb31 Mods API reference test: follow wit-parser 0.254 (FixedLengthList, Param)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 04:03:03 +01:00
Alexander KrylovandHermes Agent dcc5899692 Name tags: collect the ridden mobs once, not per tag
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 06:00:36 +03:00
Alexander KrylovandHermes Agent 51911080f0 Mob models: one skip lookup per part, not one per polygon
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 05:58:53 +03:00
Alexander KrylovandHermes Agent 1dcb33f111 Merge main into perf/no-double-cracks-and-ticker-copy
The profiling marks that landed in main (mark_entity, mark_stage) collide with
the cached cracks scan; both are kept.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 05:55:32 +03:00
Ludovic Marie 163f099ecb Merge pull request #2006 from ludovic111/perf/behaviour-cache-hash-once
🤖 Agent: pr_ready and 91 harnesses pass (shared run, body); the duplicate 70 MB hash at world entry removed. Queued by perf-next-2.
2026-10-26 03:41:58 +01:00
Ludovic Marie 6d92727d9d Merge pull request #2004 from ludovic111/perf/sculk-ticking-chunks
🤖 Agent: pr_ready and 91 harnesses pass (shared run, body); tick_sculk_blocks halved at 10 bots, −75% at 25. Queued by perf-next-2.
2026-10-26 03:38:40 +01:00
Ludovic MarieandClaude Sonnet 5.5 0bfb011ee2 Merge feat/structure-saved-starts into feat/lithostitched-processors
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:38:00 +01:00
Ludovic MarieandClaude Sonnet 5.5 b4700c0a9d Merge feat/structure-blocks-harness into feat/structure-saved-starts
Both branches changed how the harness finds the mod jars: resolve every jar
through jar_path (jars, then pack-jars) and keep the profile packs check.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:36:24 +01:00
Ludovic Marie 2b4e1fd03b Merge pull request #1944 from ludovic111/feat/mod-input
🤖 Agent: Mod API capability input (experimental): claimed items' use and the mod's keys. Main merged in beside command and render (9 capabilities). Verified: horizon-mods tests (sandbox, API reference), clippy with and without mods, horizon-client tests (1121 lib), earlier pr_ready and in-game runs in both modes.
2026-10-26 03:35:25 +01:00
Ludovic MarieandClaude Opus 5.5 2631eca00b Drop the migration's validation ledger from the docs
It records an agent's runs and diagnoses, which the pull request and its
branch history already keep; docs/ is for how the project works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:32:44 +01:00
Ludovic MarieandClaude Sonnet 5.5 355df65240 Merge feat/yung-port-as-written into feat/structure-blocks-harness
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:31:39 +01:00
Ludovic Marie c7c4cfe996 Merge pull request #1801 from ludovic111/feat/shader-packs-hand
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:30:48 +01:00
Ludovic Marie ef4018805e Merge pull request #2008 from ludovic111/ci/parity-main-vanilla-data
🤖 Agent: verify passed at 0c84eb307. Merged by hand: the PR parity job is queued behind the same runners, and this changes only how that job prepares its data. The next PR parity job on ludovics-mac-mini is the real test.
2026-10-26 03:27:46 +01:00
Ludovic Marie 905464e2b3 Merge pull request #2005 from ludovic111/perf/rings-out-of-reach
🤖 Agent: pr_ready and 91 harnesses pass (shared run, body, incl. decorated_region's stronghold site and world_spawn); first chunk 8.7 → 6.0 s on 4 threads. Queued by perf-next-2.
2026-10-26 03:27:25 +01:00
Ludovic Marie 6c2effae82 Merge pull request #1931 from levzzz5154/fix/draggable-scrollbars
🤖 Agent: Thanks levzzz5154! The Game Rules and Statistics scroll bars can be pressed, dragged and released like vanilla's; I merged main and pushed a41381236 (Statistics uses the GUI height; #1913's drag test is folded in). Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 03:24:52 +01:00
vlesmentieandClaude Opus 5.5 9572330ee4 A villager's new profession rolls its trades
Villager.setVillagerData sets offers to null when the profession changes,
and getOffers rolls the new profession's trades on the next look.
set_profession cleared the offers but left unset false, so ensure_offers
never rolled new ones: a villager that took a placed workstation after
losing its first wore the new profession but only shook its head.
It now marks the offers unset, as vanilla's null.

Fixes #1998.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:24:25 -03:00
Ludovic MarieandClaude Sonnet 5.5 d322c4b870 Merge feat/server-dialog-screen into feat/styled-menu-titles
Both branches changed the machine screens' title drawing: keep the styled server
title from this branch and the translated container title and inventory label
from feat/server-dialog-screen.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:22:13 +01:00
Ludovic Marie 4bfb7f6506 Merge pull request #1965 from rdbtCVS/t3/optimize-launcher-resource-prepare
🤖 Agent: Thanks rdbt, preparation needs only a Java runtime now and runs 5 JVMs instead of 18! Verified on the Mac mini:
2026-10-26 03:21:56 +01:00
Ludovic Marie ba105e0784 Merge branch 'feat/official-hud-mods' into feat/moved-hud-extras 2026-10-26 03:20:09 +01:00
Ludovic MarieandClaude Opus 5.5 980c619b81 Merge feat/mod-api-extras (with main) into feat/official-hud-mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:20:09 +01:00
Ludovic Marie 9f42b39aca Merge pull request #2002 from ludovic111/ci/parity-main-own-runner
🤖 Agent: verify passed at 2c612fd79. The PR parity job is still queued behind the same runners, and it can't test a runs-on change, so I merge by hand.
2026-10-26 03:19:47 +01:00
Ludovic Marie 1e317cff5e Merge branch 'feat/mod-api-extras' into feat/mod-api-zoom 2026-10-26 03:19:44 +01:00
Ludovic Marie 0f47d2a028 Merge pull request #1800 from ludovic111/feat/shader-packs-held-items
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:19:36 +01:00
Ludovic MarieandClaude Sonnet 5.5 0c73bb3c90 Merge feat/server-dialog-screen into feat/server-dialog-inputs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:18:45 +01:00
Ludovic MarieandClaude Opus 5.5 0c84eb3079 ci: PR parity jobs prepare vanilla data with main's script
A PR head older than the fix in vanilla-data.sh left a real
.local/horizon-client without sounds.json on runner ludovics-mac-mini;
every later job there failed subtitle. Like the harness selection, the
job now runs main's script unless the branch changes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:18:39 +01:00
Ludovic Marie 666a1ba6f7 Merge pull request #1786 from ludovic111/feat/shader-packs-weather
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:16:38 +01:00
Ludovic MarieandClaude Sonnet 5.5 a1ffa1ac11 Merge feat/server-dialogs into feat/server-dialog-screen
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:14:19 +01:00
Ludovic MarieandClaude Opus 5.5 50c54098ef Merge feat/mod-input (with main) into feat/mod-api-extras
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:13:27 +01:00
rdbtCVSandCodex GPT-6.1 Sol 4cc134d08a Fix passenger walk animation in shared render state
Zero passenger walk position and speed like vanilla LivingEntityRenderer, covering local and remote riders. Extend entity model parity with moving passenger and on-foot render-state cases.

Fixes #1962

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-10-26 02:13:12 +00:00
Ludovic Marie 9bf96bcea5 Merge pull request #1785 from ludovic111/feat/shader-packs-clouds
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:12:49 +01:00
Ludovic Marie 4457d4e0ca Merge remote-tracking branch 'origin/main' into perf/behaviour-cache-hash-once 2026-10-26 03:12:43 +01:00
Ludovic Marie 96290f4b94 Merge remote-tracking branch 'origin/main' into perf/rings-out-of-reach 2026-10-26 03:12:31 +01:00
Ludovic Marie facc1d4d08 Merge remote-tracking branch 'origin/main' into perf/sculk-ticking-chunks 2026-10-26 03:12:20 +01:00
rdbtCVSandOpenCode GPT-6.1 Sol 15d99a6856 Start bat animation clocks on server clients
Fixes #2000.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 02:11:48 +00:00
Ludovic Marie 8dfe518b60 Merge pull request #1911 from vlesmentie/fix/structure-chest-enchantments
🤖 Agent: Thanks vlesmentie! Structure chests keep the enchantments their loot table rolled. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 03:11:16 +01:00
Ludovic MarieandClaude Opus 5.5 2c612fd791 ci: parity-main on the release runner, not behind PR parity jobs
The queued parity-main run waited 2 h behind PR parity jobs on the two
horizon-game runners, each merge replacing the pending one, and releases
need it green. The release runner gets a horizon-parity-main label; a
release only starts after parity-main passed, so the two never compete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:10:55 +01:00
Ludovic MarieandClaude Opus 5.5 48db367d4a Mod API: zoom the camera, take the mouse wheel (render, input)
render.set-zoom divides the field of view by up to 10, eased through the
game's FOV easing or at once, with a turning scale (at least 0.05); only
while playing, and the spyglass's own view wins. The strongest of several
mods' zooms counts, and the built-in zoom keeps working beside it.

input.claim-wheel takes the wheel from the hotbar while playing: its steps
are the mod's wheel events. That is the one input a mod consumes; its keys
never do (docs/api/stability.md). A screen opening now lets the mod's held
keys up, and mod keys bound to mouse buttons (key.mouse.4) are heard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:09:29 +01:00
Ludovic MarieandClaude Opus 5.5 b7b7cd6c50 Merge main into feat/mod-input: input beside command and render
Capability::ALL is 9 with Input; the SDK re-exports command and input;
the API reference is regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:09:13 +01:00
Ludovic Marie 74b08e6310 Merge pull request #1928 from levzzz5154/fix/end-city-banner-patterns
🤖 Agent: Thanks levzzz5154! End city banners keep their template patterns; I pushed 401ac1322 so the structure code and the save loader share one banner decoder. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 03:08:48 +01:00
Ludovic MarieandClaude Sonnet 5.5 1dff593ac3 Merge feat/shader-packs-shadow-entities into feat/shader-packs-shadow-casters
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:05:49 +01:00
Ludovic Marie 68d1a8a388 Merge pull request #1769 from ludovic111/feat/shader-packs-entity-spans
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:04:53 +01:00
Ludovic MarieandClaude Sonnet 5.5 4292806437 Merge feat/shader-packs-shadow-terrain into feat/shader-packs-shadow-entities
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:04:24 +01:00
Ludovic MarieandClaude Sonnet 5.5 c841ab6d56 Merge feat/shader-packs-shadow-buffers into feat/shader-packs-shadow-terrain
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:02:40 +01:00
Ludovic MarieandClaude Sonnet 5.5 8b7315a6ff Merge feat/shader-packs-shadow-programs into feat/shader-packs-shadow-buffers
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:01:43 +01:00
Ludovic Marie 76be4d0e73 Merge pull request #1756 from ludovic111/feat/shader-packs-sildurs
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 03:01:05 +01:00
Ludovic MarieandClaude Sonnet 5.5 b2a7e77036 Merge feat/shader-packs-shadow-camera into feat/shader-packs-shadow-programs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 03:00:08 +01:00
Ludovic MarieandClaude Sonnet 5.5 86a2f8e470 Merge feat/shader-packs-hand into feat/shader-packs-shadow-camera
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:58:53 +01:00
Ludovic Marie 9d07d5a3e2 Merge pull request #1755 from ludovic111/feat/shader-packs-mip-current
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 02:58:43 +01:00
Ludovic Marie 298a9778a7 Merge pull request #1754 from ludovic111/feat/shader-packs-biomes
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 02:58:14 +01:00
Ludovic MarieandClaude Sonnet 5.5 ffda62db09 Merge feat/shader-packs-held-items into feat/shader-packs-hand
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:53:39 +01:00
Ludovic Marie d4526191ce Merge pull request #1744 from ludovic111/feat/shader-packs-normals
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 02:52:49 +01:00
Ludovic MarieandClaude Sonnet 5.5 ae3bbe6bb4 Merge feat/shader-packs-weather into feat/shader-packs-held-items
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:52:31 +01:00
Ludovic Marie b3fabc1743 Merge pull request #1699 from ludovic111/feat/shader-packs-run-globals
🤖 Agent: shader packs clean room: main merged into the stack (merge commits), verify passed on this head, parity skipped by parity-scope.
2026-10-26 02:51:12 +01:00
Ludovic MarieandClaude Sonnet 5.5 34397fe09d Merge feat/shader-packs-clouds into feat/shader-packs-weather
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:50:35 +01:00
Ludovic Marie c56102125b Merge pull request #1799 from cristibctr/codex/fix-1649-resource-pack-cache
🤖 Agent: Thanks cristibctr! Windows pack cache recovery; it bumps the cache version, so every platform rebuilds that cache once. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:50:17 +01:00
Ludovic MarieandClaude Sonnet 5.5 94c1b5bfaa Merge feat/shader-packs-translucent-entities into feat/shader-packs-clouds
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:49:32 +01:00
Ludovic MarieandClaude Sonnet 5.5 eab457d0ae Merge feat/shader-packs-entities into feat/shader-packs-translucent-entities
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:48:31 +01:00
Ludovic Marie b3bc8e807e Merge pull request #1946 from ludovic111/feat/prepare-output-version
🤖 Agent: verified: the launcher keeps prepared resources while the preparer's OUTPUT_VERSION (or a legacy fingerprint for v0.2.49 to v0.2.64) is the same. Unit tests and clippy pass. End to end with the real v0.2.64 release: an update to a build with OUTPUT_VERSION 1 reused its resources, and a build with the number bumped to 2 did not. Merging.
2026-10-26 02:47:48 +01:00
rdbtCVSandOpenCode be42e67e9d Merge origin/main into t3/fix-critical-hit-particles
Keep both critical-hit and replant regressions.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-26 01:45:16 +00:00
Ludovic MarieandClaude Sonnet 5.5 02248f4314 Merge feat/shader-packs-alpha-test into feat/shader-packs-entities
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:45:08 +01:00
Ludovic Marie dd1581c062 Merge remote-tracking branch 'origin/main' into cg15/1965
# Conflicts:
#	crates/horizon-prepare/src/lib.rs
2026-10-26 02:44:23 +01:00
Ludovic Marie 5450f5e872 Merge pull request #1698 from ludovic111/feat/shader-packs-terrain-draw
🤖 Agent: shader packs clean room 14 brought up to date with main (merge commit); verify passed on this head; parity skipped by parity-scope.
2026-10-26 02:44:07 +01:00
Ludovic MarieandClaude Sonnet 5.5 b0fd6e660e Merge feat/shader-packs-entity-spans into feat/shader-packs-alpha-test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:41:50 +01:00
Ludovic Marie 5ba4793d35 Merge pull request #1960 from rdbtCVS/t3/optimize-launcher-resource-prepare-1
🤖 Agent: Thanks rdbt! Verified on the Mac mini:
2026-10-26 02:41:36 +01:00
rdbtCVSandOpenCode 5e2871dbfd Show critical-hit particles on server clients
Queue successful mob and player crit animations until the hub can send them to the attacker and tracking clients. Cover falling, grounded and refused attacks in both host modes. Fixes #1898.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-26 01:39:30 +00:00
Ludovic Marie f565f67ee5 Merge pull request #1958 from rdbtCVS/t3/item-frame-name-display
🤖 Agent: Thanks rdbt! Matches ItemFrameRenderer: a targeted frame shows its item's custom name. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:39:17 +01:00
Ludovic MarieandClaude Sonnet 5.5 91b0db48e7 Merge feat/shader-packs-sildurs into feat/shader-packs-entity-spans
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:38:42 +01:00
Ludovic MarieandClaude Sonnet 5.5 01ef17a258 Merge feat/shader-packs-mip-current into feat/shader-packs-sildurs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:37:36 +01:00
Ludovic Marie 559ea59fcb Merge pull request #1952 from ludovic111/ci/linux-runner
🤖 Agent: ready. Linux runner support (runner-env/vanilla-data Linux paths, linux-runner.sh, runner_pause.py with Ludovic's lock/idle-15-min rule, heat, game, GPU and pause-file checks), desktop parity host, nightly and parity-main pinned to the Mac. verify and parity passed at this head. The desktop runner stays without the horizon-game label until its trial is identical: that is a runner setting, not in this PR. Desktop full run: 342/343 in 39.3 min (the 1 is a harness timing flake, fixed by #1997).
2026-10-26 02:36:59 +01:00
Ludovic Marie 09b3057610 Merge pull request #1926 from Domanffe/fix/enchantment-names-language
🤖 Agent: Thanks Domanffe! Enchantment names and levels follow the selected language; the server thread shares the client's language, and with none loaded (dedicated server, probes) the English names stay. I pushed a doc tweak (b7629cf65). Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:36:25 +01:00
Ludovic MarieandClaude Sonnet 5.5 2203c1777a Merge feat/shader-packs-biomes into feat/shader-packs-mip-current
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:36:22 +01:00
Ludovic MarieandClaude Sonnet 5.5 04000cb4db Merge feat/shader-packs-normals into feat/shader-packs-biomes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:35:21 +01:00
Ludovic MarieandClaude Sonnet 5.5 3f10f57178 Merge feat/shader-packs-block-ids into feat/shader-packs-normals
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:34:28 +01:00
Ludovic Marie 894576e1e9 Merge pull request #1914 from socksprox/fix/mod-ui-screen-capture
🤖 Agent: Thanks socksprox! Refactor of the mod screen widgets plus a mouse recapture when a mod's screen goes away. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:34:15 +01:00
Ludovic MarieandClaude Sonnet 5.5 2b05e2809b Merge feat/shader-packs-arena-storage into feat/shader-packs-block-ids
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:32:06 +01:00
Ludovic Marie a83cf01169 Merge pull request #1943 from socksprox/fix/particle-atlas-reload
🤖 Agent: Thanks socksprox! Particles keep their sprites across a resource reload (content root set again on reload). Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:31:57 +01:00
Ludovic MarieandClaude Sonnet 5.5 b023ab934a Merge feat/shader-packs-sun-moon into feat/shader-packs-arena-storage
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:30:35 +01:00
Ludovic Marie 834a84f198 Merge pull request #1945 from rdbtCVS/t3/fix-anvil-combining-sound
🤖 Agent: Thanks rdbt! Matches AnvilMenu, which sends the level event with a null player so every player hears it once; lan_e2e's anvil check heard it exactly once on the host and once on the guest. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:29:05 +01:00
Ludovic MarieandClaude Sonnet 5.5 181d188324 Merge feat/shader-packs-sky into feat/shader-packs-sun-moon
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:28:17 +01:00
Ludovic MarieandClaude Opus 5.5 d3af1e04e0 Mannequins run no mob goals and never despawn
A mannequin is an Avatar (a LivingEntity), not a Mob: its serverAiStep has
no goals and LivingEntity has no checkDespawn. With the passive brain a
summoned mannequin wandered off (14 blocks in 400 ticks on the server
thread) and could despawn far from players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:27:37 +01:00
Ludovic Marie 93d58b7024 Merge pull request #1954 from rdbtCVS/rdbt/fix-1949-leash-death
🤖 Agent: Thanks rdbt! Matches Leashable.tickLeash / dropLeash: a leashed mob that dies drops its lead; the new mob_ai scenarios run against the vanilla oracle. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:26:53 +01:00
Ludovic Marie d4e1b3c773 Merge pull request #1922 from rdbtCVS/t3/fix-crop-planting-harvesting
🤖 Agent: Thanks rdbt! Matches ServerPlayerGameMode.handleBlockBreakAction: an instant survival START never leaves a block being destroyed, so no repeated hit sounds and a replanted crop isn't broken again. It now includes #1966's sound regression. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:25:58 +01:00
Ludovic MarieandClaude Sonnet 5.5 f299e18fc6 Merge feat/shader-packs-draw-uniforms into feat/shader-packs-sky
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:25:46 +01:00
Ludovic Marie 1da949efbd Merge pull request #1919 from sl4ppleware/fix/heart-blink-on-set-health
🤖 Agent: Thanks sl4ppleware! Matches LocalPlayer.hurtTo: the hearts blink again when hurt, and the remote client ticks the blink through its controlled simulation tick. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:25:31 +01:00
Ludovic MarieandClaude Sonnet 5.5 0f1ab46921 Merge feat/shader-packs-mipmaps into feat/shader-packs-draw-uniforms
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:24:37 +01:00
Ludovic MarieandClaude Sonnet 5.5 f1fc01ffcf Merge feat/shader-packs-timing into feat/shader-packs-mipmaps
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:23:46 +01:00
Ludovic Marie 80e1eb20cc Merge pull request #1883 from ludovic111/fix/copper-golem-statue-poses
🤖 Agent: merged after verify (bugs-dezerx-1's fix for a player's bug list, self-checked with pr_ready); harnesses run on main by parity-main, which gates releases.
2026-10-26 02:22:56 +01:00
Ludovic MarieandClaude Sonnet 5.5 4ee54d84a8 Merge feat/shader-packs-water into feat/shader-packs-timing
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:22:48 +01:00
Ludovic Marie e93aba600b Merge pull request #1941 from rdbtCVS/t3/preserve-gui-scale-setting
🤖 Agent: Thanks rdbt! Matches vanilla: the saved GUI scale isn't clamped while a temporary small surface is up, the HUD caps the scale it draws with. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:20:26 +01:00
Ludovic MarieandClaude Opus 5.5 24b26da5d6 server_ops_feedback: wait for the save's reply before the next step
On the desktop runner (8 harnesses at a time) vanilla's save-all took 2 s,
the whole step: the transcript ended before "Saved the game" and the
harness failed with Horizon right. The save step now waits for that console
line, on both sides, before its usual 2 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:19:17 +01:00
Ludovic Marie 3bea1cd023 Merge pull request #1937 from rdbtCVS/fix/android-helper-scheduling
🤖 Agent: Thanks rdbt! Android-only cfg change to helper thread priorities; the desktop targets compile the same code as before. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:17:46 +01:00
Ludovic MarieandClaude Sonnet 5.5 e5f4d02549 Merge feat/shader-packs-real-buffers into feat/shader-packs-water
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:16:49 +01:00
Ludovic Marie a7d1d17ac9 Merge pull request #1991 from ludovic111/fix/pr-ready-affected-once
🤖 Agent: merged on verify: tooling only, it changes how fast harnesses are chosen, not which game code runs. Fixes pr_ready's 14+ min planning (affected_harnesses was called once per harness) and bounds the selection.
2026-10-26 02:15:09 +01:00
Ludovic Marie 3093f22a8a Merge pull request #1903 from rdbtCVS/fix/clear-offhand-slot-order
🤖 Agent: Thanks rdbt! Checked against vanilla's Inventory order (armour FEET to HEAD, then the off hand) when items are cleared. Tested together with the other wave 1 contributor PRs on current main: fmt, clippy -D warnings and the horizon-client tests (1,120 lib tests and the rest) pass; the 114 harnesses the batch affects pass; lan_e2e anvil, grindstone, replant, instant_digging, digging, frames and leads pass with the host in-frame and on its server thread.
2026-10-26 02:14:29 +01:00
Ludovic MarieandClaude Sonnet 5.5 f1a3409721 Merge feat/shader-packs-custom-uniforms into feat/shader-packs-real-buffers
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:14:28 +01:00
Ludovic MarieandClaude Opus 5.5 7b0192118e parity: choose harnesses faster (shared parse cache, early stop, time budget)
- The reach parse cache lives in the shared parity folder, keyed by content,
  so a new worktree starts warm (written atomically, other worktrees'
  entries kept within bounds).
- A harness's reach search stops as soon as it reaches a changed file that
  differs in more than its `mod` declarations; partial searches are not
  memoized. Declaration diffs are memoized (one git show per file).
- A harness whose changed dependency is outside crates/ is picked without a
  search (narrowing always keeps those).
- After PARITY_REACH_BUDGET seconds (default 180) the remaining harnesses
  count every source their probes link.
- crate_of is cached; no pathlib in the hot paths.

Same 79 harnesses on the 19-file diff from contrib-gate-16; CPU 19.9 s cold /
16.6 s warm before, 13.1 s cold / 9.9 s warm after (Mac mini under load).

parity.yml: skip the prepare_output check for heads made before #1947 (no
OUTPUT_VERSION, no tool): the job runs main's workflow on the head's files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:13:09 +01:00
Ludovic MarieandClaude Sonnet 5.5 ff3d57ea9c Merge feat/shader-packs-run-globals into feat/shader-packs-custom-uniforms
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:12:44 +01:00
Ludovic Marie f10a7cd880 Merge pull request #1994 from ludovic111/fix/vanilla-data-failed-prepare
🤖 Agent: ready. A failed horizon-prepare now leaves nothing in .local, and stale real .local/horizon-client* folders and preparations lacking horizon-client are cleaned at job start. Tested on a fake checkout (fail, stale, cached). verify passed; parity skipped (scope). No overlap with #1965.
2026-10-26 02:12:07 +01:00
Ludovic MarieandClaude Sonnet 5.5 b73ca494a8 Merge feat/shader-packs-terrain-draw into feat/shader-packs-run-globals
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 02:11:28 +01:00
Ludovic Marie b10d927e5e Merge pull request #1929 from ludovic111/feat/mod-render
🤖 Agent: Mod API capability render (experimental): HUD shapes and camera shake. Main merged in beside command (8 capabilities). Verified: horizon-mods tests, clippy with and without mods, earlier pr_ready, harnesses and in-game runs.
2026-10-26 02:10:10 +01:00
Ludovic MarieandClaude Opus 5.5 a9b5ca3936 Merge main: test beside main's new templates
The usage line, the docs and the website list `test` with main's
`new --template code|content`; the code template keeps tests/hello.toml.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:08:41 +01:00
Ludovic MarieandClaude Opus 5.5 07f1bcff68 Merge main: render beside main's command capability
Both add a Capability variant: the list holds 8 now. The SDK re-exports
both interfaces; the API reference is regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 02:02:00 +01:00
Ludovic MarieandClaude Sonnet 5.5 2a85b6a432 Merge origin/main into fix/remote-tnt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 01:59:53 +01:00
Ludovic MarieandClaude Sonnet 5.5 48121cb109 Merge origin/main into chore/wasmtime-48
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 01:59:44 +01:00
Ludovic MarieandClaude Sonnet 5.5 b222dd9194 Merge origin/main into feat/server-dialogs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 01:59:19 +01:00
Ludovic MarieandClaude Sonnet 5.5 35e047fb8e Merge origin/main into feat/yung-port-as-written
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 01:59:00 +01:00
Ludovic MarieandClaude Opus 5.5 d782e455a4 docs: the desktop's CI slice gets half the threads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:55:57 +01:00
Ludovic Marie ee403545ce Merge pull request #1805 from kacpherek/pr/pregen
🤖 Agent: thank you kacpherek! /pregen is in, off by default: a server turns it on with horizon-pregen=true in server.properties (server.toml [properties]), single player and LAN with --pregen. With it off, the command tree and /help match vanilla. Tested on a dedicated server through horizon-server (on: 1,369 chunks generated and saved), single player on the server thread and in-frame, and 11 harnesses including command_exec.
2026-10-26 01:54:20 +01:00
Ludovic MarieandClaude Opus 5.5 160c1e8b7a CI: a failed preparation leaves nothing in .local
When horizon-prepare failed, vanilla-data.sh deleted the preparation folder but kept what it had
already written to .local/ (horizon-client, horizon-client-motion, ...). The next job then saw those
real folders as already there: its preparation listed only horizon-client-structures in .entries,
and the checkout kept using the stale resources outside the per-key cache.

Now a failed preparation removes the .local/ entries it created; a job first removes real folders
named like preparation outputs, and makes again a preparation whose .entries lacks horizon-client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:54:02 +01:00
Ludovic Marie 98f5a811cc Merge pull request #1829 from ludovic111/fix/global-level-events
🤖 Agent: merged after verify (bugs-dezerx-1's fix for a player's bug list, self-checked with pr_ready); harnesses run on main by parity-main, which gates releases.
2026-10-26 01:50:29 +01:00
Ludovic MarieandClaude Sonnet 5.5 7bbd715c8c Merge origin/main into feat/shader-packs-terrain-draw
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 01:48:29 +01:00
Ludovic MarieandClaude Opus 5.5 22980fc8cc Extras: the FPS counter and keystrokes move to the mod browser
They are official mods now (#1982). The Extras page loses their rows and
the HUD its built-in drawing. A player who had one on gets the mod on the
first start: installed from Horizon's registry, checked as the launcher
checks it (size, SHA-256, signature, manifest), turned on, with the
corner and options carried into the mod's storage. One chat line says so
("FPS counter moved to the mod browser: installed for you"). If it can't
be installed (offline), a line says Horizon tries again at the next
start. horizon-extras.txt keeps the old keys and lists what was done in
movedToMods.

The client's horizon-mods dependency gains the `index` feature for the
check. The frame and click counters now run only for mods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:41:18 +01:00
Ludovic MarieandClaude Opus 5.5 49bbdf8954 CI: the desktop's CI slice gets half the threads
On all 24, a cargo build held ludodesktop's CPU at 92 °C and the watcher kept pausing; at half,
harnesses run at a median of 80 °C.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:38:23 +01:00
Alexander KrylovandHermes Agent 258dd8561d Frames: one scan for breaking progress, and no name formatting per crack
`entity_geometry` ran `cracks()` twice per frame: once for the block-entity
pass that draws `render_breaking`, once for the overlay near the end of the
same function. Each run walked `mining` and the `destroying` map, and formatted
`block/destroy_stage_<n>` for every crack it found before looking that string up
in the atlas.

The list is computed once, at the top of the function, and the passes read it.
The ten stage names are a const table, so a crack costs one lookup and no
allocation. Nothing between the two passes touches `mining` or `destroying`,
which are only read there, so the list the second pass used to rebuild was
always identical to the first one.

No behaviour change: the same ten sprite names, and a stage above 9 still finds
no sprite and skips the crack.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 03:34:33 +03:00
Ludovic MarieandClaude Opus 5.5 bdbf67ef93 pr_ready: work out the affected harnesses once, not once per harness
affected_here called parity.affected_harnesses inside the filter, once for
each of the 343 harnesses. Since the selection reads the Rust items each
probe reaches (#1964) one call takes about a minute and a half, so
pr_ready sat for hours before its first step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:31:38 +01:00
Joansitoh 060a37dc85 Edit the remaining text fields at the caret.
Those fields only appended, so the cursor, selection and arrow keys did nothing. A sign paste strips formatting first, matching TextFieldHelper.getClipboardContents.
2026-10-26 01:29:07 +01:00
Ludovic MarieandClaude Opus 5.5 9db6a0ee48 CI: parity-main stays on the Mac; heat check on a median, boot as the first sign; nix roots fixed
nix-build names a dev output's GC root <name>-dev, so the runner's PKG_CONFIG_PATH pointed at links
that didn't exist and every client build failed on libudev; it now uses the store path nix-build
prints. The package temperature spikes for a second under load, so the heat check takes the median
of 5 readings. Restarting the watcher no longer throttles a running job.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:23:27 +01:00
Ludovic MarieandClaude Opus 5.5 4c78d1b4f8 Merge origin/main into ci/linux-runner
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:23:12 +01:00
Ludovic MarieandClaude Opus 5.5 045bda8bfc release: give the exporters job an hour, as the Mac builds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:21:20 +01:00
Ludovic Marie b3149d43ef Merge pull request #1677 from sdraeger/feat/mod-command-capability
🤖 Agent: Mod API capability `command` and the Predator Missile example, by @sdraeger. Thank you! Main merged in, with `command` kept beside `ui` as a 0.1 preview interface. Verified: pr_ready (2106 workspace tests), and in game in-frame and on the server thread, including survival without cheats refused.
2026-10-26 01:19:35 +01:00
Ludovic MarieandClaude Opus 5.5 8b26c4cf34 Ring search thread: start once generation pauses
In the idle class the thread still slowed a fresh world's first 600 chunks
by about 3 s on a 4-thread laptop (it shares a physical core with a worker).
It now waits until no proto chunk was generated for 2 s, or a minute after
the world opened, and gives up if the world closes first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:14:39 +01:00
DanielGodriNetoandClaude 467413df5b fix(benchmark): wait for the post-teleport load before measuring
wait_loaded declared a scene loaded during the idle second right after a teleport, so village and mountain were measured with 0 chunks. Port the guard from tools/perf_bench.py: counts must change once (or stay full for 5 s) and resident chunks must reach 80% of the view circle.

Co-Authored-By: Claude <[email protected]>
2026-10-25 21:13:23 -03:00
Ludovic MarieandClaude Opus 5.5 bb3a289caa Read the click's changed slots for the machine's setChanged
The menu op already lists the slots the click changed; a container slot
among them is the Slot.setChanged that saves the block entity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:07:48 +01:00
Ludovic MarieandClaude Opus 5.5 7dfd4c6280 Share the player's water and lava tags with dropped items
The item's fluid world had its own copy of the FluidTags.WATER/LAVA
membership the player's immersion uses; motion now exports that one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:04:20 +01:00
Ludovic MarieandClaude Opus 5.5 31eecdc271 Compute a firework's blast distance in floats, as Entity.distanceTo does
Entity.distanceTo casts each offset to float and sums the squares in
floats; taking the double distance and rounding it gave a slightly
different damage at the edge of rounding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:03:25 +01:00
Ludovic MarieandClaude Opus 5.5 1dd47581de Ring search thread: the idle class, not behind the server
On a 4-thread laptop the background search at nice +10 took a share of the
cores from the chunk workers: 600 chunks arrived 3-4 s later. The thread now
runs only on cores nothing else wants (Linux SCHED_IDLE, macOS background
QoS, Windows idle priority): horizon_threads::idle_current.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:59:15 +01:00
socksproxandDevin 920a728989 World text parity: same-frame glyph uploads, display lines, name-tag colours
Atlas patches rasterized while building world text (display text, name
tags, sign glyphs, map items) drained into the HUD batch before
entity_geometry ran, uploading a frame late: for that frame the quads
sampled unwritten atlas cells, so text flickered or showed the wrong
glyphs whenever content changed or new text entered view.

Display text also lined up 10 px apart where vanilla's lineHeight is 9,
its background quad stopped a pixel short of vanilla's -1..width+1 fill,
and name tags used 0x7F where renderNameTag's colours are 0x20FFFFFF
(discrete) and 0x80FFFFFF (see-through). lightCoordsWithEmission floors
only the block channel at 2 and leaves the sky alone, and a discrete
name's background draws lit in the normal pass rather than unlit.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 00:56:00 +01:00
Ludovic Marie e1a7892361 Merge pull request #1985 from ludovic111/perf/parity-disk
🤖 Agent: merged on verify. CI speed (Ludovic's focus tonight); measurements in the body and in horizon-parity/ci-speed/LOG.md.
2026-10-26 00:52:57 +01:00
Ludovic Marie e9f93c656a Merge pull request #1981 from ludovic111/ci/parity-main
🤖 Agent: merged on verify. CI speed (Ludovic's focus tonight); measurements in the body and in horizon-parity/ci-speed/LOG.md.
2026-10-26 00:48:22 +01:00
Ludovic MarieandClaude Opus 5.5 eec2e87a15 Merge main: command beside main's ui capability
Conflicts kept both sides: the capability list (now 7), the WIT, the
SDK's re-exports and docs, the mods state's fields, the workspace
members and AGENTS.md's macOS --exclude list. The API reference is
regenerated, and docs/api/stability.md lists `command` with the 0.1
preview interfaces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:48:02 +01:00
Ludovic Marie 373d9c08c0 Merge pull request #1947 from ludovic111/feat/prepare-output-check
🤖 Agent: verified: OUTPUT_VERSION = 1 and output-versions.txt carry the digest measured on 10 CI preparations. tools/prepare_output.py check passes on v0.2.64's preparation and fails on v0.2.47's. This head's own parity job prepares with its preparer and checks the digest. Merging.
2026-10-26 00:47:05 +01:00
Ludovic Marie 911ca721de Merge pull request #1983 from ludovic111/perf/cover-test-debug
🤖 Agent: merged on verify. CI speed (Ludovic's focus tonight); measurements in the body and in horizon-parity/ci-speed/LOG.md.
2026-10-26 00:46:46 +01:00
Ludovic MarieandClaude Opus 5.5 5034e4557d parity: clear rust-<name>-parity outputs after a pass, reuse oracles on CI
Disk: clear_large_outputs only looked at .local directories named after the
harness, so the 174 harnesses writing to rust-<name>-parity kept every dump
(86 GB in one runner checkout, 13 GB for state_provider alone). It now
clears those too, keeps vanilla outputs only up to 256 MB (larger ones cost
more disk than rerunning their oracle), and handles shard names.
collision, world and geometry dropped one mkdtemp run directory per run
(11 GB): each run now removes the earlier ones.

Time: harnesses inherited the runner's stdin, a pipe on GitHub's runner, and
parity_shim never reuses an oracle run whose stdin is a pipe, so oracle reuse
never worked on CI. Harnesses now get /dev/null.

CI: parity.yml falls back to the head's own selection when the names main's
`--list` printed don't match its summary count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:44:22 +01:00
Ludovic MarieandClaude Opus 5.5 40f79354d6 release: build the preparer and compile the exporters fresh in the exporters job
The clean checkout removes target/, and vanilla-data.sh builds horizon-prepare only when it
prepares afresh, so compile-exporters had no binary on a cached preparation. The bundle is
also compiled in the job instead of reused from .local/exporters, which vanilla-data.sh links
to preparations that pull requests' parity jobs write, and the job fails if anything but our
classes and the manifest would leave the runner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:40:43 +01:00
rdbtCVSandCodex gpt-6.1-sol 2d088739e5 Fix dropped items following fluid currents
Sample item immersion and apply shared entity currents before and after movement, matching vanilla tick order. Cover item-sized fluid scans and flowing-water movement.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-10-25 23:40:17 +00:00
Ludovic MarieandClaude Opus 5.5 0a8af38ac4 Rings: search them on a thread behind the server, as the world opens
The previous commit decides chunks far from the rings without them. The
128 biome searches now start on a helper thread as an overworld with
structures opens, behind the server and render threads, so an eye of ender
or a chunk near a ring finds them done or shares what is left, instead of
running them all on the thread that asked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:39:18 +01:00
Ludovic MarieandClaude Opus 5.5 40c08c2d12 streaming: check every fourth cell of the cover test in debug builds
the_grid_answers_as_the_scan compared the grid with the scan on every cell of
60 cases and took 20 of the ~30 s of `cargo test -p horizon-client` on the
ThinkPad. A debug build now checks every fourth cell of each row, shifted by
row and case, so the cases together still meet every cell position relative
to the areas; a release build checks every cell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:38:47 +01:00
Ludovic MarieandClaude Opus 5.5 73da2f04dd CI: parity on main, a batch of merges at a time; releases wait for it
parity-main runs the vanilla harnesses on every push to main, one run at a
time: merges that land during a run wait in one pending run, which checks
every merge since main's last green parity-main (tools/parity_main.py base,
then parity.py --changed). A failure is bisected over the batch's
first-parent merges, running only the failing harnesses, and the breaking
merge's pull request gets a comment.

Game releases ship only a commit both checks and parity-main passed on
(release_train.py; release.yml also starts when parity-main finishes; a
forced release doesn't wait). The per-PR parity job now runs only for a PR
that changes a harness, an oracle, a probe or the runner, or carries
run-parity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:35:28 +01:00
rdbtCVSandCodex 0b970c0c9a Cover instant-break sounds alongside replanting
Move the instant_digging regression from redundant PR #1966 into the existing stale-dig fix for #1880 and #1908.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-25 23:35:22 +00:00
Ludovic Marie 39efe6ef39 Merge pull request #1968 from ludovic111/ci/parity-select-from-main
🤖 Agent: merged on verify. It changes only which harnesses CI picks: main's item-level selection for every queued branch. I checked on main that the --list format gives the names line by line, then the summary line.
2026-10-26 00:33:51 +01:00
Ludovic Marie da66056bd5 Merge remote-tracking branch 'origin/main' into perf/behaviour-cache-hash-once 2026-10-26 00:33:29 +01:00
Ludovic Marie d5699d5f90 Merge remote-tracking branch 'origin/main' into perf/sculk-ticking-chunks 2026-10-26 00:33:28 +01:00
Ludovic Marie 7092d5f4d7 Merge remote-tracking branch 'origin/main' into perf/rings-out-of-reach 2026-10-26 00:33:28 +01:00
Ludovic MarieandClaude Opus 5.5 e5403c0224 Merge origin/main into fix/item-cooldowns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:33:20 +01:00
Ludovic Marie 29c09b4bf2 Merge pull request #1794 from ludovic111/perf/paletted-locate-no-div
🤖 Agent: verify and parity pass on 40c77c02f; −12 to −14% per read of 5–7-bit sections (body). Queued by perf-next-2.
2026-10-26 00:32:09 +01:00
Ludovic Marie 1fd42f311f Merge remote-tracking branch 'origin/main' into perf/collision-scan-edges 2026-10-26 00:31:46 +01:00
Ludovic Marie 2189baea6d Merge pull request #1778 from ludovic111/perf/worker-count
🤖 Agent: verify and parity pass on 1437537cb; spawn load −15% and teleports −12% on a 4-thread laptop (measurements in the body). Queued by perf-next-2.
2026-10-26 00:29:11 +01:00
Ludovic MarieandClaude Opus 5.5 836c1ee3b9 CI parity: pick harnesses with main's selection code, run them at the head
The parity job checks out the PR head, so every branch made before #1964
still selected with the old per-crate rule. The step now runs main's
tools/parity.py and tools/parity_reach.py with --changed --list, restores the
head's files, and runs the named harnesses with the head's parity.py. A
branch that edits either file, or a failure of main's selection, falls back
to the head's own `--changed` run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:27:32 +01:00
Ludovic MarieandClaude Opus 5.5 0baf6cbd62 CI: the desktop runner works while its screen is locked or untouched for 15 min
Ludovic's rule (2026-10-26): runner_pause.py pauses at once on keyboard, mouse or gamepad input
(read from /dev/input, only whether bytes came), on an unlocked session touched in the last
15 min, at 90 °C, on a game or the GPU. Fixes the service start: the link loop's last test
failed the script under set -e.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:27:22 +01:00
Ludovic MarieandClaude Opus 5.5 ef5361e4c2 Creatures: drop the doc comments the removed cooldown fields left behind
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:26:17 +01:00
Ludovic Marie cc7534341c Merge pull request #1964 from ludovic111/perf/ci-speed-selection
🤖 Agent: merged on verify. It changes only which harnesses run, not the game; evidence in the body: all 61 failed parity runs of 10-18..10-25 are still caught, and LLVM coverage on 6 harnesses confirms the selection. Its own parity selects no harness. CI speed is Ludovic's priority tonight.
2026-10-26 00:23:09 +01:00
some-du6eandClaude Opus 5.5 97e78e0f11 horizon-prepare: find 26.3 in ATLauncher's and the CurseForge app's folders
ATLauncher saves Mojang's version JSON as is in configs/json/minecraft and
the client jar as the library net.minecraft:client:<version>, with the
libraries at their version JSON paths and the assets and Java runtimes in
Mojang's layouts. horizon-prepare now reads that layout, in the chosen
folder, the launcher above a chosen instances/<name> folder, or, with no
folder chosen, ATLauncher's default folder on each system after the
official launcher's, then the CurseForge app's Install folder. The lock's
pins gate it as before, and nothing is downloaded. The error names every
folder looked in and the launchers Horizon reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:22:32 -06:00
Kacpherek a6548089c4 Count the death roll in ticks so the dead camera eases instead of shaking
The camera's death roll and zoom (GameRenderer.bobHurt, Camera.modifyFovBasedOnDeathOrFluid) read deathTime + partialTick. Horizon derived deathTime from the wall clock since the death message, which the server thread path never set: there deathTime stayed 0, so every frame used 0 + partialTick and the view rolled from 0 to 0.2 degrees and snapped back twenty times a second (with a matching zoom pulse) instead of settling at vanilla's 3.6 degrees. In frame, the wall-clock ticks were not in step with the frame's partial tick either.

deathTime is now the tick counter LivingEntity.tickDeath keeps: the in-frame tick already counted it, and the server thread path now resets it when the death screen opens and counts it once per client tick. horizon-ctl state reports it as player.death_time.
2026-10-26 00:20:39 +01:00
Kacpherek bbc57aa838 Save furnace, brewing stand and crafter edits made through a server menu
A server player's clicks in a furnace, brewing stand or crafter menu wrote
the block entity's slots but never marked it changed, so the chunk was
not saved: an idle furnace (nothing burning, so its own tick changes
nothing) given or emptied through its menu rolled back on reopen or
chunk unload, losing or duplicating the items. Comparators kept the old
reading too.

When the click changed the container's slots, call container_changed
(BlockEntity.setChanged, reached through Slot.setChanged in vanilla's
AbstractContainerMenu.doClick and quickMoveStack), as chest-like menus
already do.
2026-10-26 00:20:26 +01:00
Kacpherek d143666e46 Mark a server's player dead while its arrival in a level is loading
A server's player on a trip into another level was skipped by
tick_guests before its zero-health body was marked dead, so a death
during the arrival (a /kill, damage) only counted once the destination
had loaded. The client already showed the death screen, and a Respawn
press in between was refused (guest_respawn wants a dead player) and
then lost, leaving the player on the death screen until a second press.

LivingEntity.hurt calls die at once, loaded or not: the death half of
the guest tick is now its own guest_die, which also runs for a player
on a trip.
2026-10-26 00:19:55 +01:00
socksproxandDevin ea1c0a4490 Render mannequin entities (server NPCs)
Servers on 26.3 spawn NPCs as minecraft:mannequin — the avatar entity
vanilla added for exactly this — instead of fake players (gommehd.net's
"Gommunity" lobby NPCs are invisible today). The client never registered
the type: MobTypes.load only builds species::ALL, so AddEntity produced a
tracked entity but nothing to draw.

- EntityDataSerializers.RESOLVABLE_PROFILE (41) decodes into
  Value::Profile. Before, the entry list ended at it and the mannequin's
  DATA_IMMOVABLE and DATA_DESCRIPTION after it never decoded.
- species::ALL gains mannequin (player#main model, Humanoid anim), and
  the player-only render paths — skin texture, sleep pose,
  AvatarRenderer's 0.9375 scale — take an avatar() check instead of the
  id string.
- SetEntityData's apply stores DATA_PROFILE on the mob, and
  update_remote_skins resolves its textures property through
  PlayerSkinRenderCache's rules (unsigned allowed, SkinManager's
  insecure path) the way a tab player's skin resolves.
- Mannequins get the shared-flags crouch and sleeping-position handling
  other players already had.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 00:19:44 +01:00
Kacpherek c7432c21e1 Check a server player's moves with the box of its pose
A player sprint-swimming up under a ceiling was sent back every tick on the server thread (and for LAN guests): the server never updated the player's pose, and handlePlayerPositionChange's collision check always used a standing 1.8-block box, so the 0.6-block swimming box the client moved with looked like it entered the ceiling. Player.tick ends with updatePlayerPose, and the check uses getBoundingBox, so run updatePlayerPose in the server player's tick and check moves with the pose's dimensions. Fixes #1899.
2026-10-26 00:17:03 +01:00
Kacpherek 2c0d9cc30e Tick End gateways' server copies on a server without a window
A used End gateway never worked again on the server thread or on a
dedicated server. TheEndGatewayBlockEntity's server ticker (portalTick)
ran only inside the client block-entity pass, which returns at once when
there is no renderer, so the 40 tick cooldown that
EndGatewayBlock.entityInside starts (triggerCooldown) never ran out.

A Game without a renderer now runs portalTick on the gateways it keeps a
server copy of, in block-ticking chunks as Level.tickBlockEntities does,
and sends triggerCooldown's block event every 2400 ticks of age.
2026-10-26 00:16:50 +01:00
Kacpherek f988ed417a Drop a pending server trip when its player is teleported within the level
A server's player sent into another level waits on a trip until the
chunks around its target load, and the server leaves its body alone and
skips its portal checks until then. A /tp within the level while the
arrival was still loading left that trip behind: its target lost its
loading tickets and never finished, so Nether, End and gateway portals
stopped taking the player until they went back to the abandoned spot.

ServerPlayer.teleport within a level places the player at once, so the
pending arrival is now abandoned. server-dimension-players gains the
case: sent far into the Nether, teleported back near the origin before
the far chunks load, then an End portal there.
2026-10-26 00:15:54 +01:00
rdbtCVSandCodex 3adff150cf Record preparation validation timings and measurement limits
Co-Authored-By: Codex <[email protected]>
2026-10-25 23:15:40 +00:00
Ludovic MarieandClaude Opus 5.5 1e6f05ed5a Chat option: filter the chat's lines both ways, as setVisibleMessageFilter
The chat keeps where each message came from and the Chat option's
abilities. A message the option hides is still dropped on arrival, but a
change of the option now also hides the lines already in the chat that
it no longer shows, and brings them back when it shows them again
(ChatComponent.setVisibleMessageFilter -> refreshTrimmedMessages), on
apply_settings. Every Chat::add names its source. The Restrictions
screen reads its two flags from ChatAbilities instead of its own copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:15:37 +01:00
Ludovic MarieandClaude Opus 5.5 21d8e53034 parity: pick harnesses by the Rust items their probes reach, not whole crates
`--changed` / `--changed-files` now narrow each harness's recorded Rust
sources to those its probes can reach (tools/parity_reach.py): an item-level
reachability walk from the probe modules a harness ran (or the binary it
built) over free items, inherent methods, trait impls and macros, matched by
name so it only over-approximates. Parent modules count through their `mod`
declarations, compared with the merge base. A harness whose probe can't be
placed keeps every source; `--by-crate` turns the narrowing off.

On the last 20 PRs merged on 2026-10-25: 2255 -> 1061 harnesses picked,
2384 -> 1592 harness-minutes. Replaying the week's 61 failed CI parity runs,
every failed harness the old selection picked is still picked; LLVM coverage of
six harnesses' probes executes only kept files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:14:51 +01:00
Kacpherek 21019afc2b Vehicle storage menus edit the vehicle's own items
A chest minecart, hopper minecart or chest boat's menu edits a stand-in
container, which is meant to be copied back to the vehicle when the menu
closes. close_container took the open container before calling
close_cart_container, which then found nothing open and copied nothing:
items taken out came back on the next open, and items put in vanished.

Copy the stand-in back from the key close_container took. A server
player's clicks in such a menu copy it back at once, since the menu is
the vehicle's own inventory (ContainerEntity, AbstractMinecartContainer
and ChestBoat implement the container the menu reads and writes).
2026-10-26 00:14:27 +01:00
Kacpherek 785e83feeb Firework explosions hurt nearby players, not only the boosted one
FireworkRocketEntity.dealExplosionDamage hurts every LivingEntity within 5
blocks that the rocket can see (its feet or its middle, ClipContext
COLLIDER with no fluids), besides the entity it is attached to. Horizon only
hurt the attached player and the nearby mobs, so a rocket bursting next to a
player on foot did nothing to them, in both single player modes.

The player (unless it is the attached one) and every guest in the level now
take damageAmount * sqrt((5 - distanceTo) / 5) as fireworks damage, with the
armor wear of hurtServer. distanceTo is a float, as vanilla computes it, for
the mobs too.
2026-10-26 00:14:19 +01:00
rdbtCVSandCodex 03190ec724 Update the runtime download fixture for runtime-only discovery
Co-Authored-By: Codex <[email protected]>
2026-10-25 23:13:02 +00:00
Ludovic MarieandClaude Opus 5.5 5e9d45bc9a /pregen: off unless the server turns it on
Vanilla has no /pregen, so with it always registered /help and the command
tree differed from vanilla's (the command_exec harness failed on /help).
Horizon's own commands are now added to the game's tree only when the owner
asks: `horizon-pregen=true` in server.properties (`horizon-pregen = true`
under [properties] in server.toml) for a dedicated server, `--pregen` for
the game (single player, LAN). Off, the trees are vanilla's own, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:11:39 +01:00
rdbtCVSandCodex f45e3f1237 Optimize launcher preparation with precompiled, batched exporters
Compile and validate original exporter bundles during release builds, cache development compilation, and reuse initialized JVMs and collision data during preparation. Keep legacy preparers compatible while current releases need only a Java runtime.

Co-Authored-By: Codex <[email protected]>
2026-10-25 23:11:23 +00:00
Kacpherek 9aa6d7723f Shulker bullets home on the guest the shulker targets
A mob that sees a guest (a LAN guest, or the player of the server thread)
holds Who::Player as its target, and what it does to that player runs with
the guest swapped in. A shulker's bullet, though, keeps the target it was
fired with until it lands, and the projectiles tick outside that swap:
there Who::Player is the host's own player, so on a server thread the
bullet found no target, stopped homing and fell onto the blocks.

The bullet now carries the guest's entity id, as ShulkerBullet.finalTarget
holds the targeted entity in vanilla (ShulkerAttackGoal.tick passes
Shulker.getTarget()), so it homes on the guest among the projectiles'
targets and its hit runs as that guest: 4 damage and Levitation.
2026-10-26 00:10:54 +01:00
Ludovic MarieandClaude Opus 5.5 fbd6bee3e3 Official mods: FPS Counter and Keystrokes, the first extras as mods
The FPS counter and keystrokes extras rewritten as mods on the public API
(render, input, ui, storage), in mods/official/, with the extras' options
on their settings pages and the extras' look: the same boxes, colours and
text positions, pixel for pixel. They are workspace members, so clippy and
pr_ready cover them; a maintainer publishes them in horizon-mods. The
built-in extras stay until the removal PR, which installs these for the
players who had them on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:08:55 +01:00
Kacpherek e50a4c6443 Play no place sound when a block item placement is refused
Clicking with a torch or flower into a space that is already taken (or where the block cannot stand) made the server-thread client play the block's place sound when the server resent the refused target. BlockItem.place returns FAIL before playSound when canPlace() is false, and the client only hears its own predicted placement, so clear the pending place sound when no placement was predicted.
2026-10-26 00:06:53 +01:00
Ludovic MarieandClaude Opus 5.5 6824224a33 Player heads: read the profile's skin once, and no skull on a piston head
- Skull keeps what its profile draws (the skin URL, its skins/<sha1>
  sprite and the default skin), computed when the block entity loads,
  instead of decoding base64 JSON and hashing it for every head every
  frame; skull_skin_at no longer scans the chunk's saved block entities.
- BlockEntity::for_block gave minecraft:piston_head a skull block entity
  (it ends in _head), which would have been saved as minecraft:skull.
- AGENTS.md is left as it is on main: the workflow note is not part of
  this fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:06:22 +01:00
Ludovic MarieandClaude Opus 5.5 c28bea8e96 Mod API: input's permission line says it sees keys and clicks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:06:16 +01:00
Ludovic Marie 6df37324f5 Merge pull request #1853 from ludovic111/fix/mob-ambient-particles
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-26 00:01:39 +01:00
Ludovic MarieandClaude Opus 5.5 2a576b5886 Merge origin/main into fix/water-cauldron-extinguish
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:01:15 +01:00
Ludovic MarieandClaude Opus 5.5 eb443cdacc Merge origin/main into fix/server-player-sculk-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:59:04 +01:00
rdbtCVSandCodex 4be53ee28a Reduce resource preparation filesystem work
Share the asset index and verification state, stage checked imports while hashing, preserve indexed replacements, and clone independent resource copies where supported.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-25 22:58:00 +00:00
Ludovic MarieandClaude Opus 5.5 9d5301627b Chickens: the flap test uses the shared test mob type
chicken_flap reads only the ground flag, so the test builds its mob from
mobs::sensor_scan_tests::kind() instead of a 50-line copy of a MobType.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:55:43 +01:00
Ludovic MarieandClaude Opus 5.5 d5b88ff130 Merge origin/main into fix/chicken-flap-remote
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:55:43 +01:00
Ludovic MarieandClaude Opus 5.5 b7629cf656 Enchantments: say which callers have no language
full_name reads the process's language, which the server thread shares
with its window; only a process with no language loaded (a dedicated
server, the probes) keeps the English names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:46:36 +01:00
Ludovic MarieandClaude Opus 5.5 a413812363 Scroll bars: the screen's GUI size, and a test of the drag
stats_scroll and stats_drag take the GUI height from gui_size() instead
of a copy of it. The Game Rules list's press, drag and release get the
test from #1913 (vlesmentie's take on the same fix).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:46:15 +01:00
Ludovic MarieandClaude Opus 5.5 dd73faccbc Merge origin/main into fix/draggable-scrollbars
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:43:27 +01:00
rdbtCVSandCodex 072f5ce2ee Fix custom item names above targeted item frames
Show the framed item's custom name through the existing world name-label renderer, matching vanilla's HUD and crosshair conditions. Extend the LAN frame check for normal and glow frames.

Co-Authored-By: Codex <[email protected]>
2026-10-25 22:43:07 +00:00
Ludovic MarieandClaude Opus 5.5 401ac1322f Banners: one loader for saved and generated banners
The generated banner reuses save.rs's BannerBlockEntity.loadAdditional
(now decode_banner) instead of a copy of it, and the generated block
entity clones its NBT only for the kinds that read it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:43:03 +01:00
rdbtCVSandOpenAI Codex 28206976d8 Move parity fixtures onto server execution
Co-Authored-By: OpenAI Codex (GPT-6.1) <[email protected]>
2026-10-25 22:36:21 +00:00
Ludovic MarieandClaude Opus 5.5 d81aa97e84 CI: the desktop runner works only while nobody is at the screen; desktop as a parity host
runner_pause.py pauses on any seat0 session (locked too), games, GPU use or a pause file, and is
the only thing that starts the runner; a job under way is throttled, then the runner stops. The
desktop host in parity_hosts.json refuses work while paused and its workers stop on a pause. The
nightly audit stays on the Mac mini. Docs and a unit test of the watcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:12:09 +01:00
Ludovic MarieandClaude Opus 5.5 8463325648 CI: Linux runners for the parity job (desktop, ThinkPad), paused while a game runs
runner-env.sh and vanilla-data.sh find Python 3.13, the pinned JDK and sccache on NixOS;
linux-runner.sh installs a runner as a systemd user service with its own screen; runner-pause.py
stops it from taking jobs while a game runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:12:09 +01:00
Ludovic Marie d58d26c793 Merge pull request #1798 from socksprox/fix/mob-combat-poses
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 23:10:07 +01:00
Ludovic Marie 93e3096e06 Merge pull request #1875 from kaicsm/fix/client-mods-server-thread
🤖 Agent: merged after contribution gate 14 (integration run of main + this PR) and verify on the head with main merged in. Thank you!
2026-10-25 23:08:25 +01:00
Ludovic MarieandClaude Opus 5.5 e3bd626688 Mod API: what the extras need as mods: corner panels, frame stats, mappings, buttons
The built-in extras become official mods; these are the calls the FPS
counter and keystrokes overlay need to look and behave the same:

- render.set-corner(corner, w, h, shapes): a box the game places with
  its own corner stacking (under the status effects, with the overlays
  and hud panels), clear-corner; text-width(text) in the HUD font
  (advances for U+0000..U+024F, 6 otherwise); frame-stats(): the game's
  FPS counter (fps, min, 1% low).
- input.mapping(name): a movement or click mapping's key label and
  whether it is held; clicks-per-second() for attack and use.
- ui: a button widget (200 x 20, select(id, 0)) for settings pages.
- The game keeps its FPS and click counters running while a mod runs,
  and refreshes the snapshot every frame for mods with a frame hook.
- The ui test guest gains /corner and /readings, with a sandbox test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:08:15 +01:00
rdbtCVSandCodex 0b49da2466 Fix lead drops when leashed mobs die
Follow Leashable.tickLeash for dying mobs and cover trader/player-held llama leads through the death animation.

Co-Authored-By: Codex <[email protected]>
2026-10-25 22:07:58 +00:00
Ludovic Marie e05933169a Merge pull request #1857 from Joansitoh/fix/item-lore-tooltips
🤖 Agent: merged after contribution gate 14 (integration run of main + this PR, harnesses for the touched areas) and verify on the resolved head. Thank you!
2026-10-25 23:01:30 +01:00
Ludovic Marie bb3497bea0 Merge pull request #1671 from socksprox/fix/elytra-fireworks
🤖 Agent: merged after contribution gate 14 (integration run of main + this PR, harnesses for the touched areas) and verify on the resolved head. Thank you!
2026-10-25 23:00:42 +01:00
Ludovic MarieandClaude Opus 5.5 6ef8deedca Merge origin/main into fix/mob-combat-poses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:56:32 +01:00
Ludovic MarieandClaude Opus 5.5 d63d0c1744 Merge origin/main into fix/client-mods-server-thread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:56:31 +01:00
Ludovic Marie f5e97deac2 Merge pull request #1834 from vlesmentie/fix/lava-cauldron-player
🤖 Agent: merged after contribution gate 14 (integration run of main + this PR, harnesses for the touched areas) and verify on the resolved head. Thank you!
2026-10-25 22:55:16 +01:00
Ludovic Marie 1a857b285d Merge branch 'feat/prepare-output-check' into fix/prepare-rewritten-version-file 2026-10-25 22:50:36 +01:00
Ludovic MarieandClaude Opus 5.5 116a7f7dca horizon-prepare: OUTPUT_VERSION, the number of what it prepares, checked by CI
Launchers copy a player's prepared resources to a new release while its preparer declares the
same OUTPUT_VERSION (the launcher side is its own pull request). The number is bumped by hand;
CI keeps it honest: the parity job already prepares with every change to the preparer, and
tools/prepare_output.py check now fails when that preparation's digest is not the one
crates/horizon-prepare/output-versions.txt gives the current number, or when the file changed
other than by appending a version. The digest canonicalises what differs between two runs of
the same preparer (JSON key order, creative tab tag order, lambda class addresses, logs, the
sounds): 10 preparations by 5 preparer versions on the three Mac runners give one digest, and a Linux preparation by an older preparer gives the Mac runners' digest for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:48:51 +01:00
Ludovic MarieandClaude Opus 5.5 acb68ed163 launcher: reuse prepared resources while the preparer's OUTPUT_VERSION is the same
A version's resources were reused only when the preparer's sources and the exporters were
byte for byte the same, so any change to crates/horizon-prepare (even one that prepares the
same files) made every player prepare again. The launcher now compares the number the preparer
declares for what it makes (`pub const OUTPUT_VERSION: u32` in crates/horizon-prepare/src/lib.rs,
bumped by hand when the output changes and checked by CI in the follow-up). Preparers from
before the number are known by a fingerprint of their sources and exporters: v0.2.49 to
v0.2.64 prepare version 1's output (measured on the CI runners' preparations). Older versions
are compared as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:44:56 +01:00
rdbtCVSandOpenAI Codex b6efebc879 Play anvil result sounds once for every player
Deliver anvil level events to the acting player and keep their local sound out of positional sound capture. Extend the anvil LAN check to count sounds for both players using either player’s anvil result.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-25 21:39:14 +00:00
socksproxandDevin 2cdb6362ca Particles keep their sprites over a resource reload
The particle engine copied the GUI atlas's sprite rects once at start; a pack or server-pack
change rebuilt the atlas but not the engine, so every named and set sprite then drew a stale
region — break crumbs showed other blocks and campfire smoke showed random items (#1923).
The engine now re-reads the rects and the packs' sprite sets on reload, and a particle whose
sprite-set index outlived the rebuild keeps its sprite instead of indexing past the new list.
The reload also layers content mods over the root again, which startup does but reload forgot,
so the first reload silently dropped their assets and data.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 22:38:40 +01:00
Ludovic MarieandClaude Sonnet 5.5 31e9e2da97 Merge origin/main into fix/item-lore-tooltips
In stack_tooltip keep #1806's translate through `display_get` and this
PR's `push` helper for the styled tooltip lines.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 22:26:10 +01:00
Ludovic Marie 74768d2eeb Merge pull request #1939 from ludovic111/roadmap/player-ideas-1025
🤖 Agent: merged after verify (roadmap data only).
2026-10-25 22:24:22 +01:00
vlesmentieandClaude Opus 5.5 15877931db Merge origin/main into fix/water-cauldron-extinguish
Keeps both sides of #1823 (wither rose) and this branch (lava and water
cauldrons) in motion.rs's inside-block match, the MotionEvent variants,
game.rs and lan_actions.rs, and all three tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:22:46 -03:00
Ludovic MarieandClaude Opus 5.5 a4a709c2d6 Roadmap: OpenGL shipped in v0.2.63; three players' ideas kept by Ludovic
One-command server install, launcher-style game menus (off by default), AppImage and a Nix flake.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:16:18 +01:00
Ludovic Marie b4345b1c2b Merge pull request #1813 from luiS1q1/fix/ios-dualsense-build
🤖 Agent: thank you @luiS1q1! Merged on Ludovic's go-ahead as iOS groundwork (desktop targets unchanged, clippy clean, this head's verify).
2026-10-25 22:16:05 +01:00
Ludovic Marie ed11f614f8 Merge pull request #1870 from ludovic111/fix/complete-sounds-panorama
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 22:13:20 +01:00
levzzz 61c5f29ab7 Game Rules and Statistics: the scroll bars can be dragged
Both lists drew a scroll bar but only listened to the mouse wheel: a press
inside the bar column, the drag that follows and the release were dropped.
`ui::Scroll` already models AbstractSelectionList's press/drag/release (the
server list uses it); the two screens now keep their scrolling in it and the
window passes the press, the pointer's moves while it is held and the release
along.

Fixes #1782
2026-10-26 02:13:10 +05:00
Ludovic MarieandClaude Opus 5.5 fada660310 Mod API: capability input (experimental): claimed items' use, mod keys
- WIT `input`: claim-items(ids) makes the use button on those items the
  mod's: use-start, then use-stop with the ticks held (button up, item
  out of the hand, or a screen opening); the game does nothing else
  with the click. register-key(name, label, key) for up to 16 keys
  (key.keyboard.r), heard as key-down / key-up while playing; the
  game's own mapping on the key still runs. held(), poll() (256 kept).
- game/mod_input.rs: play_mouse asks mod_use first (local and remote
  windows), the tick ends a use whose item left the hand, the keyboard
  and horizon-ctl's key send the physical key to mod_key.
- No claim and no key, no cost: one check each.
- Experimental: the manifest needs experimental = ["input"].
- The ui test guest gains /input_claim, /input_poll and /input_held,
  with a sandbox test. Docs: mods.md, stability.md, the changelog, the
  regenerated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:10:44 +01:00
Ludovic Marie da0056c248 Merge pull request #1936 from ludovic111/fix/opengl-gl33
🤖 Agent: merged after verify and real GL runs on the ThinkPad (Intel HD 620, GL 3.3 with extensions hidden: title, server thread and in-frame render, 0 errors; screenshots match Vulkan). Released at once: any GL driver without compute shaders crashed at start without it.
2026-10-25 22:10:39 +01:00
rdbtCVSandCodex gpt-6.1-sol 40b3c1e3d6 Preserve requested GUI scale across temporary small surfaces
Keep framebuffer limits in rendering and explicit option cycling rather than overwriting the saved choice during startup or settings application.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-10-25 21:08:55 +00:00
Ludovic MarieandClaude Sonnet 5.5 2364bd3504 Merge origin/main into fix/elytra-fireworks
Keep main's item_sync.rs and lan::guest::stack_to_network for stacks on
the wire (#1874) and move the firework component decoding there:
Component::Fireworks and Component::FireworkExplosion in
item_sync::components_tag, with fireworks_tag.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 22:04:54 +01:00
Ludovic MarieandClaude Opus 5.5 2fb0ac186a Behaviour cache: hash the source files once per process
The snapshot's key hashes the 70 MB of source tables it was resolved from.
The client's tables and the world generator's are loaded from the same
folder, so a world opening hashed them a second time: a third of a second
of the server thread's start on a 4-thread laptop, before any chunk. The
hash is now kept for the process, by each file's length and modification
time; a changed file is read again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:04:13 +01:00
Ludovic MarieandClaude Opus 5.5 4a2b262343 Structure starts: decide chunks no ring search can reach without the rings
The 128 stronghold biome searches (two seconds of CPU on a 4-thread laptop)
ran when the first chunk of a new world looked up its structure starts, so
nothing near spawn was generated until they were done. Each search ends
within 8 chunks of its ring chunk, which is known before searching, and
strongholds lie 1,400 blocks out or more: a chunk with no ring search in
reach (exclusion zones included) is now decided without them, which is what
they would say. They run when a chunk near a ring, /locate or an eye of
ender needs them (ring_positions now searches on demand instead of returning
nothing before a lookup did).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:00:07 +01:00
socksproxandDevin 820cc211d3 Player heads render their owner's skin
Placed player_head blocks kept no profile: skulls had no block entity, so the
renderer fell back to a hardcoded steve for every head. Give skulls a real
BlockEntity carrying vanilla's SkullBlockEntity fields (profile,
note_block_sound, custom_name) so the placement pipeline lands
minecraft:profile in it the way block_entity_item already maps, encode and
decode it through save so regions keep it, send its update tag to remote
clients, and resolve the profile through the skin downloader the head items
and the tab list share: the downloaded skin slot once it lands, the
profile's DefaultPlayerSkin uuid pick while it fetches, SKIN_BY_TYPE steve
without a profile.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 21:55:02 +01:00
socksproxandDevin 8dcd00fc62 AGENTS.md: the feature-request loop
When an agent is asked for a feature it runs the whole loop itself: check open
PRs for the area first, work in a new worktree on the latest upstream main,
build and run pr_ready, then push to the fork and open the pull request
against upstream, never the fork.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 21:54:47 +01:00
Ludovic Marie 82b0051a3c Merge pull request #1856 from Domanffe/fix/furnace-experience
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:53:58 +01:00
Ludovic Marie e0abe90c5c Merge pull request #1858 from Excurs1ons/fix/server-death-messages
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:53:06 +01:00
Ludovic Marie 982034f5f8 Merge pull request #1806 from Excurs1ons/fix/localized-ui-text
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:51:23 +01:00
rdbtCVSandCodex 95c336a387 fix(threads): lower helper thread priority on Android
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-25 20:50:57 +00:00
Ludovic MarieandClaude Opus 5.5 2761349d98 Renderer: no indirect buffer usage without indirect draws (OpenGL before 4.3)
wgpu refuses to create a buffer with INDIRECT usage on a device without
DownlevelFlags::INDIRECT_EXECUTION (OpenGL before 4.3 without compute shaders), so the game
panicked creating 'Section draws'. Multi-draw now also needs indirect execution, and without it
the buffer has no INDIRECT usage: sections are drawn one direct draw each, as they already were
without INDIRECT_FIRST_INSTANCE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:50:49 +01:00
levzzzand[CC] <deepseek-v4.1-flash> b71623dfd7 Item cooldowns: the sweep on a slot, and no use while one runs
Nothing tracked `ItemCooldowns`: `draw_item_decorations` passed a cooldown of
0.0 with a note that there was none to shade, and ender pearls were held back
by an ad-hoc `pearl_cooldown` counter.

`horizon_client::cooldowns` keeps the `use_cooldown` component's entries by
cooldown group the way `Player.getCooldowns` does (start and end ticks, the
percent `getCooldownPercent` returns), `ItemInfo` now carries the item's
`use_cooldown`, and the game uses it where vanilla does:
`ServerPlayerGameMode.useItem`/useItemOn refuse a stack on cooldown,
`ItemStack.applyAfterUseComponentSideEffects` starts the entry when a use
finishes (a thrown pearl, a wind charge, an eaten chorus fruit),
`GuiGraphicsExtractor.itemCooldown` shades the slot, and a server's guests
keep their own and are sent `ClientboundCooldownPacket` (started, and 0 when
it ends), which a client applies.

Fixes #1763

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 01:50:22 +05:00
Ludovic MarieandClaude Sonnet 5.5 21d8b0702c Merge origin/main into fix/lava-cauldron-player
Keep both the lava cauldron and wither rose match arms in
MotionWorld's entity-inside handling, and both tests.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 21:50:03 +01:00
Ludovic Marie d0f61d07cf Merge pull request #1879 from socksprox/fix/legacy-colour-codes
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:48:30 +01:00
Ludovic Marie 4e8269276f Merge pull request #1884 from cryingtoilet/fix/1764-creative-search-word-delete
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:47:46 +01:00
Alexander KrylovandHermes Agent 3762ab7cda Enchantments: the name and the level follow the selected language
The registry resolved every `description` from the pinned `en_us` map at load
and `full_name` took `enchantment.level.<n>` from the same table, so a tooltip
showed an English enchantment line whatever language the player picked.

Keep the description's translation key and resolve it, and the level, through
the current language at display time, with the English text as the fallback for
the server thread, the probes and an old resource root.

Refs #1774.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 23:47:14 +03:00
Ludovic Marie fd56c1f358 Merge pull request #1850 from rdbtCVS/t3/fix-end-crystal-explosion
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:47:00 +01:00
levzzzand[CC] <deepseek-v4.1-flash> b3807d6e50 Chat: the Chat option hides what it says it hides
The Chat Settings option was saved and shown but never applied: Hidden and
Commands Only still sent and displayed ordinary chat, and Hidden still
displayed the server's messages.

Minecraft.computeChatAbilities turns the option into ChatRestriction's
permissions, and they are now honoured where vanilla honours them:
ChatScreen refuses to submit input they take (hasAllowedInput) and says why
in a red usage line, ClientPacketListener.sendChat/sendCommand stop it before
the packet, ChatListener drops the player, server and client sources the
option hides, and ClientInformation carries the visibility to a server (at
login, and again when it changes, as Options.broadcastOptions does).

Fixes #1376

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 01:45:17 +05:00
Ludovic Marie fa2ae64d83 Merge pull request #1874 from Domanffe/fix/creative-item-components
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:45:05 +01:00
Ludovic Marie c5d816cc12 Merge pull request #1920 from ludovic111/fix/opengl-downlevel
🤖 Agent: merged after verify and a real OpenGL run on the desktop (Mesa, GL 4.6 and 3.3: no crash, animated sprites on the CPU below 4.3). Released at once on Ludovic's request.
2026-10-25 21:44:24 +01:00
Ludovic Marie 99a954d665 Merge pull request #1823 from vlesmentie/fix/wither-rose-player
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:43:03 +01:00
Ludovic Marie f3efe2802c Merge pull request #1811 from vlesmentie/fix/zombie-horseman-peaceful
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + its batch: clippy -D warnings, tests, the affected parity harnesses (111/112, all pass), in game on the server thread and in-frame, lan_e2e in both host modes) and this head's verify.
2026-10-25 21:42:22 +01:00
rdbtCVSandCodex 09ff66bfee Fix stale server digging after instant survival breaks
Clear completed guest digging so replanted flowers and crops are not broken again after attack is released. Add a LAN replanting regression for both single-player paths.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-10-25 20:39:44 +00:00
Ludovic MarieandClaude Opus 5.5 f65750baed Merge origin/main into fix/opengl-downlevel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:35:35 +01:00
Ludovic MarieandClaude Opus 5.5 61c44c22ef Mod API: capability render (experimental): HUD shapes and camera shake
Mods draw through the game's own renderer, never their own shaders:

- WIT `render`: set-hud(shapes) with fill, quad (any four corners),
  sprite (the game's GUI sprites), item (scaled) and text shapes, up
  to 1024, kept until replaced; gui-size(); shake(strength, seconds).
- Drawn over the HUD after the extras, under chat and screens, hidden
  with F1 (game/mod_render.rs). Colours are 0xAARRGGBB.
- shake turns the view, not the player: jolts fade out squared, add up
  to 1, at most 3 degrees of yaw and 2 of pitch, scaled by Distortion
  Effects (screenEffectScale; 0 turns it off).
- Experimental: the manifest needs experimental = ["render"].
- No mod drawing, no cost: an empty iterator and an is_empty check.
- cargo horizon-mod check reports shakes and HUD shapes. The ui test
  guest gains /render_hud, /render_clear and /render_shake, with a
  sandbox test; HudBatch gets fill_quad.
- Docs: mods.md, stability.md, the changelog, the regenerated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:35:12 +01:00
Ludovic MarieandClaude Opus 5.5 21203de7f0 Animated sprites: the CPU path also when the driver cannot build the compute pipeline
An OpenGL driver can list compute shaders among its extensions while wgpu writes GLSL of its core
version, which has none (seen under MESA_GL_VERSION_OVERRIDE=3.3: the pipeline failed and the game
panicked). The pipeline is now created inside error scopes, and a failure falls back to the CPU path
with one warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:33:05 +01:00
sl4ppleware 386912a76d Hearts blink again when the server sets health: LocalPlayer.hurtTo's damageCooldownTime 2026-10-25 23:32:27 +03:00
Ludovic Marie 731d88a1d8 Merge pull request #1917 from ludovic111/fix/opengl-fallback
🤖 Agent: merged after verify, a real OpenGL run (Mesa GL 4.6: title and a world rendered, no errors) and the backend/shader unit tests. Released at once on Ludovic's request (release-now): a player's Radeon HD 7400M could not start Horizon.
2026-10-25 21:30:01 +01:00
vlesmentieandClaude Opus 5.5 72de6e0b70 Water cauldrons put out a burning player and lose a level
On top of the lava cauldron's inside test: water and powder snow
cauldrons are no longer skipped. Their entity-inside shape is the
cauldron's or their content, Block.column(12, 4, 6 + 3 per level), and
LayeredCauldronBlock.entityInside applies: on a server, a burning
player first asks for handleEntityOnFireInside (runBefore EXTINGUISH),
which lowers the fill level (lowerFillLevel, a powder snow cauldron
turning to water of its level first, block_change with no source); then
EXTINGUISH (Entity.clearFire) puts the fire out.

Fixes #1330.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:25:48 -03:00
Ludovic MarieandClaude Opus 5.5 1a16e35aaa Animated sprites without compute shaders (OpenGL before 4.3)
SpriteAnimator wrote every changed sprite with a compute pass, which OpenGL 3.3 to 4.2 has no
equivalent for in wgpu: creating the pipeline failed. Without compute shaders and storage, the same
jobs now run on the CPU with animate.wgsl's integer mix, and each level is copied into its layer
through the upload belt. The GPU test runs both paths against the same CPU reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:25:37 +01:00
Ludovic MarieandClaude Opus 5.5 b3e400a526 Renderer: sprite arrays OpenGL can view as arrays
wgpu's GL backend picks a texture's target from its layer count when it creates it: one layer makes
a 2D texture and a multiple of six on a square texture a cube map, and neither can be bound as
terrain.wgsl's texture_2d_array. On GL, the 1-layer stand-in for unused sprite arrays gets two
layers and a sprite array of 1 or 6n layers one empty layer more. Other backends are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:25:37 +01:00
Ludovic MarieandClaude Opus 5.5 ac47b988c9 Shader packs, clean room 42: culled entities, the first-person player, shadowPlayer and shadowLightBlockEntities in the shadow pass
Entities Horizon's entity culling hides from the camera keep their vertices in a hidden span that
only the pack's shadow pass draws; the local player's model is built in first person, hidden from
the camera, while the pack casts entity shadows. shadowPlayer draws the player alone when
shadowEntities is off, shadowLightBlockEntities the light-emitting block entities when
shadowBlockEntities is off. Without a shader pack nothing changes.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1, §2.4, §3.2; no other
loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:21:05 +01:00
Ludovic MarieandClaude Opus 5.5 903da4f1ab Cargo.lock: wgpu's GL backend dependencies (glow, khronos-egl, glutin_wgl_sys)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:20:02 +01:00
Ludovic MarieandClaude Opus 5.5 54bb3e4fbe Screen effects: one sampler per entry point, so the shader compiles for OpenGL
GLSL combines a texture with its sampler, and naga refuses an entry point that reads one texture
through two (ImageMultipleSamplers): the BLUR override left both branches in fs_screen and
fs_vignette, so creating the screen effect pipelines failed on wgpu's GL backend at startup. Blurred
sprites now have their own entry points (fs_screen_blur, fs_vignette_blur); each pipeline still
samples once per pixel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:19:47 +01:00
Ludovic MarieandClaude Opus 5.5 a013e35a61 Collision scans: pass the rest of an outer row in a contained section at once
On the rows along a box's bottom, top and sides every cell is outer, so in a
section no block reaches out of, the rest of the row up to the chunk's edge
is passed in one step instead of cell by cell. The candidate budget still
ends the scan where it did.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 21:11:47 +01:00
socksproxandDevin 74e3b0adf6 ui: recapture the mouse when a mod's screen vanishes, borrow its widgets
Review on #1864: when a mod stops while its screen or settings page is
up, draw_mod_screen dropped the player back to Playing without
capture(true), leaving the mouse free until the next click into the
world. The dead surface now leaves through close_mod_screen, which puts
back the screen it covered and holds the mouse on a return to Playing;
the same goes for a screen dropped while it was up.

mod_ui_view also cloned the title and every widget (up to 64, lists to
512 rows) on each frame, click and scroll. It now borrows the mod's
stored widgets — the screen's scrolls live in a disjoint field — and the
click finds the picked row first so the &mut ui_select runs after.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 21:10:52 +01:00
vlesmentieandClaude Opus 5.5 de0f76476f Big dripleaves tilt under a player standing on them
The leaf's tilt ticks, its reset and its redstone reset were already in
horizon-world (big_dripleaf_tick, big_dripleaf_reset_tilt), but nothing
started them: BigDripleafBlock.entityInside was missing. An entity on
the ground whose feet are above the leaf's y + 0.6875 (canEntityTilt)
tilts a flat, unpowered leaf UNSTABLE and schedules its tick in 10.

The block tick's inside scan now also passes the host's and the LAN
guests' feet and on-ground flag to Behaviours::entity_on_big_dripleaf.

Fixes #1683.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:57:10 +01:00
Ludovic Marie 3d8a8f7d02 Merge remote-tracking branch 'origin/main' into cg14/pr1671
# Conflicts:
#	crates/horizon-client/src/lan/guest.rs
2026-10-25 20:54:03 +01:00
Ludovic MarieandClaude Opus 5.5 200b756b4a Sculk: pass over idle sensors and catalysts in the block entity tick
A sculk sensor, shrieker or catalyst with no vibration chosen, travelling or
waiting, and no charge cursors, ticks without changing anything (vanilla's
VibrationSystem.Ticker.tick and SculkSpreader.updateCursors return at once).
The tick now leaves those out before asking whether the chunks around them
tick (nine lookups each) and before looking them up a second time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:53:31 +01:00
levzzzand[CC] <deepseek-v4.1-flash> bb9c474c64 End city banners keep their template patterns
A structure template saves a banner's layers in its block entity NBT, and
StructureTemplate.placeInWorld hands that NBT to the level, where
BannerBlockEntity.loadAdditional reads `patterns`. Horizon built the client
block entity from the block alone (BlockEntity::for_block), so the End
cities' magenta banners came out bare.

Fixes #1438.

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 00:53:17 +05:00
Ludovic MarieandClaude Opus 5.5 4459f132fe Collision scans: skip the outer cells of sections no block reaches out of
BlockCollisions keeps a block on the cells around the box (Cursor3D types 1
and 2) only if it has a large collision shape or is a moving piston. Most
sections hold neither in their palette, so the scan now asks the provider
once per section and passes those cells without reading them: a mob's box
reads about a fifth of the cells it did. Same hits, same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:44:09 +01:00
Alexander KrylovandHermes Agent bc09558dfa Merge origin/main into fix/creative-item-components
Main gained PotionContents on the wire (#1796) in the same two functions
this branch hands to the shared codec. Resolved by keeping the codec and
teaching it the potion: components_tag writes it by its built-in registry
name, and the remote's id lookup asks the built-in registries first, as a
hub's SyncData::id already does. The round-trip test now covers a splash
potion.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 22:42:28 +03:00
rdbtCVSandOpenAI Codex GPT-6.1 bd09f8b03e Migrate player integration coverage to server-thread hosting
Co-Authored-By: OpenAI Codex GPT-6.1 <[email protected]>
2026-10-25 19:36:11 +00:00
vlesmentieandClaude Opus 5.5 89d6e1c2a5 Structure chests keep the enchantments their loot rolled
The loot rolls carry enchantments and potions (horizon_world::loot runs
enchant_with_levels, enchant_randomly and set_potion), but chests filled
when a structure is placed, and mineshaft chest minecarts, turned each
LootStack into an item with loot_item, which keeps only the item, count,
damage and exploration map. End city gear came out bare. Both now use
loot_item_with_components, as vaults, trial spawners and decorated pots
already do.

Fixes #1437.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:13:36 +01:00
Ludovic Marie 8177a6f450 Merge pull request #1807 from ludovic111/fix/no-nether-portal-in-the-end
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:03:49 +01:00
Ludovic Marie f6b882c9da Merge pull request #1802 from ludovic111/fix/mob-flags-and-swings
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:01:58 +01:00
Ludovic Marie 9e248a3214 Merge pull request #1797 from ludovic111/fix/tool-use-on-block-ends-click
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 20:00:04 +01:00
Ludovic Marie 781b7b877b Merge pull request #1796 from ludovic111/fix/potion-contents-over-network
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 19:58:08 +01:00
Ludovic Marie 50f4c913a5 Merge pull request #1868 from ludovic111/fix/modrinth-loader-profiles
🤖 Agent: merged after verify and the parity harnesses this change reaches passed on its head.
2026-10-25 19:55:40 +01:00
Ludovic MarieandClaude Opus 5.5 dab13669f5 Creative screen drops reach the server: SetCreativeModeSlot(-1)
A stack picked from a creative tab exists only on the client. Thrown out of
the creative screen as a server's client, it went out as a ContainerClick to
slot -999, which the server had no carried stack for, so nothing dropped. The
client now sends handleCreativeModeItemDrop's SetCreativeModeSlot(-1, stack),
and the server drops it from the player's hand, with vanilla's drop spam
throttle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:53:09 +01:00
Ludovic MarieandClaude Opus 5.5 9ec5f1ddbc Rockets: a guest's boost rocket hurts them; fireworks sent to a server's clients
FireworkRocketEntity.dealExplosionDamage hurts attachedToEntity with the
full 5 + 2 per explosion: a guest's boost rocket now hurts that guest
instead of nobody. The host's encoder (network_stack) now sends
minecraft:fireworks and minecraft:firework_explosion too, so a rocket's
flight duration and a star's explosion reach the server thread's window,
LAN guests and a dedicated server's players; the creative slot encoder
shares it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:51:09 +01:00
vlesmentieandClaude Opus 5.5 e4a151c2e9 Menu buttons show the pointing hand under the mouse
26.3's AbstractWidget.handleCursor asks for CursorTypes.POINTING_HAND
when a widget is hovered, or NOT_ALLOWED when it is inactive, and the
frame applies the last request to the window (GuiGraphicsExtractor
requestCursor and applyCursor, Window.selectCursor). Horizon never
changed the cursor, so it stayed an arrow over buttons.

widgets.rs keeps the frame's request, ui::button and the resource pack
screen's buttons make it from the mouse hover (not keyboard focus), and
the game sets winit's Pointer, NotAllowed or Default cursor after each
frame when it changes.

Fixes #1347.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:49:15 -03:00
Ludovic Marie e555c4ee48 Merge pull request #1831 from kacpherek/fix/1503-guest-chest-comparator
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:43:16 +01:00
Ludovic Marie f5adaec209 Merge pull request #1828 from kacpherek/fix/1335-crafting-table-valid
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:41:29 +01:00
Ludovic Marie 406fbd7793 Merge pull request #1827 from kacpherek/fix/1334-menu-range
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:39:17 +01:00
Ludovic Marie c0567a323c Merge pull request #1804 from kacpherek/pr/surface-rule-bands
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:38:20 +01:00
Ludovic MarieandClaude Opus 5.5 f969415c7a Creative stacks: reuse the wire codec both sides already had
Horizon already encoded stacks for the wire (lan::guest::stack_to_network,
what a Horizon server sends its clients: damage, enchantments, stored
enchantments, repair cost, custom name, dyed colour, trim, maps, charged
projectiles, books) and decoded them on the client (remote.rs's
protocol_components + item_data::decode_components, every component it has
a codec for). The new encode/apply pair covered six components and
duplicated both.

- The client's SetCreativeModeSlot stack goes through stack_to_network.
- The decoder moves into item_sync::from_network, used by the client for
  what its server sends and by the server (Hub::stack_from_network) for a
  creative client's slot, so the server now keeps every component the
  client's decoder reads, not six.
- One round-trip test covers encode then decode with registries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:36:52 +01:00
Ludovic Marie 0713ee5ec0 Merge pull request #1803 from kacpherek/pr/keep-scheduled-ticks
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3; one integration branch of main + all 14 of your PRs: fmt, clippy -D warnings, horizon-client/world tests, horizon-ctl scenarios on the server thread and in-frame, lan_e2e in both host modes, and the 30 harnesses covering these changes: 29 pass, the 30th is /help listing /pregen from #1805) and this head's verify.
2026-10-25 19:36:08 +01:00
Ludovic Marie f9d85d2e38 Merge remote-tracking branch 'origin/main' into cg14/pr1874 2026-10-25 19:28:34 +01:00
Ludovic Marie b4f12543ca Merge remote-tracking branch 'origin/main' into cg14/pr1850
# Conflicts:
#	tools/lan_e2e.py
2026-10-25 19:26:13 +01:00
Ludovic Marie 4604491fcb Merge pull request #1691 from socksprox/fix/translucent-particles 2026-10-25 19:23:59 +01:00
Ludovic MarieandClaude Opus 5.5 96199c23e7 Shader packs, clean room 41: entities and block entities in the shadow pass
shadow_entities and shadow_block (each falling back to shadow) load with
the entities' vertex inputs unless shadowEntities or shadowBlockEntities is
false (spec 2.4, 3.2; both on by default, OQ-19). They draw the same entity
and block-entity runs the gbuffers programs would, with the same per-draw
uniforms, into the shadow map after the solid terrain and before the
shadowtex1 copy. A run is uploaded when a gbuffers program or a shadow
program draws it; Horizon still draws the runs no gbuffers program covers.

Without a pack nothing changes: only the pack's entity code is touched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:20:17 +01:00
vlesmentieandClaude Opus 5.5 2c8930c48d Chickens flap while they fall on a client of a server
Chicken.aiStep updates flap and flapSpeed on the client too, for the
chickens it only displays. tick_remote_entities never did, so with
single player on its server thread (and on servers) a falling chicken
kept its wings folded. The wing part of chicken_ai_step is now
chicken_flap, and the remote tick runs it from the server's on-ground
flag; the falling slowdown and egg laying stay the server's.

Fixes #1706.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:16:32 -03:00
rdbtCVSandOpenAI Codex b3840adccd Fix renderer backend fallback after surface creation failure
Keep surface creation errors in the backend failure list so Windows Auto can try DirectX 12 after Vulkan fails.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-25 18:08:03 +00:00
Joansitoh 90eda915d5 Add a lore tooltip harness against ItemLore.addToTooltip.
The styled lines have to keep matching vanilla, including an explicit non-italic span and a lore component hidden by tooltip_display.
2026-10-25 18:58:52 +01:00
Ludovic MarieandClaude Opus 5.5 bdbc816486 Creative search: match the localized hover name, not the item's base name
The base name (Tipped Arrow, Potion, White Banner) matched where vanilla's
first tooltip line (Arrow of Poison, Water Bottle, Ominous Banner) doesn't:
the creative harness found extra results for 'e', 'potion', 'tipped' and
'white'. name_line builds the full hover name through display_get.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:53:20 +01:00
Ludovic MarieandClaude Opus 5.5 3a35e147e5 Server mods run in the game: server thread, in-frame, LAN hosts, dedicated
The game that simulates a world now starts its server mods (API 0.3,
experimental): single player's server thread, single player in-frame
(beside client mods), a LAN host either way, and the dedicated server.
Windows that are clients of a server never run them.

game/server_mods.rs: tick_server_mods at the end of the tick diffs the
players (player_joined, player_left), delivers the tick's chat and block
events in order, then calls tick. Block events come from players' breaking
and placing (the acting player is the guest during as_guest); chat from the
local chat path and the hub's new HostEvent::PlayerChat; a guest's /command
reaches its mod before vanilla's dispatcher. tell and command replies go to
their player (the window's chat or a SystemChat); broadcast goes to every
player and the log.

--dev-mod now works on the server thread, and servers check a dev mod's
file once a second from their tick. Every hook returns at once when no
server mod runs; without the mods feature they are empty stubs.

Docs: docs/mods.md (where server mods run, when hooks run, not yet),
docs/api/mods.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:48:13 +01:00
Ludovic Marie 6f8cad0020 Merge pull request #1692 from socksprox/fix/streamed-server-pack-zip 2026-10-25 18:45:56 +01:00
Ludovic MarieandClaude Opus 5.5 aa2f60b7bd Copper golem statues turn to their next pose when right-clicked
CopperGolemStatueBlock.useItemOn: anything but an axe, an empty hand too,
cycles copper_golem_pose with its sound and block_change game event; honeycomb
on a weathering statue passes on to waxing. Axe scraping, unwaxing and
stripping and honeycomb waxing now count the item as used, and waxing sends
its block_change game event, as vanilla's useOn does. The use_item_on harness
gains statue cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:43:03 +01:00
Jan PresandOpenCode 13508cbd9c Creative: Ctrl+Backspace deletes a word in the search
The creative inventory's search box is an append-only EditBox of 50
characters, but its editing was a plain String pop, so Ctrl+Backspace
removed one character instead of the word before the caret, unlike every
other text field and vanilla's EditBox.keyPressed.

Move EditBox.getWordPosition and deleteWords into one shared helper in
ui.rs (the UI and chat text fields had two copies of it), and route a
Ctrl+Backspace in the creative search through it. A Select All selection
is still what backspace deletes first, as in vanilla.

Fixes #1764.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash Free) <[email protected]>
2026-10-25 17:41:07 +00:00
Ludovic MarieandClaude Opus 5.5 c965aad8d3 Shader packs, clean room 40: terrain in the shadow map, culled from the light
The shadow program now draws the world's terrain from the light's view.
The shadow pass culls its own section list against the shadow camera's
frustum (render distance, capped by shadowDistanceRenderMul), nearest
the light first, and draws only the faces toward the light. Its draws go
by arena page within 64-block bands from the light, as the camera's solid
draws do, so the flat path draws them in about ten calls per list instead
of one per page change (over a thousand with BSL). Its draws
follow the camera's translucent draws in the same instance and indirect
buffers, so the flat path gives them calls of their own. Sections never
sorted for the camera are sorted once so their translucent indices are
valid.

The frame's first shadow pass clears the shadow map and shadowcolor
buffers as it begins, instead of a separate pass that loads and stores
them all; the translucent shadow pass is timed under its own label.

Without a pack nothing changes: the shadow lists are empty and the
terrain stays one draw call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:36:20 +01:00
socksproxandDevin 72b9daabf0 Resolve legacy § codes where the font draws text
Name tags and styled HUD text drew a span's § codes as glyphs instead of
restyling what follows, as StringDecomposer.iterateFormatted does. A team
prefix like "§c" showed literally over players' heads, and a resource
pack's styled menu.disconnect kept its codes on the leave-server button.

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 18:09:13 +01:00
Ludovic MarieandClaude Opus 5.5 bb414b263c Merge main into fix/vehicle-passenger-pose: keep both lan_e2e checks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:08:13 +01:00
Ludovic MarieandClaude Opus 5.5 a276f81aba Shader packs, clean room 39: the run doc comment back on run
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:55:23 +01:00
Ludovic Marie f324251563 Merge remote-tracking branch 'origin/main' into cg14/pr1813 2026-10-25 17:53:14 +01:00
socksproxandDevin f2832f83ef check: cover the ui open/close-screen outputs
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 17:53:13 +01:00
Ludovic MarieandClaude Opus 5.5 5ee1130719 horizon-dualsense: iOS comments name only what exists
The repo has no horizon-gamecontroller crate, and on iOS the Link never
opens, so no rumble covers vibration there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:53:13 +01:00
Alexander KrylovandHermes Agent 9fe77c7a56 Creative: a stack keeps its components when it crosses the wire
`Game::to_network` wrote damage alone into SetCreativeModeSlot, and
`item_from_network` read back damage and enchantments, so anything else a
creative stack carried was gone once the server echoed the inventory: a
named sword lost its name, trimmed armour its trim, an enchanted book its
stored enchantments and its repair cost.

`item_sync` is the pair for the components Horizon models: `encode` writes
them with the synced registries' ids (an unknown name is left out rather
than sent as id 0, which is a different entry) and `apply` reads them back
into the same `StackData` fields `item_data::decode_components` fills.

Potions are not carried yet: `minecraft:potion_contents` has no id or codec
in horizon-protocol, so a creative potion still arrives as an uncraftable
one (#1832). Nor are containers, charged projectiles, written books and a
map id, which the packet models but this patch does not write.

Refs #1832

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 19:49:08 +03:00
Ludovic MarieandClaude Opus 5.5 6443847633 Launcher: add the panorama, Unifont, languages and sounds prepared resources lack
Versions prepared while the asset store could not be read (Prism Launcher
installs since 0.2.48) are silent with a grey title screen. Like the built-in
packs (#1539), the launcher now copies each of these from the player's own
asset store into prepared resources that lack it, at start, after an install
and before Play, without preparing again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:42:00 +01:00
Ludovic MarieandClaude Opus 5.5 b873f30c29 wears_freeze_immune above sync_player_body's doc comment, which it had taken over
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:41:04 +01:00
Ludovic MarieandClaude Opus 5.5 8c54e18587 /ride dismount: the boats' BOARDING_COOLDOWN, not a second 60
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:40:41 +01:00
Ludovic MarieandClaude Opus 5.5 0dfc6d8700 command_exec harness: summoned mobs' saved attribute bases and health (#1483)
A cow summoned with max_health and movement_speed bases, and a zombie
with an attack_damage base and a saved Health, against vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:38:28 +01:00
Ludovic Marie b983282247 Merge remote-tracking branch 'origin/main' into cg14/pr1850
# Conflicts:
#	tools/lan_e2e.py
2026-10-25 17:38:04 +01:00
Ludovic MarieandClaude Opus 5.5 a495fcf7a7 End crystal: one blast call for both paths
The respawn check is read before the blast, so the two copies of the
explosion collapse into one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:37:51 +01:00
Ludovic Marie 83e8cbc4b6 Merge remote-tracking branch 'origin/main' into cg14/pr1856 2026-10-25 17:37:23 +01:00
Ludovic MarieandClaude Opus 5.5 b729cfc833 Furnace experience: createExperience's chance, orbs, every removal, guests
- createExperience floors count * experience and adds one with the chance of
  the fraction (a level random nextFloat); flooring alone paid nothing for a
  single iron ore (0.7).
- The payout pops ExperienceOrb.award's orbs at the player, recipe by recipe,
  instead of adding points directly.
- A server's player (the default single-player path, LAN and dedicated)
  gets it too: lan_machine_click pays when the guest takes from the output.
- AbstractFurnaceBlockEntity.preRemoveSideEffects pays at the block's centre
  on any removal (explosions, /setblock, creative breaking), after the
  contents spill, so it moves from break_block to the removal side effects.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:37:22 +01:00
Ludovic MarieandClaude Opus 5.5 1a39af6b62 /pregen: plan any radius at once, and skip a strip's rows outside the circle
Pregen::new counted the area chunk by chunk and split the strips with
an i32 product: /pregen start 30000000 overflowed, then hung the server
thread for hours. Rows are now counted from the circle's half-width,
the strip edges in i64, and each strip starts and ends at the rows the
circle reaches, so a band never walks empty corner rows. Unit tests
check that every chunk of squares and circles is swept once and that
the largest radius plans at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:36:22 +01:00
Ludovic MarieandClaude Opus 5.5 71f0ffde4b Prepare: a launcher's rewritten version file falls back to Mojang's (#1772)
Launchers like HMCL rewrite versions/<v>/<v>.json in the official layout
(same jars and libraries, their own fields), and horizon-prepare refused it
as a version Mojang may have republished. Mojang's copy saved as the imported
manifest.json is now used instead, and without one the error says the
launcher may have rewritten it and how to get Mojang's.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:31:37 +01:00
Ludovic Marie 8942d00ace Merge remote-tracking branch 'origin/main' into cg14/pr1857 2026-10-25 17:30:38 +01:00
Ludovic MarieandClaude Opus 5.5 0609fb2beb Tooltip: lore after the enchantments, as ItemStack.addDetailsToTooltip
Vanilla adds LORE after CONTAINER, BANNER_PATTERNS, FIREWORKS,
POTION_CONTENTS, TRIM and the enchantments, and before the attribute
modifiers, not right under the name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:30:37 +01:00
Ludovic Marie e37770bbfe Merge remote-tracking branch 'origin/main' into cg14/pr1858 2026-10-25 17:30:01 +01:00
Ludovic MarieandClaude Opus 5.5 70aff6e90b Death screen: empty message with showDeathMessages off; forget a guest's record when they leave
- ServerPlayer.die sends CommonComponents.EMPTY to the death screen when
  showDeathMessages is off, as the local player's death_message already
  does; the combat record is only read when the rule is on.
- A guest's CombatTracker leaves guest_combat with them, so a long-running
  server doesn't keep one per connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:30:01 +01:00
Ludovic Marie b9d2d93ccb Merge remote-tracking branch 'origin/main' into cg14/pr1806 2026-10-25 17:27:41 +01:00
Ludovic MarieandClaude Opus 5.5 df23e1bfed Keep vanilla's death screen message; resolve with the loaded language
- Leave ServerPlayer.die's message as it was: with showDeathMessages off
  vanilla sends CommonComponents.EMPTY to the death screen, and #1858
  reworks the death message from the combat record.
- The effects panel and recipe book tooltips use Names::display_get, so a
  key missing from the language still falls back instead of showing the key.
- net::load_language reuses the language lang already loaded (packs
  included) instead of reading it from disk again.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:27:00 +01:00
Ludovic Marie 5115bc276b Merge pull request #1650 from socksprox/fix/proxy-transfers
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 17:26:57 +01:00
Ludovic MarieandClaude Opus 5.5 50727eb5c3 Harnesses: one scaffold for one-player dedicated server scripts
test_hardcore_respawn_parity.py copied test_locator_bar_parity.py's
build and run code; both now share locator.sides, and one-player
scripts run through locator.one_player.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:25:53 +01:00
Ludovic Marie 7bac73094f Merge pull request #1860 from ludovic111/fix/launcher-pack-test-windows
🤖 Agent: merged after verify and a launcher dry run on all three platforms (Windows tests pass); the launcher release follows.
2026-10-25 17:25:11 +01:00
Ludovic MarieandClaude Opus 5.5 6d3cd330d1 One isWithinBlockInteractionRange for guests, the in-frame player, stations, lecterns and signs
The reach test was written out four times (stations, lecterns, the
in-frame container check and the guest's); they now share
lan::guest::within_block_range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:25:10 +01:00
Ludovic Marie ef82e97d67 Merge pull request #1652 from socksprox/fix/remote-player-skins
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 17:24:13 +01:00
Ludovic Marie 1920106445 Merge pull request #1793 from socksprox/launcher-dark-titlebar
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 17:23:29 +01:00
Ludovic Marie aca93fa902 Merge pull request #1779 from levzzz5154/fix/sprint-fov-stuck
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 17:23:02 +01:00
Ludovic MarieandClaude Opus 5.5 204859a2cb Launcher: a preparation reusing imported game files still finds the assets
When an update's preparation reused the game files an earlier version imported,
`preparer` handed the chosen folder as it was to the sound copy and the panorama,
fonts, packs and languages step. A Prism Launcher instance or Modrinth App profile
holds no asset store, so the game was silent with a grey title screen, and
Prepare again did the same. It now resolves the folder (linking another
launcher's files into the official layout) either way; with reused imports a
missing one is a log line, not an error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:20:41 +01:00
Ludovic Marie e99414e9d3 Merge pull request #1795 from socksprox/t3/fix-remote-close-ghost-grid
🤖 Agent: thank you! Merged after the contribution gate (checked against vanilla 26.3, tested in the modes it touches; pr_ready on main + the six gate PRs: fmt, clippy, 1327 tests, 112/112 affected harnesses) and this head's verify.
2026-10-25 17:16:15 +01:00
Ludovic Marie 66a35ab2ea Merge pull request #1865 from ludovic111/fix/website-docker-data
🤖 Agent: merged after verify; image build simulated locally (hub, data, docs/api in dist).
2026-10-25 17:15:31 +01:00
Ludovic MarieandClaude Opus 5.5 d41a3075aa Launcher: a local registry's file:/// URLs open on Windows
file:///C:/x left /C:/x after the scheme, which Windows can't open; the pack tests now
write real file:/// URLs (the index only accepts those), with forward slashes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:13:26 +01:00
Ludovic MarieandClaude Opus 5.5 fd154dcbba Website image: copy data/ so the build finds the roadmap
build.mjs reads website/data/roadmap.json since the hub (#1819), but the Dockerfile never
copied data/, so every site deploy since then failed and playhorizon.dev kept the old build.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 17:09:25 +01:00
Ludovic MarieandClaude Opus 5.5 df8b23637e horizon-mods: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:09:21 +01:00
kaicsm fdfa3d568d Merge branch 'ludovic111:main' into fix/client-mods-server-thread 2026-10-25 13:01:39 -03:00
Ludovic Marie 7ab91a4d2e Merge pull request #1859 from ludovic111/feat/mod-tool-content
🤖 Agent: merged after verify passed; site built with every docs page and no broken links (platform-2), website tests pass.
2026-10-25 17:00:44 +01:00
Ludovic Marie 9f761a3e3b Merge pull request #1810 from ludovic111/docs/api-portal-website
🤖 Agent: merged after verify passed; site built with every docs page and no broken links (platform-2), website tests pass.
2026-10-25 16:58:41 +01:00
Ludovic MarieandClaude Opus 5.5 c640561000 Launcher: the registry pack tests build file URLs with forward slashes, so they pass on Windows
A Windows temp path's backslashes went into the test index's JSON as escapes, so the
index failed to parse and the Windows launcher release stopped at its tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:56:26 +01:00
Ludovic Marie d18b7701da Merge pull request #1852 from ludovic111/feat/hub-docs
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:56:07 +01:00
Ludovic Marie 1bf957989a Merge pull request #1851 from ludovic111/feat/hub-forms
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:54:01 +01:00
Ludovic Marie a230411fa3 Merge pull request #1839 from ludovic111/feat/hub-intake
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:51:54 +01:00
Ludovic Marie 26b94efcdf Merge pull request #1838 from ludovic111/feat/hub-votes
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:49:41 +01:00
Ludovic Marie e5214f7f25 Merge pull request #1821 from ludovic111/feat/hub-board
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:48:00 +01:00
Ludovic Marie 2576e80ff2 Merge pull request #1819 from ludovic111/feat/roadmap-hub
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:45:51 +01:00
Ludovic MarieandClaude Opus 5.5 effea2cfd4 Merge feat/hub-board (main's registry pages) into feat/hub-forms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:41:10 +01:00
Ludovic MarieandClaude Opus 5.5 bcee0d02ec Merge feat/roadmap-hub (main's registry pages) into feat/hub-board
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:40:01 +01:00
Ludovic MarieandClaude Opus 5.5 78ab03e554 Merge main into feat/roadmap-hub: the hub beside the registry's pages
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:39:17 +01:00
Ludovic MarieandClaude Opus 5.5 390da26cf4 horizon-mod-registry: the form's test sends a shader pack through the game's check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:38:54 +01:00
Ludovic MarieandClaude Opus 5.5 ba4cf45aac cargo horizon-mod: content mods (new --template content, build packs assets and data, check takes no code)
- `new --template content`: a content mod with one block (Glowing Amethyst,
  copying vanilla's amethyst block) and its block state, model, item model,
  name, loot, recipe and pickaxe tag; `--template code` is the default.
- `build` packs the mod's `assets/` and `data/` (read and checked as the
  game reads them) beside the component; a folder without `Cargo.toml` is a
  content mod and needs no compiler.
- `check` takes a `.hmod` with no `mod.wasm`: "no code: content only,
  nothing to run" instead of the trial run, which refused it, so the
  registry can publish content mods.
- The guide (content mods, make a mod) and docs/mods.md, docs/api/mods.md
  say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:35:58 +01:00
Joansitoh d9360338f6 Show item lore in tooltips with vanilla purple italic styles.
Stack tooltips skipped minecraft:lore entirely; draw the component lines as Text so colours and explicit non-italic spans survive.
2026-10-25 16:33:35 +01:00
Ludovic MarieandClaude Opus 5.5 1285247230 Registry: shader packs are checked by the game's own shader check, and open
The `shader` kind's check (index::inspect) reads the .hshader and runs
horizon_shader_api::check, the same check the game runs before it loads a
pack, so the registry's CI (`cargo horizon-mod check` / `registry build`)
refuses a pack the game would refuse, with each problem at its file and line.
Its facts are the manifest's id, version and shader API line. The kind's
files are .hshader, `cargo horizon-mod check` recognises them, and
registry::open takes shader submissions. Installing one from the launcher
stays refused (check_file) until the launcher installs shader packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:31:05 +01:00
JasonGuandOpenCode DeepSeek V4.1 Flash feec1b67e8 Server deaths: build the message from the player's own combat record
A guest's death only had `death_cause`, which `/kill` alone sets, so a
server-thread, LAN or dedicated death fell back to "X died" or a generic
cause. Give each guest its own CombatTracker, fed by the motion events
the server already records, and take the message from
CombatTracker.getDeathMessage with the dying player's own name. The
wither's skull names the wither that launched it (projectile owner).

The death screen now resolves the server's component in the client's
current language instead of the session's en_us table, so it matches the
chat line.

Co-Authored-By: OpenCode DeepSeek V4.1 Flash <[email protected]>
2026-10-25 23:29:23 +08:00
Alexander KrylovandHermes Agent bf81ae6358 Furnace: pay the experience a smelt earned
RecipesUsed was written as an empty compound and never read, so a furnace
paid nothing when its output was taken and nothing when it was broken.

- Furnace keeps the per-recipe counter, saves and loads it, and floors each
  recipe on its own as AbstractFurnaceBlockEntity.createExperience does.
- FurnaceResultSlot.checkTakeAchievements pays on a take: the counter is
  read and cleared when the output slot loses an item.
- AbstractFurnaceBlockEntity.playerDestroy pays the same counter as orbs at
  the block when it is broken, whatever the tool.

Fixes #1732

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-25 18:28:57 +03:00
Ludovic MarieandClaude Opus 5.5 6320718491 Shader packs, clean room 39: shadow buffers, their samplers and shadowcomp
When the shadow pass runs, the pack gets real shadow buffers (spec 6.3):
shadowtex0 (cleared to 1.0 every frame), shadowtex1 (a copy, made only when
a program reads it) and shadowcolor0/1 (each with two sides, made only when
used, cleared to their ShadowClear colour, white by default), square at
shadowMapResolution within the device's limit.

Samplers: shadowtex0/watershadow, shadowtex1, `shadow` (shadowtex1 beside
watershadow) and the HW forms read the shadow map, through a linear depth
comparison when declared sampler2DShadow; shadowcolor/shadowcolor0/1 read
the colour buffers. In the shadow programs, buffer n is shadowcolor n and
colortex reads a stand-in. Without the shadow pass nothing changes: the
names read the stand-ins as before.

shadowcomp..shadowcomp99 run after the shadow pass and before prepare, each
drawing into the alt side of the shadowcolor buffers it writes and flipping
them after, as composite programs do with colortex (6.1.6). The shadow pass
itself draws nothing yet, so the map stays clear.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
sections 2.1, 5.2 (samplers), 6.1.6 and 6.3; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:25:54 +01:00
Ludovic MarieandClaude Opus 5.5 86d8d9e3a1 Shader packs, clean room 38: load the shadow programs
The shadow pass runs when some program reads a shadow buffer (shadow,
watershadow, shadowtex0/1, their HW forms, shadowcolor, shadowcolor0/1) or
shadow.enabled=true; shadow.enabled=false keeps it off (spec 2.5). Then
`shadow` (solid and cutout terrain), `shadow_water` falling back to
`shadow` (translucent terrain) and shadowcomp..shadowcomp99 load, in the
new Shadow and ShadowComp groups (2.1, 2.4). shadow_solid and shadow_cutout
are not used: the game's solid and cutout terrain is one draw.

Their output n writes shadowcolor n (6.3): draw buffer lists stop at the
first index past 1. translate_shadow gives a shadow program the shadow
camera's matrices as its model-view and projection (5.2). The game does not
draw any of them yet.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
sections 2.1, 2.4, 2.5, 5.2 and 6.3; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:25:34 +01:00
Ludovic MarieandClaude Opus 5.5 5dcbe222c9 Shader packs, clean room 37: shadow settings and the shadow camera
The stage files' shadow constants (shadowMapResolution, shadowDistance,
shadowDistanceRenderMul, shadowIntervalSize, sunPathRotation, shadowMapFov,
shadowNearPlane, shadowFarPlane, shadowHardwareFiltering[0|1] and the
shadowcolor0/1 Format, Clear and ClearColor) are read into Config::shadow
(spec 6.3, 7.1).

The shadow matrices are no longer a placeholder (OQ-6): the camera looks
from the shadow light, sits 128 blocks toward it from a point that moves in
steps of shadowIntervalSize along the shadow view's own axes, and projects
orthographically over shadowDistance each side (or in perspective with
shadowMapFov), between shadowNearPlane and shadowFarPlane.

sunPathRotation turns the sun and moon's path about the world's X axis:
sunPosition, moonPosition, shadowLightPosition and the shadow camera follow
it, and so do the sun, moon and stars a pack's gbuffers_skytextured and
gbuffers_skybasic draw.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
sections 6.3 and 7.1; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:25:34 +01:00
Ludovic MarieandClaude Opus 5.5 a6f951e126 Website: Make a mod, the full guide from the first line to the registry
Six pages under a "Make a mod" group of the docs: set up and see a mod in
the game (the launcher's Game arguments or cargo horizon-mod run), the
manifest and every capability, the hooks and calls with a worked example
(Playtime, built and checked), content mods, testing and check, and
publishing (minisign signing, the submit form, review, updates). /mods,
the submit page, the docs index, Mods and data packs and the Mod API link
to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:22:19 +01:00
Ludovic Marie 1a91727eee Merge pull request #1815 from ludovic111/feat/roadmap
🤖 Agent: merged after verify passed (hub: website tests 30/30, roadmap tests + sync check, telemetry server 35 tests; local end-to-end run by roadmap-1).
2026-10-25 16:17:14 +01:00
Ludovic MarieandClaude Opus 5.5 818b9638ed CONTRIBUTING.md and docs/README.md point to the hub
Where to start is playhorizon.dev/hub: the roadmap, the work board, good first
tasks and the forms; roadmap PRs carry their roadmap:<id> label, ideas go to the
suggestion queue and issues are for bugs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:15:16 +01:00
Ludovic Marie c462eb0b48 Merge pull request #1846 from ludovic111/feat/registry-owner-links
🤖 Agent: merged after verify passed (website + launcher owner links, tests added).
2026-10-25 16:14:48 +01:00
Ludovic MarieandClaude Opus 5.5 c1ffa17c87 Hub: vote for roadmap items, report a bug and suggest something from the page
Each item still to come gets a "Want it" button with its count; the Contribute
tab gets a bug report form (the #horizon-bugs template, which the telemetry
server opens as a GitHub issue and links back) and a suggestion form (to the
private queue Kira relays), and a Contributors list that shows the Discord name
of those who agreed on Kira (/contributors.json). The forms and buttons appear
once the hub's server answers; until then the page points to GitHub and Discord.
roadmap/README.md documents the intake API that Kira shares.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:14:12 +01:00
Ludovic MarieandClaude Opus 5.5 4a9ca29887 Website: the API portal at /docs/api, with Markdown tables
The repository's docs/api/ (the portal, its surface pages and the generated mod and
server references) is built into the docs section: /docs/api is its README and
/docs/api/<page> each page, in two nav groups, "Build on Horizon" and "API reference".
The pages are read from the checkout or from main on GitHub, like the README, so the
site never holds a copy. Links between portal pages become the site's (and the build's
link check covers them); other relative links go to the file on GitHub.

The renderer gains tables: the "Ways in" table, the references and the other tables
on the site (apis, performance) showed as raw pipes. A link whose label is a code span
no longer throws.

Caddy and the dev server route /docs/api/<page> and redirect /docs/api/ to /docs/api;
Railway rebuilds when docs/api/ changes. /docs/mods links the Mod API, its reference
and its ways in; the summary page is now "APIs at a glance".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:14:03 +01:00
Ludovic MarieandClaude Opus 5.5 bac51e3fba Telemetry server: the hub's bug reports and suggestions
POST /hub/report opens a GitHub issue at once (labels bug and from-website, the
every template field required, people's text made inert (no mentions, issue
references, HTML or closing keywords) and the same report twice answered with the
first issue. If GitHub fails it answers 202 and the upkeep tries again for a week.
Off (503) without HORIZON_TELEMETRY_GITHUB_TOKEN.

POST /hub/suggest queues a suggestion; GET /hub/suggestions?after=<id> hands the
queue to Kira with the HORIZON_TELEMETRY_HUB_TOKEN bearer (404 when unset, 401 for
a wrong token). Nothing is published.

Per-address limits (20 an hour, bursts of 2) on keyed hashes of the address, daily
caps from everyone (30 reports, 200 suggestions), a honeypot field, entries kept
90 days.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:10:18 +01:00
Ludovic MarieandClaude Opus 5.5 91148cdf74 Telemetry server: hub votes allow 600 an hour per address (bursts of 60)
A visitor voting down the roadmap, or a school behind one address, ran out of
the 12-request burst. Votes are cheap; the item cap and the voter id still bound them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:09:48 +01:00
Kacpherek 5bfc649edd Drop the cursor stack of a dead player
A stack held on the inventory cursor at death stayed there through the
respawn, even with keep_inventory off. In vanilla the dead player is
removed 20 ticks after dying (or when it respawns sooner), and
Player.remove's inventoryMenu.removed drops what the cursor held where
the player died. Do the same for server players and the in-frame
player, and add a harness that dies with a cursor stack on a dedicated
server and compares with vanilla.
2026-10-25 16:09:09 +01:00
Ludovic MarieandClaude Opus 5.5 0eb475152e Registry: show who publishes a project, linked to their GitHub profile
The index already carries each project's owners (the GitHub accounts the
registry checked at submission), but neither the website nor the launcher
showed them. The project page now says "Published by @login" with the
avatar and a link to the profile; the launcher adds an @login link per
owner. Only strings that are valid GitHub logins become links.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:07:46 +01:00
Kacpherek 0c017678eb Keep shulker boxes out of shulker boxes on the server
ShulkerBoxSlot.mayPlace refuses items that cannot fit inside container items, which BlockItem says of every shulker box. The server's menus for guests treated a shulker box's slots like a chest's, so with the server thread a shulker box could be clicked, shift-clicked or swapped into another. Apply the slot rule when the open menu is a shulker box.
2026-10-25 16:05:57 +01:00
Kacpherek 10efca9e6c Discard a dead player's ender pearl when the rule says so
With ender_pearls_vanish_on_death on, a pearl thrown just before death
kept flying and pulled the respawned player back to it. Vanilla's
ThrownEnderpearl.tick discards the pearl instead of ticking it while
its owner is dead. Add a harness that throws a pearl, dies and respawns
on a dedicated server and compares the teleports with vanilla's.
2026-10-25 16:05:52 +01:00
Ludovic MarieandClaude Opus 5.5 e458e37249 Hub: journal.json in the flat format Kira reads, at /data/journal.json
One entry per merged pull request (with its roadmap item), game or launcher
release, shipped roadmap item (post and announce) and weekly summary, newest
first, ids stable forever: the feed kira's src/journal.js posts to #updates and
X. The journal's item links are hub#<id>; the board's and the journal's ids are
reserved too, and the page-wide unique-id test covers every tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:02:36 +01:00
Kacpherek 436c2fefb4 Freeze server-thread players in powder snow
With the server thread, the host's copy of a player never set isInPowderSnow from the blocks along its moves and never ran LivingEntity.aiStep's freezing, so a frozen player took no freezing damage and the client's own damage was overwritten. Record powder snow's FREEZE on the server path and run the freezing countdown and its damage every 40 ticks there. The guest's freeze-immune armor now comes from its worn armor, as for the local player.
2026-10-25 16:01:39 +01:00
Kacpherek f48cfb7966 Let /ride mount and dismount mob riders
/ride now looks up a mob's vehicle as well as the player's, so mobs
can mount mobs, get the already-riding and loop errors, and dismount
to the vehicle's dismount spot. execute on vehicle uses the same
lookup. The command_exec corpus gets a mob riding group.
2026-10-25 16:01:39 +01:00
Kacpherek 0481dbdb81 Make hardcore players spectators when they respawn
A hardcore death offered another survival life. Vanilla's death screen
says Spectate in hardcore, and the server makes the respawned player a
spectator (ServerGamePacketListenerImpl.handleClientCommand). Do the
same on the server and in the in-frame world, and add a harness that
compares a hardcore respawn on a dedicated server with vanilla's.
2026-10-25 16:01:39 +01:00
Ludovic MarieandClaude Opus 5.5 c04b7afdb9 Hub: the Kanban and the Journal tabs, and /hub/journal.json
Kanban: every open issue and pull request and what closed in the last seven days, placed in To do,
In progress, In review and Done by the rules of Kira's work board, each card with its roadmap item
(its roadmap:<id> label or the item linking it) and a filter by item. Journal: every release with its
changes grouped by roadmap item, the merges no release has yet, and a weekly summary from
roadmap/journal/<year>-<week>.md. The same journal is /hub/journal.json, for Kira's #updates and posts
elsewhere: each entry names the roadmap items it moved and shipped, with their 240-character text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 16:00:03 +01:00
Ludovic MarieandClaude Opus 5.5 c5a640dd56 Endermen, endermites, blazes, eyes of ender, dried ghasts and end portals make their ambient effects
The client-side branches vanilla runs on its own level: Enderman and
Endermite aiStep (two portal particles a tick), Blaze aiStep (two large
smoke particles, the burn sound one tick in 24), EyeOfEnder.tick (a
portal particle behind it, four bubbles in water), and the
DriedGhastBlock and EndPortalBlock animateTick. They run on the window's
game in-frame and as a server's client, never on a server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:59:56 +01:00
Ludovic MarieandClaude Opus 5.5 68234ac407 Hub: the roadmap at /data/roadmap.json and items at hub#<id>, as Kira reads them
Kira (kira #10-#14) fetches https://playhorizon.dev/data/roadmap.json and links
items as <hub>#<id>: the JSON moves there (readable from any origin) with the
announce flag, and each item's anchor is its id. Ids the page uses itself (tabs,
sections, headings) are refused by the check; a test keeps every id of the page
unique, which caught the overview's headings sharing ids with the roadmap's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:59:31 +01:00
rdbtCVSandOpenAI Codex 8ffd595575 fix: suppress secondary explosions from destroyed End crystals
Pass the crystal damage cause through destruction so explosion damage removes crystals without another blast, including during dragon respawn. Keep fight callbacks and direct-hit blasts.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-25 14:58:42 +00:00
Ludovic MarieandClaude Opus 5.5 e936e412ba Website: the hub (/hub) with the roadmap, a Simple / Technical switch and live states
playhorizon.dev/hub is the public entry point to what is happening on Horizon. Overview: where
Horizon is, the 3 to 5 things in focus, what is next, what shipped last, how to help. Roadmap: Now /
Next / Later / Help wanted / Shipped, filtered by area, every item with its issues and pull requests
in their GitHub state (baked at build, refreshed in the browser). Contribute: good first tasks,
claiming, connecting an agent to Kira, the rules. A Simple / Technical switch swaps every item's
plain and dev text and is remembered. /roadmap redirects to the Roadmap tab.

tools/roadmap.py build now also writes website/data/roadmap.json, which the site reads and serves at
/hub/roadmap.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:57:46 +01:00
Ludovic MarieandClaude Opus 5.5 a95be8a7f3 Roadmap: announce = true marks items Kira also posts on X when they ship
Kira's X poster (kira #12) posts shipped items flagged announce in journal.json.
Set on the five whose release is news: modpack world generation, the mod
registry, Discord Rich Presence, Microsoft sign-in and CurseForge. Flags must be
booleans.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:57:34 +01:00
Ludovic MarieandClaude Opus 5.5 bcd42f50e9 cargo horizon-mod test: server mods' scripts
A test script drives a server mod as a server would: join = "Alex" brings a
player in (ids stable per name, so a rejoin is the same player), as = "Steve"
picks who acts (the first player on otherwise), and player, chat, command,
break and place are that player's doing; leave = "Alex" lets them go. Server
expectations: told (every players.tell, "Name: text") and broadcast; feedback
and error read the reply to the player who typed the command. Ticks call both
kinds of tick hooks, so one script format serves client and server mods.

The welcome example gets two scripts (greeting, a newcomer announced once
even after a reload, /online across dimensions, /mined per player), run by
the sandbox tests with coords-hud's; docs/mods.md lists the server keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:55:55 +01:00
Ludovic Marie bd591f9857 Merge pull request #1814 from ludovic111/fix/mod-registry-pr-marker
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:54:52 +01:00
Ludovic MarieandClaude Opus 5.5 5d40d6a33e Telemetry server: the hub's votes (POST /hub/vote, GET /hub/votes)
A thumbs-up on a roadmap item at playhorizon.dev/hub: one per browser (a random
id the page keeps), taken back with "up": false, counts cached a minute. Item ids
are checked for shape and capped at 1000 distinct items. Rate limits key their
buckets by a keyed hash of the address (key drawn at start, in memory); no address
is stored. CORS preflight for the website's JSON posts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:54:39 +01:00
Ludovic Marie 930a18c912 Merge pull request #1640 from ludovic111/feat/launcher-horizon-modpacks
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:53:18 +01:00
Kacpherek 13e83e4e63 Read saved attribute bases for every mob
LivingEntity.readAdditionalSaveData applies the attributes list for any
living entity, then sets the health (the max health when none is saved).
Only horses read max_health and movement_speed, so a summoned cow kept its
type defaults. Read max_health, movement_speed and attack_damage for all
mobs before the health; horses still read jump_strength.
2026-10-25 15:51:29 +01:00
Ludovic Marie 1c67541379 Merge pull request #1638 from ludovic111/feat/launcher-horizon-tab
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:51:16 +01:00
Ludovic MarieandClaude Opus 5.5 c9f554d1c4 Mod API 0.3 (experimental): server mods' contract, runtime and SDK
A server mod runs where the world is simulated. Its manifest says side =
"server" and api = "0.3" (only a server mod may), and its component targets
the new WIT world horizon-server-mod:

- imports log and storage as client mods do, and the new capability players:
  online() (id, name, position, dimension, game mode, health), tell(id, text),
  broadcast(text), 8 lines per call shared with chat;
- exports server-events: init, tick, player-joined, player-left, chat, command
  (with the player who typed it; its feedback goes back to that player),
  block-broken and block-placed (with the block state as text).

horizon-mods: a second bindgen world sharing log and storage with the first;
a running mod holds either kind of hooks; Mods::load_for(Runs) starts only the
mods a game runs (the window client mods, the world's server server mods,
in-frame single player both) and marks the others Elsewhere. Client and server
commands are owned separately. The game passes Runs::Client for now, so
nothing changes in game until its server code calls these hooks.

The SDK gains horizon_mod_sdk::server (ServerMod, Registration,
export_server_mod!), the example server mod welcome (greets, announces
newcomers kept in storage, /online, /mined), and cargo horizon-mod check's
trial run plays a server mod with a player joining, chatting, typing its
commands and leaving. The manifest refuses client capabilities on a server
mod and players on a client mod. The generated reference, the guide, the
portal and the stability table describe it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:51:15 +01:00
Kacpherek 3843a9a4aa Use the team-formatted player name in chat
Chat, /say, /me and /tell now name the sender the way
Player.getDisplayName does: the team prefix, the name and the suffix
in the team colour. On a server the hub gets the scoreboard's teams
each tick; in-frame the existing player_display_name is used.
2026-10-25 15:51:11 +01:00
Ludovic Marie e28b453762 Merge pull request #1637 from ludovic111/feat/launcher-horizon-mods
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:49:13 +01:00
Kacpherek 467277ef6d Update comparators when a guest changes a chest through its menu
A container click from a server-thread or LAN player wrote the slots but
never called BlockEntity.setChanged, so comparators kept the old output.
Notify the block entity when the click changed the container's slots.
2026-10-25 15:48:51 +01:00
vlesmentieandClaude Opus 5.5 4bfa496173 Lava cauldrons burn the player inside them
The player's inside-block sweep skipped lava cauldrons, so standing in
one did nothing. It now tests the player against LavaCauldronBlock's
entity-inside shape (the cauldron's shape or its lava, Block.column(12,
4, 15)) the way Entity.checkInsideBlocks does, and applies
LavaCauldronBlock.entityInside: CLEAR_FREEZE, LAVA_IGNITE (15 seconds)
and Entity.lavaHurt (lava, 4), for the game's own player and a server's.

Fixes #1320.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:47:21 -03:00
Ludovic Marie 6a2512bfe7 Merge pull request #1636 from ludovic111/feat/launcher-horizon-source
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:47:09 +01:00
Ludovic Marie bd7bb1dfc6 Merge pull request #1822 from ludovic111/feat/native-shaders-sdk
🤖 Agent: merged after verify passed (horizon-shader-api only).
2026-10-25 15:46:54 +01:00
Ludovic Marie e785362aa7 Merge pull request #1635 from ludovic111/feat/website-mods-submit
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:45:17 +01:00
Kacpherek 18ec4145c4 Close crafting tables that are removed or out of reach
CraftingMenu.stillValid needs the block to still be a crafting table and the
player within reach of it. The table's position is now kept with the open
grid, so the menu closes in the tick (giving its grid back) and a stale
click no longer crafts, in single player and for LAN guests.
2026-10-25 15:44:49 +01:00
Ludovic Marie 6c4d1d6cbb Merge pull request #1596 from ludovic111/feat/website-mods
🤖 Agent: merged after verify passed; mod-registry-land-1's combined check (pr_ready --quick on all seven, launcher tests 125, website 25/25, no harness affected) and live end-to-end on horizon-mods.
2026-10-25 15:43:14 +01:00
Ludovic Marie 2dbdcda89c Merge pull request #1820 from ludovic111/feat/native-shaders-check
🤖 Agent: merged after verify passed (horizon-shader-api only).
2026-10-25 15:41:39 +01:00
Kacpherek ed7d88b213 Close container menus when the player moves out of reach
Container.stillValidBlockEntity also checks the player's block interaction
range plus a 4-block buffer. Guest menus now close in the tick once out of
reach and ignore clicks while invalid, and the in-frame single player menu
closes the same way.
2026-10-25 15:40:19 +01:00
Ludovic MarieandClaude Opus 5.5 49f62d02cb Horizon shader packs: horizon-shader new/check/pack, templates, an example pack and the guide
The SDK's tool, in horizon-shader-api: `horizon-shader new <dir> --template
post|sky|material` makes a pack that passes the check, `check <pack>...
[--json]` runs the check the game, the launcher and the registry run and
prints each stage's cost and the pack's SHA-256 or every problem at its
file:line:column, and `pack <dir>` checks then writes the reproducible
.hshader. Golden Hour, the example pack, warms the sky around a low sun and
adds a soft glow and a colour grade, with five options. docs/shaders.md is the
authors' guide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:38:23 +01:00
Ludovic Marie 220f057647 Merge pull request #1818 from ludovic111/feat/native-shaders-stages
🤖 Agent: merged after verify passed (new crate horizon-shader-api + generated reference with drift test).
2026-10-25 15:37:32 +01:00
Ludovic Marie f94b05032b Merge pull request #1817 from ludovic111/feat/native-shaders-format
🤖 Agent: merged after verify passed (new crate horizon-shader-api only).
2026-10-25 15:34:25 +01:00
Ludovic MarieandClaude Opus 5.5 923fdee42e Horizon shader packs: the check every pack passes before it runs
horizon_shader_api::check builds each stage as the renderer does and judges
it the same way wherever it runs (the game, the launcher, the registry's CI,
the coming horizon-shader tool): naga parses and validates it; the stage
function must have its signature; the pack's files declare no bindings, no
entry points and no horizon_ names; each override is an option of its type or
has a default, and each option is used; a stage stays within 20 000
expressions and 64 texture reads; and every entry point translates to SPIR-V,
Metal and HLSL with the options at their defaults, minimums and maximums.
Problems carry the pack's own file, line and column. Pack::sha256 gives the
reproducible package's hash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:34:07 +01:00
vlesmentieandClaude Opus 5.5 5fb0cad6e7 Wither roses give the player Wither
WitherRoseBlock.entityInside adds getBeeInteractionEffect (Wither, 40
ticks) to a living entity inside it on a server level that isn't
peaceful, unless it is invulnerable to wither damage. The player's
inside-block dispatch had no wither rose case, so neither singleplayer
mode applied it. The sweep now asks for it, the game adds it to its own
player, and the server-thread path adds it to a server's player.

Fixes #1319.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:31:02 -03:00
Ludovic MarieandClaude Opus 5.5 749a2dc9bd Global level events reach a server's players; explosions play their packet's sound
ServerLevel.globalLevelEvent (an end portal opening, 1038; the dragon's
death roar, 1028) went to the Game's own audio without a position, which
is never forwarded: on the server thread, LAN and dedicated servers
nobody heard them. They now go to every guest as a global LevelEvent,
which a client plays two blocks from its camera towards the event
(LevelEventHandler.globalLevelEvent).

ClientPacketListener.handleExplosion plays the packet's explosionSound;
the client always played entity.generic.explode (a vanilla server's wind
charge sounded like TNT).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:30:43 +01:00
260dd423c9 Keep sprint as a state so a sprint-jump keeps its forward impulse
PlayerInput was rewriting the server sprint flag from the Ctrl and forward
keys. That flag is synced back onto the client, and jumpFromGround adds its
0.2 look-direction impulse only while the flag is set, so releasing Ctrl
before the jump (or double-tapping forward) produced a plain hop. Sprint
still starts and stops through PlayerCommand.

Co-Authored-By: Grok <[email protected]>

Co-authored-by: 一水合氨NH3-H2O <[email protected]>
2026-10-25 14:30:42 +00:00
Ludovic MarieandClaude Opus 5.5 6b97107dc7 Transfers: handshake with the TRANSFER intent, answer login cookie requests
Vanilla 26.3 reconnects after ClientboundTransferPacket with a TransferState,
so Connection.initiateServerboundPlayConnection sends ClientIntent.TRANSFER (3):
a target with accepts-transfers=false refuses the player, and the target can
ask for the carried cookies during login
(ClientHandshakePacketListenerImpl.handleRequestCookie). Both now match.

A transfer from single player closes the world instead of connecting
elsewhere (handleTransfer: "Cannot transfer to server from singleplayer").

Adds a unit test for the play-state store/request round trip and the
Transfer event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:29:59 +01:00
Fo 4b5fa2ea6b build: make horizon-dualsense compile on iOS
hidapi 2.6.7 has no iOS backend, so the crate failed to build for
aarch64-apple-ios (hidapi's build script looked for hid.h). Gate the
hidapi and cpal dependencies plus all HID device/haptics code behind
cfg(not(target_os = "ios")) and provide an inert stub on iOS
(Link::start() is a no-op), mirroring how horizon-gamecontroller
already compiles empty on iOS.

Verified with cargo check -p horizon-dualsense --target
aarch64-apple-ios: clean, no warnings. Host clippy/tests are left to
CI (this sandbox has no C toolchain for the host target).

Co-Authored-By: Fo <[email protected]>
2026-10-25 14:28:55 +00:00
Ludovic MarieandClaude Opus 5.5 608d1a70a7 Horizon shader packs: the stages of API 0.1 and their generated reference
Three stages, each a WGSL function with a fixed signature that Horizon calls
inside its own pass: sky (fn sky(input: SkyInput) -> vec4<f32>), material
(fn material(input: MaterialInput) -> Surface) and post (fn post(input:
PostInput) -> vec4<f32>). A stage's WGSL is a generated prelude (the frame
uniform, the stage's structs, its resources in bind group 3, the post
helpers), the pack's shared files, its own file, then the entry points; each
part is located so errors point at the pack's file and line.

The frame is the Rust struct the renderer will upload; its WGSL, its offsets
and docs/api/shaders-reference.md are generated from it, and tests check that
the preludes compile, that naga's layout of the WGSL is the Rust layout, that
the sample pack's stages compile, and that the reference is up to date.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:27:43 +01:00
Ludovic MarieandClaude Opus 5.5 7bffc4c670 cargo horizon-mod test: run a mod's TOML test scripts headless
A mod's tests/<name>.toml scripts run in the sandbox the game uses, with no game
and no window. The runner plays the game's part (the player and clock the mod
reads, ticks, frames, chat lines, commands, a reload) and each step checks what
the mod did: its commands, chat lines, command feedback and errors, HUD panel
and corner, or that it was turned off. A failing step names itself and prints
expected against got. Each script runs on a scratch copy, so storage starts
empty and is removed afterwards.

horizon_mods::testing (feature runtime) holds TestGame and Script; the cargo
subcommand builds the mod (or takes --hmod), filters scripts by name, and fails
when one does. The template gains tests/hello.toml, coords-hud gains panel and
where scripts, and the sandbox tests run the example's scripts and check the
runner's failures, reload and expected stops. Scripts don't care what language
the mod is written in, so script mods (Lua) get the same runner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:25:52 +01:00
Ludovic MarieandClaude Opus 5.5 98c8db50a3 Horizon shader packs: horizon-shaders.toml (schema 1) and the .hshader package
A new crate, horizon-shader-api, reads and checks Horizon's own WGSL shader
packs (docs/api/shaders.md), so the game, the launcher and the registry judge
a pack the same way. This first part is the format: the manifest (id, name,
version, the api line, shared files, the sky, material and post stages,
options typed for WGSL override constants, a GPU time budget) and the package
(a folder or a .hshader zip, built reproducibly so its SHA-256 depends only on
its files). The stages' WGSL is checked in the next parts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:18:03 +01:00
Ludovic MarieandClaude Opus 5.5 6d418fdb3e Docs: publish a mod through the registry, and how horizon-mods runs
docs/mods.md gets "Publish your mod" (the form, the review, updates) and
its Distribution section points at the registry instead of the plan for
it. docs/api/mod-index.md says how horizon-mods runs: its checks, merging,
Pages, the weekly cleanup, and that removing an entry needs an admin merge
(takedowns go in revoked.toml). The service's README says where it runs on
Railway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:17:30 +01:00
Ludovic MarieandClaude Opus 5.5 98bebd26bd Launcher: one place reads the pinned appearance (theme::pinned_dark)
The title bar and the theme both resolved HORIZON_LAUNCHER_APPEARANCE and the
Settings choice; titlebar::apply_appearance now uses the theme's helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:14:48 +01:00
Ludovic MarieandClaude Opus 5.5 22c3f2bd0f Roadmap: roadmap/roadmap.toml, ROADMAP.md generated from it, and its checks
One source of truth for what is being done, what comes next and where to help: roadmap/roadmap.toml,
each item said twice (plain words for players, the technical picture for contributors), Now / Next /
Later / Help wanted / Shipped, no dates. tools/roadmap.py builds ROADMAP.md from it, checks its rules
offline (CI, through tests/test_roadmap.py) and against GitHub (links resolve, no stale Now item),
ships items whose pull requests all went out in a release (sync), and keeps a roadmap:<id> label per
item (labels). README links it; roadmap/README.md is the maintenance guide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:13:51 +01:00
Ludovic Marie 44ba133710 Merge branch 'feat/shader-packs-held-items' into feat/shader-packs-hand 2026-10-25 15:09:46 +01:00
Ludovic Marie aa04a30ad2 Merge branch 'feat/shader-packs-weather' into feat/shader-packs-held-items 2026-10-25 15:09:46 +01:00
Ludovic Marie 612024eff4 Merge branch 'feat/shader-packs-clouds' into feat/shader-packs-weather 2026-10-25 15:09:45 +01:00
Ludovic Marie c39579772c Merge branch 'feat/shader-packs-translucent-entities' into feat/shader-packs-clouds 2026-10-25 15:09:45 +01:00
Ludovic Marie 257654ef30 Merge branch 'feat/shader-packs-entities' into feat/shader-packs-translucent-entities 2026-10-25 15:09:44 +01:00
Ludovic Marie c4856e6feb Merge branch 'feat/shader-packs-alpha-test' into feat/shader-packs-entities 2026-10-25 15:09:44 +01:00
Ludovic Marie 90fe138e83 Merge branch 'feat/shader-packs-entity-spans' into feat/shader-packs-alpha-test 2026-10-25 15:09:44 +01:00
Ludovic Marie 15e0fcb3d8 Merge branch 'feat/shader-packs-sildurs' into feat/shader-packs-entity-spans 2026-10-25 15:09:43 +01:00
Ludovic Marie 07668d2d1e Merge branch 'feat/shader-packs-mip-current' into feat/shader-packs-sildurs 2026-10-25 15:09:43 +01:00
Ludovic Marie 202c421f6e Merge branch 'feat/shader-packs-biomes' into feat/shader-packs-mip-current 2026-10-25 15:09:43 +01:00
Ludovic Marie 90b5da76c4 Merge branch 'feat/shader-packs-normals' into feat/shader-packs-biomes 2026-10-25 15:09:42 +01:00
Ludovic Marie 57dca25dd6 Merge branch 'feat/shader-packs-block-ids' into feat/shader-packs-normals 2026-10-25 15:09:42 +01:00
Ludovic Marie dbc59979fb Merge branch 'feat/shader-packs-arena-storage' into feat/shader-packs-block-ids 2026-10-25 15:09:42 +01:00
Ludovic Marie 53db90264c Merge branch 'feat/shader-packs-sun-moon' into feat/shader-packs-arena-storage 2026-10-25 15:09:41 +01:00
Ludovic Marie 27f68454c1 Merge branch 'feat/shader-packs-sky' into feat/shader-packs-sun-moon 2026-10-25 15:09:41 +01:00
Ludovic Marie 1aad193be0 Merge branch 'feat/shader-packs-draw-uniforms' into feat/shader-packs-sky 2026-10-25 15:09:40 +01:00
Ludovic Marie c55f89ef98 Merge branch 'feat/shader-packs-mipmaps' into feat/shader-packs-draw-uniforms 2026-10-25 15:09:40 +01:00
Ludovic Marie 2617b67c79 Merge branch 'feat/shader-packs-timing' into feat/shader-packs-mipmaps 2026-10-25 15:09:39 +01:00
Ludovic Marie 15cf80921e Merge branch 'feat/shader-packs-water' into feat/shader-packs-timing 2026-10-25 15:09:39 +01:00
Ludovic Marie 60e8ec326a Merge branch 'feat/shader-packs-real-buffers' into feat/shader-packs-water 2026-10-25 15:09:39 +01:00
Ludovic Marie bf5c7e4ff2 Merge branch 'feat/shader-packs-custom-uniforms' into feat/shader-packs-real-buffers 2026-10-25 15:09:38 +01:00
Ludovic Marie aebc95fd00 Merge branch 'feat/shader-packs-run-globals' into feat/shader-packs-custom-uniforms 2026-10-25 15:09:37 +01:00
Ludovic Marie 754437fac6 Merge branch 'feat/shader-packs-terrain-draw' into feat/shader-packs-run-globals 2026-10-25 15:09:37 +01:00
Ludovic Marie 2f527aaaf2 Merge remote-tracking branch 'origin/main' into feat/shader-packs-terrain-draw
# Conflicts:
#	crates/horizon-client/src/gfx.rs
2026-10-25 15:09:36 +01:00
Ludovic MarieandClaude Opus 5.5 0f2a497f02 Trapped chests give their signal, barrels show they're open
TrappedChestBlock.getSignal is the chest's opener count (at most 15);
TrappedChestBlockEntity.signalOpenCount updates the blocks around it and
below it when the count changes. GameLevel answered 0 for every trapped
chest. BarrelBlockEntity's openers set open=true for the first one and
open=false when the last one leaves; nothing set it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:09:16 +01:00
Ludovic MarieandClaude Opus 5.5 a9e6818d2a Merge main (workspace: main's registry exclude + this branch's predator-missile example)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:09:03 +01:00
Ludovic MarieandClaude Opus 5.5 ceff5a2110 Launcher: installing a taken-down registry mod says it was taken down, and why
It said the mod had no version for 26.3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:06:34 +01:00
vlesmentieandClaude Opus 5.5 e9d2c10417 Peaceful zombie horses spawn without their zombie rider
ZombieHorse.finalizeSpawn gets its rider from EntityType.create, which
returns null for a notInPeaceful type (the zombie) in peaceful, so vanilla
spawns the zombie horse alone there. spawn_jockeys created the rider
regardless. mobs/finalize.rs already skips it through level.create.

Fixes #1761.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:06:00 -03:00
Ludovic MarieandClaude Opus 5.5 93d05591c2 horizon-mod-registry: a submission's pull request says it came through the form
The form opens pull requests with the registry's token, which is Ludovic's
account, so the body now starts "🤖 Submitted through playhorizon.dev by
@login" and says neither Ludovic nor a reviewer wrote it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:04:04 +01:00
Ludovic MarieandClaude Opus 5.5 319c7fcaee A server's client shows the nether portal effect
NetherPortalBlock.entityInside runs on the client's own level too: its
setAsInsidePortal drives LocalPlayer.handlePortalTransitionEffect (the
purple overlay, block.portal.trigger). The remote motion loop dropped
MotionEvent::InsidePortal, so single player on the server thread, LAN
guests and dedicated servers' players went through portals without it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:58:51 +01:00
Ludovic MarieandClaude Opus 5.5 5680da9834 Merge feat/launcher-horizon-tab (main merged) into feat/launcher-horizon-modpacks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:32 +01:00
Ludovic MarieandClaude Opus 5.5 41e68aeb76 Merge feat/launcher-horizon-mods (main merged) into feat/launcher-horizon-tab
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:31 +01:00
Ludovic MarieandClaude Opus 5.5 28ad41eb22 Merge feat/launcher-horizon-source (main merged) into feat/launcher-horizon-mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:31 +01:00
Ludovic MarieandClaude Opus 5.5 de195755f7 Merge origin/main into feat/launcher-horizon-source
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:57:20 +01:00
Ludovic Marie 587af1e345 Merge feat/website-mods (main merged) into feat/website-mods-submit
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	website/build.mjs
2026-10-25 14:57:08 +01:00
Ludovic MarieandClaude Opus 5.5 65fa108cba Merge origin/main into feat/website-mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:56:52 +01:00
Ludovic MarieandClaude Opus 5.5 a2fccf0205 Flint and steel lights nether portals only in the Overworld and the Nether
BaseFireBlock.inPortalDimension: a frame lit in the End stays a fire.
The world code's fire placement already checked it; the flint-and-steel
path (Game::try_light_portal) didn't, so a portal could be lit and used
in the End in every mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:52:17 +01:00
socksproxandDevin 56560b467a Multiplayer: answer cookie requests, store cookies, follow transfer packets
ClientboundCookieRequestPacket in play state decoded as Unknown and was
dropped; StoreCookie and Transfer likewise, and the configuration-phase
CookieRequest was always answered empty. Proxies (BungeeCord, Velocity)
store and read cookies around a server switch and send Transfer to move
a player to another address — both ended in a kick.

Play-state cookie_request/store_cookie/transfer now have packet types,
cookie requests answer from the stored cookies in both configuration and
play (ClientCommonPacketListenerImpl.handleRequestCookie), stored cookies
land in the session's map (handleStoreCookie), and a transfer reconnects
the game to the target address carrying them (handleTransfer →
ConnectScreen.startConnecting with TransferState).

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 14:51:32 +01:00
JasonGuandOpenCode DeepSeek V4.1 Flash b457f6382a Localization: names, container titles, death text and search follow the language
- item and block names in tooltips, held labels, command feedback and the
  creative tabs read the selected language; the pinned English map stays
  for logic and search
- Game::translate resolves through lang::get_or, so station titles follow
  the language screen
- container titles and Inventory labels for chests, hoppers, furnaces,
  smokers, blast furnaces, crafters, shulkers and mounts are translated
- server-resolved components (menu titles, death messages, command
  suggestion tooltips) load the selected language over en_us, and follow
  a language change while a world is open
- death messages always reach the death screen translated; the chat
  broadcast still obeys showDeathMessages
- creative search matches the localized display name
- sign editors use sign.edit and hanging_sign.edit

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

Surface stage, one thread, seed 1234: 2.45 -> 2.08 ms per chunk.
2026-10-25 14:40:37 +01:00
Kacpherek e181b17e17 Register new chunks' ticks before post-processing them
postprocess_chunks ran before tick_blocks registered the chunks that
arrived since the last tick, so the fluid and block ticks their marks
schedule went to chunks the scheduler did not know and were dropped.
Vanilla registers a chunk's tick containers before postProcessGeneration.
2026-10-25 14:40:37 +01:00
Kacpherek 4cd1504513 Pregen: report what a stalled band waits for, and requeue lost light
When the band hasn't moved for 30 seconds the task logs how many chunks
of its first row are not loaded, being lit, unlit with no light job or
not post-processed, with the streaming stage counts, and queues light
again for unlit chunks whose neighbours are all loaded.
2026-10-25 14:40:37 +01:00
Kacpherek f56785c2f5 Keep a chunk's scheduled ticks when it unloads before the next tick
attach_save_data took a chunk's ticks only from the tick scheduler, which
registers new chunks once a tick. A chunk loaded and unloaded again in
between was saved with its block and fluid ticks cleared, though its own
saved list still held them.
2026-10-25 14:40:37 +01:00
Kacpherek 35254c4f02 Pregen: keep an empty dedicated server from pausing while it runs
pause-when-empty-seconds paused a server with nobody online after a
minute, and a paused server skips its tick: post-processing stopped, so
the band waited forever on a row of lit chunks. A running /pregen now
counts as activity; the countdown starts once it is done.
2026-10-25 14:40:37 +01:00
Kacpherek 1506c1808a Add /pregen: generate, light and save an area ahead of the players
A Chunky-like command for single player and servers: /pregen start
<radius> [square|circle] [<x> <z>], status, pause, continue and cancel,
with progress (chunks, chunks/s, time left) in the log and to the player
who started it.

The area is swept strip by strip by a band of rows that streaming treats
like a player's area (Streaming::set_band). A row is done once its chunks
are lit and post-processed; the rows behind the band then unload and are
saved, so memory holds only the rows in flight. The band's chunks are
ranked for decoration row by row as they come within reach, so a task
decorates in the same order however its jobs are timed. Between ticks
the server waits on the workers' results and schedules the next jobs at
once instead of once a tick.
2026-10-25 14:40:37 +01:00
Ludovic MarieandClaude Opus 5.5 68deac8533 Server clients see mobs' aggression and arm swings
Mob.DATA_MOB_FLAGS_ID now carries the aggressive bit (with its default
0, so it goes back off) and every client mob reads no AI, left-handed
and aggressive from it: a vindicator draws its axe, skeletons their bow,
piglins and illagers their attack poses on the server thread, LAN guests
and dedicated servers. Mobs' swings (LivingEntity.swing) go out as
ClientboundSwingAnimationPacket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:38:44 +01:00
Ludovic MarieandClaude Opus 5.5 2f1f0150eb mobs diagnostics: say why passenger is set outside the json! macro
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:37:26 +01:00
cristibctrandOpenAI Codex c756a372a3 Fix stale Windows resource pack overlays
Invalidate version 3 overlays that can omit untouched vanilla resources. Cover folder and ZIP pack cache recovery, and use a writable timestamp handle in the Windows cache test.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-25 15:32:57 +02:00
Ludovic MarieandClaude Opus 5.5 fb1142cd71 Skins: keep vanilla's whole-payload rejection, drop the dead name fallback
authlib 10.0.77 MinecraftServicesSessionService.unpackTextures returns
MinecraftProfileTextures.EMPTY when any texture has no URL or a URL outside
the allowed domains, so skins::unpack goes back to main's behaviour.

remote_player_names: r.players and r.tab_players are filled from the same
ADD_PLAYER entries and emptied by the same PlayerInfoRemove, so the
tab-list fallback could never apply.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:31:41 +01:00
Ludovic MarieandClaude Opus 5.5 806f7d87f3 Merge origin/main into fix/vehicle-passenger-pose
Keeps main's crystals check and this branch's boat-dismount-pose check in tools/lan_e2e.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:29:29 +01:00
Ludovic MarieandClaude Opus 5.5 e069750ca1 Merge origin/main into fix/sprint-fov-stuck
Keeps main's crystals check and this branch's sprint-fov check in tools/lan_e2e.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:29:28 +01:00
socksproxandDevin 41b799042a Sync mobs' aggressive and item-use flags so clients pose them right
Mob.DATA_MOB_FLAGS_ID's aggressive bit and LivingEntity's
DATA_LIVING_ENTITY_FLAGS never reached a client for mobs: on the
server thread and for LAN guests a skeleton kept its arms down while
its arrows flew, and a drawn bow never pulled. Send both entries (with
their defaults so a flag going back to 0 is sent too), decode them for
every mob, and let a remote mob's item-use ticks count up as
LivingEntity.updatingUsingItem does. The same entries drive the zombie
arms, the vindicator's axe, the drowned's trident and the spellcasters'
poses, so the pillager's charging and evoker's/illusioner's casting
entries go with them. A left-handed mob now mirrors the pose and the
held item (Mob.getMainArm), and a bow in the off hand counts for
LivingEntity.isHolding.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 14:26:55 +01:00
Ludovic MarieandClaude Sonnet 5.5 c5cfab5e07 Merge feat/server-dialog-screen into feat/styled-menu-titles
Clean merge.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:25:14 +01:00
Ludovic MarieandClaude Sonnet 5.5 ec2f1b2a0c Merge feat/server-dialog-screen into feat/server-dialog-inputs
Clean merge.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:25:14 +01:00
Ludovic Marie 6a065fa489 Merge pull request #1788 from ludovic111/feat/discord-presence
🤖 Agent: merged after verify passed; Rich Presence e2e 6/6 on macOS and Linux.
2026-10-25 14:24:58 +01:00
Ludovic MarieandClaude Sonnet 5.5 330a5205ae Merge feat/server-dialogs into feat/server-dialog-screen
Conflict: lib.rs module list (dialog_screen + discord_presence), both kept.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:24:52 +01:00
Ludovic MarieandClaude Sonnet 5.5 49dd31d36f Merge origin/main into feat/server-dialogs
Conflicts: lib.rs (dialog + discord_presence modules), packets/play.rs tests
(boss_event_round_trips + dialogs_round_trip), both kept.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:24:45 +01:00
Ludovic Marie e6dbc3b39f Merge pull request #1787 from ludovic111/feat/discord-presence-ipc
🤖 Agent: merged after verify passed; Rich Presence e2e 6/6 on macOS and Linux (see #1788).
2026-10-25 14:22:55 +01:00
Ludovic Marie e6b0f92427 Merge main 2026-10-25 14:21:57 +01:00
Ludovic Marie dbc66a5a41 Merge pull request #1697 from ludovic111/feat/shader-packs-terrain
Shader packs, clean room 13: loading the terrain program
2026-10-25 14:21:33 +01:00
Ludovic Marie d14b100a97 Merge pull request #1670 from ludovic111/feat/shader-packs-atlas
Shader packs, clean room 12: the block atlas packs sample
2026-10-25 14:21:21 +01:00
Ludovic Marie 9ed21bf436 Merge pull request #1708 from rdbtCVS/t3/7c6098f0
Website: unify navigation across Home, Releases, Benchmarks and Docs
2026-10-25 14:21:12 +01:00
Ludovic Marie ab2006a230 Merge pull request #1669 from ludovic111/feat/shader-packs-gbuffers-translate
Shader packs, clean room 11: translating gbuffers programs
2026-10-25 14:21:07 +01:00
Ludovic Marie b61ef8f9be Merge pull request #1668 from ludovic111/feat/shader-packs-post-perf
Shader packs, clean room: fewer passes and loads in post-processing
2026-10-25 14:20:56 +01:00
Ludovic Marie bd4236f3dd Merge pull request #1656 from ludovic111/feat/shader-packs-post
Shader packs, clean room 10: post-processing over Horizon's world
2026-10-25 14:20:44 +01:00
Ludovic Marie 8ec23a2606 Merge pull request #1655 from ludovic111/feat/shader-packs-uniforms
Shader packs, clean room 9: frame uniforms, and gl_FragCoord in buffer passes
2026-10-25 14:20:32 +01:00
Ludovic Marie 1b6a55e47d Merge pull request #1654 from ludovic111/feat/shader-packs-post-programs
Shader packs, clean room 8: a dimension's post-processing programs
2026-10-25 14:20:19 +01:00
Ludovic Marie c143be7fd0 Merge pull request #1653 from ludovic111/feat/shader-packs-post-load
Shader packs, clean room 7: buffer settings and output lists from stage files
2026-10-25 14:20:05 +01:00
Ludovic Marie da8ae88eee Merge pull request #1601 from ludovic111/feat/shader-packs-translate
Shader packs, clean room 6: composite-style programs to naga
2026-10-25 14:19:52 +01:00
Ludovic MarieandClaude Opus 5.5 df626cf2f3 A tool that changes a block ends the click on a server's client
On a server's client (single player on the server thread, LAN guests), a
shovel making a path, an axe stripping, a hoe tilling or honeycomb waxing
did not end the right click: the client went on to the other hand, so an
off-hand torch was predicted as placed and its hand swung instead of the
tool's (#1289).

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

Fixes #1289

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 14:15:28 +01:00
Ludovic Marie 3a419877b0 Merge pull request #1752 from rdbtCVS/t3/fix-end-crystal-attack
Fix End crystal attacks from connected clients
2026-10-25 14:11:52 +01:00
Ludovic Marie 5fefa6ce42 Merge pull request #1689 from rdbtCVS/t3/d001fd2c
Slow Falling clears the fall distance so landing does not hurt
2026-10-25 14:11:43 +01:00
Ludovic Marie 6e4cb6210e Merge pull request #1687 from rdbtCVS/fix/creative-hotbar-hover
Creative inventory: highlight hovered hotbar slots
2026-10-25 14:11:33 +01:00
Ludovic Marie 985ec67b83 Merge pull request #1685 from sdraeger/fix/nbt-fireballs
Fireballs from NBT fly: /summon, chunk loads, selectors and /data get
2026-10-25 14:11:23 +01:00
Ludovic Marie ca05c89c6e Merge pull request #1676 from sdraeger/fix/ctl-screenshot-spaces
horizon-ctl screenshot: paths with spaces
2026-10-25 14:11:15 +01:00
Ludovic Marie 18c74b438c Merge pull request #1675 from sdraeger/fix/tp-mob-pitch
/tp: keep the pitch for mobs
2026-10-25 14:11:05 +01:00
Ludovic Marie ff7cfe0f10 Merge pull request #1695 from Domanffe/chore/deslop
Cleanup: one predicate property helper, a Windows-only allow, tolerant locks in the bots
2026-10-25 14:10:57 +01:00
Ludovic Marie 250c148e78 Merge pull request #1673 from Domanffe/fix/title-menu-labels
Title: the main menu labels follow the game language
2026-10-25 14:10:49 +01:00
Ludovic Marie 40fcc91eff Merge pull request #1682 from Domanffe/feat/horizon-lang-namespace
Options: Horizon's own captions read a bundled language namespace
2026-10-25 14:10:41 +01:00
Ludovic Marie 628e230217 Merge main (the save_root lint fix, #1789) 2026-10-25 14:08:29 +01:00
Ludovic MarieandClaude Opus 5.5 47d1b060b2 Discord presence: test where the game looks for Discord on macOS, Linux, Flatpak and Snap
sockets() reads the environment through a function, so a unit test can
check the paths a real Discord listens on for each platform ($TMPDIR on
macOS, $XDG_RUNTIME_DIR and its Flatpak and Snap subfolders on Linux,
named pipes on Windows). Offscreen e2e runs can't cover this: an automated
run only talks to a fake Discord named by HORIZON_DISCORD_IPC_DIR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:57:19 +01:00
socksproxandDevin 87b3c9aa38 Drop the stale craft grid when a remote menu closes
The server's AbstractContainerMenu.removed gives the grid and the
carried stack back to the inventory and syncs those slots, but the
client kept its copies: a crafting table closed with items inside left
ghost stacks in the inventory's 2x2 grid that could not be picked up.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 13:51:12 +01:00
Ludovic MarieandClaude Opus 5.5 40c77c02f0 PalettedContainer: look up a word's filled bits, not divide for them
`locate` found an entry's word by multiplication (the MAGIC table) but
still divided `64 / bits` at run time for the shift, on every block read
and write of a section stored in 5, 6 or 7 bits. A division by a value the
compiler does not know costs tens of cycles on x86: `perf annotate` of a
10-bot server on the ThinkPad put 12% of `PalettedContainer::get`, the
server thread's top function, on that `div`. The table now also holds the
bits a word's entries fill, so the shift is `i * bits - word * filled`.
A test checks `locate` against the division for every width and entry.

ThinkPad (i5-7200U), 4M random reads, 5-7 bit palettes: 28.5-36.9 ->
24.6-32.4 ns over 2,000 sections, 6.7-7.1 -> 5.4-5.8 ns within one;
4 and 8 bits (shifts, unchanged) the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:50:35 +01:00
Ludovic MarieandClaude Opus 5.5 224b83c6a6 Shader packs, clean room 36: the hand
A pack with gbuffers_hand (or textured_lit, textured, basic; spec 2.3)
draws the first-person hand and held items after the opaque world, into
its own buffers and the world's depth, without blending and with the
entities' 0.1 alpha cut. The vertices are Horizon's hand geometry, read
as entities are; `gl_ProjectionMatrix` is the hand's own projection (the
hand's field of view and bob) with depth scaled by MC_HAND_DEPTH, while
`gbufferProjection` stays the world's (5.2). Horizon's own hand draw is
skipped only when the pack draws it.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1;
no other loader's code read.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:46:45 +01:00
socksproxandDevin 3740de5116 Launcher: pin the macOS title bar to the chosen appearance
The launcher's Appearance setting darkened the page but not the window's native chrome: NSWindow keeps the system appearance, so a launcher set to Dark still drew a light title bar on a light desktop. apply_appearance (titlebar.rs) pins the NSWindow's appearance to the choice at startup and when the setting changes; System clears the pin so the chrome follows the computer again.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 13:40:07 +01:00
Ludovic MarieandClaude Opus 5.5 18cac82d44 Shader packs, clean room 35: held item ids and light
`item.properties` (spec 4.1) gives `heldItemId` and `heldItemId2` the
id of the main and off hand's item: -1 for an item the file lacks or an
empty hand, 0 without the file (5.3). `heldBlockLightValue(2)` are the
light levels of the held items' blocks, as Horizon's dynamic lights
already compute them. With `oldHandLight` (on unless the pack turns it
off, OQ-21) the first id and light are those of the brighter hand.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1;
no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 13:35:44 +01:00
socksproxandDevin 0bc85843ce Client: remote players keep their skins instead of falling back
skins::unpack discarded a profile's whole textures payload when any one
entry lacked "url" or pointed outside the texture services' domains, so
a player with e.g. an unusual cape lost their skin too; it now drops only
the offending texture. The 32 skin atlas slots evicted past players back
to defaults on busy servers — now 128. A dimension reset (join game or
respawn onto another world) now also clears the entity-id→profile map so
a re-used entity id can't pin the wrong skin or name to a player, and the
nametag join falls back to the player-list entry's name.

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

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

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

Fixes #1679.

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

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-25 16:15:36 +05:00
levzzzandCodex 57ee081038 Keep sprint state independent of held sprint keys
Let PlayerCommand control the server sprint flag, matching vanilla, so releasing Ctrl cannot leave the client's speed modifier and FOV stuck. Cover the packet sequence and FOV recovery in regression tests.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-25 15:56:53 +05:00
Ludovic MarieandClaude Opus 5.5 1437537cbf Worker threads: leave one core, as vanilla does, not two
Graphics::worker_threads sizes the worker pools (the server thread's, which
generates and lights, and the window's, which meshes). Its automatic count
left two cores to the render and server threads; vanilla's executor leaves
one (Util.maxAllowedExecutorThreads). The workers run behind those threads
(niced on Linux; above four cores, raised server and render threads on
macOS and Windows), so the second core mostly idled: on a 4-thread laptop
only 2 cores generated while a world loaded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:49:57 +01:00
Ludovic MarieandClaude Opus 5.5 3063d87199 docs: Discord Rich Presence is live with the Horizon application
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:49:53 +01:00
Ludovic MarieandClaude Opus 5.5 15e46114a6 Discord Rich Presence: show what the player does, with a switch in Horizon Settings
Each frame the game hands the presence thread its status (menus; single
player, multiplayer or hosting a world with its party size; the
dimension; the game mode), and only a change wakes it. The switch
"Discord Rich Presence" (on by default, discordPresence in
horizon-extras.txt) sits beside Controller Settings. Dedicated servers
never start it. tools/discord_presence_e2e.py runs real games against a
fake Discord: title, world, Nether and quit on the server thread and
in-frame, a LAN host (both paths) and guest, the switch, and a Discord
started after the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:49:44 +01:00
Ludovic MarieandClaude Opus 5.5 e49de342ed Discord Rich Presence: the Horizon application's id
Discord's "Horizon" application (1563857609738690560, public) has the
horizon logo as its large image; no game-mode images yet. The feature is
live: only HORIZON_NO_DISCORD=1, the switch and automated runs keep it
quiet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:47:13 +01:00
Ludovic MarieandClaude Opus 5.5 c879c22a6a Merge the branch's own main merge
item.rs: the same components either way; keep the branch's order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:45:44 +01:00
Ludovic MarieandClaude Opus 5.5 39904c8051 docs/mods.md: the contract lists the command interface
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:33:08 +01:00
Ludovic MarieandClaude Opus 5.5 e7ca664e01 Server: a guest's boost rocket is theirs
The server attached every boosting rocket to its own player: it followed
the host, and DATA_ATTACHED_TO_TARGET named the host (or nothing on the
server thread, which has none), so the guest's client never saw a rocket
attached to them and did not boost. The rocket now keeps the guest it was
fired by, follows them and names their entity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:32:23 +01:00
Ludovic MarieandClaude Opus 5.5 11035336a0 Server: a gliding player's rocket boosts them
FireworkRocketItem.use attaches a rocket only to a player who is fall
flying. The server tracks a guest's glide (START_FALL_FLYING) but the
body it swaps in for their item use left fall_flying unset, so on the
server thread, LAN and dedicated servers the rocket did nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:24:29 +01:00
socksproxandDevin c99de82044 Merge main into fix/elytra-fireworks: keep firework components beside trim/raw passthrough
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 11:13:42 +01:00
Ludovic MarieandClaude Opus 5.5 604ec7e2e4 Discord Rich Presence: the IPC client and its thread (inert until wired)
discord_presence.rs speaks Discord's local IPC itself (discord-ipc-N
socket or named pipe; opcode + length + JSON frames; HANDSHAKE, then
SET_ACTIVITY with the pid) from a thread of its own. The game hands it a
status; the thread connects lazily, retries every 15 s, keeps within 5
updates per 20 s, refreshes every minute and clears the activity on
quit. Inert while DISCORD_APP_ID is the placeholder, with
HORIZON_NO_DISCORD=1, and for automated runs unless
HORIZON_DISCORD_IPC_DIR points at a test's fake Discord; lab.game_env
sets HORIZON_NO_DISCORD=1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 11:04:38 +01:00
Ludovic MarieandClaude Opus 5.5 173463acdf Shader packs, clean room 34: rain and snow
A pack with gbuffers_weather (or its fallbacks textured_lit, textured,
basic; spec 2.3) draws the game's rain and snow after translucent
terrain, blended, with vanilla's 0.1 alpha cut. The weather quads become
entity vertices with their UV animation applied on the CPU (the same
phase Horizon's weather shader computes; a test checks it against the
weather columns bit for bit), and rain and snow each read a copy of
their sprite, so the pack's `texture` repeats down the column as a
standalone texture would. The shared atlas gains COPY_SRC for that copy.

Without a pack nothing changes: Horizon's weather draw is skipped only
when the pack draws it.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1;
no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:51:53 +01:00
Ludovic MarieandClaude Opus 5.5 5262effa7e cargo horizon-mod check: list the commands a mod runs
Main's check reports every output; the command capability adds one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:48:35 +01:00
Ludovic MarieandClaude Opus 5.5 c2c8df4542 Mods: run a mod's commands on the next tick
Run while the mod's output was shown, a command's chat line reached the
mods again inside that call: a mod that answers a line with a command
that prints one recursed without end, and when a control-socket command
set the hook off, the mod's commands ran with the console's level. Queue
them for the next tick instead, where the player's own level applies (the
server already checks it on a server).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:47:54 +01:00
Ludovic MarieandClaude Opus 5.5 f08546fd74 Merge main into the mod command capability
Cargo.toml: keep main's horizon-shaders member beside predator-missile.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:44:31 +01:00
Ludovic MarieandClaude Opus 5.5 abe7047d19 Shader packs, clean room 33: clouds
A pack with gbuffers_clouds draws Horizon's cloud geometry itself (spec
2.3, OQ-3: no fallback), blended, after the opaque world. Horizon's own
cloud draw is skipped then, also when the pack's program writes nothing
(BSL and Complementary hide vanilla clouds that way). Without a pack the
cloud draw is unchanged.

The translator now rewrites a bare `discard;` or `return;` to
`if (true) discard;` / `if (true) return;`, so code a pack leaves after
it stays reachable for naga ("Expression not visited"). This also fixes
Complementary Unbound's gbuffers_skytextured.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1;
no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:32:10 +01:00
Ludovic MarieandClaude Opus 5.5 272e230371 Merge main into the elytra fireworks branch
Main now reads past components it doesn't model (Component::Raw) and
added ArmorTrim; this branch's Fireworks and FireworkExplosion keep
their structured decode beside them, ahead of the Raw fallback.
remote_projectiles takes main's FallStep import too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:24:06 +01:00
Ludovic MarieandClaude Opus 5.5 90ae953747 Rockets: collapse the boost's nested if (clippy::collapsible_if)
Verify failed on it with warnings denied.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:21:48 +01:00
Ludovic MarieandClaude Opus 5.5 3bc38cebb4 YUNG jigsaws: the start is not counted, priority, depth and bounds rules run
From spec-yungsapi rev 3 §3, §3.1 and §3.2 (impl-questions IQ-2, IQ-3):
- the start piece does not count toward max_count;
- is_priority entries are tried first, in the pool's shuffled order, without
  a weighted draw; min_required_depth skips a pick (no draw) while the parent
  piece is shallower; ignore_bounds places without the free-space test (its
  box is still taken, IQ-12);
- the fallback was already shuffled only when reached.

The port no longer counts these three fields as lost. The layout harness gets
yung-probe and yung-probe2: the spec team's probe pack (built from the oracle
profile) carried as a mod, compared start by start with the oracle's census:
384 of 384 and 32 of 32 starts match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:17:21 +01:00
Ludovic MarieandClaude Opus 5.5 3a7d00a74c Shader packs, clean room 32: translucent entity parts and particles
A shader pack now draws particles with `gbuffers_particles` (falling
back to `gbuffers_textured_lit`, `gbuffers_textured`, `gbuffers_basic`)
and the entity parts that blend, a sneaking player's faded name tag and
entity shadows, with `gbuffers_entities_translucent` (falling back to
`gbuffers_entities`). Both blend with the translucent default unless
`blend.*` says otherwise; particles keep vanilla's alpha cut at 0.1.
Name tag text draws with `gbuffers_entities`, as cutout (2.3).

Particles draw after deferred, with translucent terrain, unless
`particles.ordering=before` or `particles.before.deferred=true`;
translucent entities draw before deferred unless
`separateEntityDraws=true` (3.2).

Without a shader pack nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 10:11:09 +01:00
Ludovic MarieandClaude Opus 5.5 ab289a2cf3 Shader packs, clean room 31: entities and block entities
A shader pack now draws entities with `gbuffers_entities` and block
entities with `gbuffers_block` (or their fallbacks, spec 2.3), after
terrain. Each run of the entity spans is one draw with its own
`entityId` or `blockEntityId` and `entityColor` (the hurt red, the
white flashes) in a per-draw slot. Horizon still draws everything else
(particles, cracks, beams, name tags, shadows, End portals, clouds) in
its own single entity draw, without the runs the pack drew.

The entity vertices are read again at set 1, so the pack gets the face
normal where a vertex has none (block entities), `at_tangent` along the
texture's u and `mc_midTexCoord`, as for terrain.

Entities and block entities draw without blending, with the loader's
alpha test at GREATER 0.1, as cutout (OQ-23); name tags and shadows,
which blend, stay Horizon's for now.

`HORIZON_SHADER_PACK_TIMING` no longer panics when a pass skips a whole
report window (no entities in view).

Without a shader pack nothing changes: the split only runs while a pack
draws entities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:51:55 +01:00
Ludovic MarieandClaude Opus 5.5 b5a791170d Shader packs, clean room 30: the loader's alpha test
Gbuffers programs get the loader's alpha test on output 0 when they
write `gl_FragData`/`gl_FragColor` (spec 3.2 `alphaTest.<program>`;
with `out` variables the pack tests alpha itself). `GbuffersInputs`
carries the geometry's default, none for terrain, water and the sky so
far; `alphaTest.<program>` replaces it for that program (`off`, or a GL
comparison and a reference).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:51:55 +01:00
Ludovic MarieandClaude Opus 5.5 d6cc631735 Merge main into the End crystal attack branch
Conflict in tools/lan_e2e.py's check list: main added shulker_bullets
beside crystals; both kept, crystals' entry aligned with the others.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:37:40 +01:00
Ludovic MarieandClaude Opus 5.5 0d18ff7ca7 Fall distance: Levitation resets it too, as LivingEntity.aiStep does
aiStep calls resetFallDistance under either Slow Falling or Levitation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:36:54 +01:00
Ludovic MarieandClaude Opus 5.5 b7fcd446b6 Fireballs: LeftOwner and HasBeenShot are saved as Projectile writes them
Projectile.addAdditionalSaveData writes LeftOwner only when it is set and
HasBeenShot always (true from the first tick on); fireball_to_nbt wrote
LeftOwner:0b and no HasBeenShot. Same shape as arrow_to_nbt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:36:32 +01:00
Ludovic MarieandClaude Opus 5.5 f23db61ecf /tp: a mob's head turns and its old rotation follows, as teleportSetPosition does
Entity.teleportSetPosition calls setYRot, setYHeadRot and setXRot, then
setOldPosAndRot: the head faces the new yaw and yRotO/xRotO match, so a
teleported mob doesn't sweep from its old pitch on the next frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:35:46 +01:00
Ludovic MarieandClaude Opus 5.5 b470bec351 Cleanup: one lock helper in horizon-bots, the predicate helper beside its trait
The twenty unwrap_or_else(|e| e.into_inner()) calls become one lock()
helper, so the bots read as they did with the same poison tolerance.
bound_property moves from worldgen.rs (between DecodeError's impls) to
block_predicate.rs, next to PredicateBlockResolver, whose
predicate_property it implements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:35:25 +01:00
Ludovic MarieandClaude Opus 5.5 53e1191480 Title: the Friends icon reads vanilla's gui.friends.open
FriendsButton's message is gui.friends.open, so the title screen needs
no Horizon key for it. The fallbacks are en_us's own text for the
three icons ("Language...", "Accessibility Settings..."), as the
other labels' are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:34:33 +01:00
Ludovic MarieandClaude Opus 5.5 f28722fb15 Lang: drop horizon.menu.friends, vanilla has gui.friends.open
TitleScreen's Friends button is vanilla's FriendsButton, whose message
is gui.friends.open ("Friends" in en_us, translated in every language
the prepared resources carry), so Horizon needs no key of its own for
it. Bundled now sits after the trait it implements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:34:24 +01:00
Ludovic MarieandClaude Opus 5.5 eb6c81945a Lithostitched processors: unit tests rewritten from the spec
The previous tests of litho.rs are deleted unread (clean-room ruling of
2026-10-25). The new ones build their own block registry from a facts
table and run each processor through process_list against a one-block
world: block_swap's property copy (CTOV's beetroots), references in
order and discard_input, the probe pack's nested condition with else,
apply_random per piece and per block, set_block's providers and its
block entity modifier.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:29:53 +01:00
Ludovic Marie 8d9e1e95ac Merge remote-tracking branch 'origin/main' into fix/remote-tnt
# Conflicts:
#	tools/lan_e2e.py
2026-10-25 09:12:04 +01:00
Ludovic MarieandClaude Opus 5.5 d89fb16f60 Structure blocks harness: CTOV's known differences
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:06:14 +01:00
Ludovic MarieandClaude Opus 5.5 58e184f5d5 Structure blocks harness: place the oracle's own pieces, split piece boxes from their surroundings
The mod world now takes each saved start whose pieces are all jigsaw pieces
as the oracle saved it (chunk_dump's HORIZON_SAVED_STARTS, through a new
GeneratorWorker::use_saved_start and PoolPiece::from_saved), so the blocks
are checked whatever the layout: Lithostitched lays jigsaws out its own way
(spec-lithostitched OPEN-L5) and the layout harness checks layouts apart.
Lithostitched's wrapper elements place their delegate; 1.21.1 block states
are read in 26.3's shape.

Counts are split between positions inside a piece's box and around them
(terrain adaptation, decoration), and every mismatch class is written to
.local/structure-layout/<profile>/blocks-report.json.

A CTOV profile carries Lithostitched and ChoiceTheorem's Overhauled Village
from a pack's jar folder (.local/structure-oracle/pack-jars).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 08:51:17 +01:00
Ludovic MarieandClaude Opus 5.5 c43f4bdb2b Lithostitched's processors as the game's own types; replace the old Lithostitched conversion
Clean-room implementation from spec-lithostitched.md §3-§5 and spec-ctov.md §4.

The game reads Lithostitched's processors as its data writes them
(processor/litho.rs): block_swap (only the properties the new block can
have: ripe wheat becomes ripe carrots, beetroot keeps its default age),
reference, apply_random, condition (with else, one processor or a list),
set_block (simple and weighted providers, preserve_state, block entity
modifier) and discard_input; the conditions all_of, any_of, not,
random_chance, true and matching_blocks (input or location, properties).
Their random follows random_mode per block or per piece; Lithostitched's own
seeding is open (OPEN-L6), so a positional random of the world seed and the
processor stands in (impl-questions IQ-7).

horizon-port-pack's new lithostitched module carries the processors as they
are, with the processors nested in them carried, drops those the game cannot
read (lists named by tag, other providers) as Java processors, turns CTOV's
modular_compat into a reference when its mod is ported along (else nothing),
maps lithostitched:jigsaw to vanilla's jigsaw (vanilla layouts, the decision
on OPEN-L5), unwraps limited/guaranteed/delegating elements to their
delegate (limits counted as lost until the next PR) and writes
add_template_pool_elements modifiers as horizon:add_to pools when their
NeoForge load conditions pass.

Deleted unread: legacy_structure_mods/lithostitched.rs and horizon-world's
structure/processor/lithostitched.rs. Blind edits to the legacy folder,
located by exact text or the compiler's error position: its module line
removed, dead_code allowed on ctov (its modular compat is unused now) and on a
test helper, and ctov's modular compat test ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 08:51:17 +01:00
Ludovic MarieandClaude Opus 5.5 aac2f3a903 Shader packs, clean room 29: entity spans
While a shader pack is loaded, building the entity geometry records where
each entity's vertices begin, with its render stage (entities, block
entities, particles, breaking cracks) and its `entityId` or
`blockEntityId` from the pack's `entity.properties` and
`block.properties` (65535 for a name the file lacks, 0 without the
file). The spans reach the renderer in `Frame::entity_spans`, for the
pack's per-entity draws that follow; Horizon's own entity draw stays one
call and without a pack nothing is recorded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 07:44:21 +01:00
Kaic Salomão da150409ea Complete and color mod commands in the chat on the server thread 2026-10-25 02:49:22 -03:00
Ludovic MarieandClaude Opus 5.5 f80f4e01e7 Structure blocks harness: the blocks Horizon's structure mods place against the structure oracle's
The clean-room structure oracle saved, for each start of a mod world, the
blocks of its box and every position around it that differs from the no-mod
world of the same seed. tools/test_structure_blocks_parity.py generates the
same chunks in full with Horizon twice (26.3 alone, and with the mods carried
by horizon-port-pack as players' packs are) and compares: a position counts
only where Horizon's no-mod world equals the oracle's (1.21.1 and 26.3 differ
elsewhere), and a mismatch is reported by its no-mod, expected and Horizon
blocks, so a missing processor rule reads as one line.

Better Dungeons (3 seeds, 96 complete starts): every structure is listed as
known, with the reason: the placeholder processors, the biome modifier and the
mod's own structure types are not implemented yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:39:52 +01:00
rdbtCVSandOpenCode GPT-6.1 9409d2c19b Multiplayer: allow punching End crystals
Include End crystals in attack-only object hit-testing using their network entity IDs. Add a real-mouse LAN regression for survival and creative, reach, block occlusion, passing item use, and explosion damage.

Co-Authored-By: OpenCode GPT-6.1 <[email protected]>
2026-10-25 05:38:00 +00:00
Ludovic MarieandClaude Opus 5.5 7b35ca2799 Shader packs, clean room 28: Sildurs Vibrant renders
Three things kept Sildurs Vibrant black or wrong:

- Matrix and array varyings (`varying mat3 tbnMatrix;`): wgpu's stage
  interfaces take only scalars and vectors, so the translator now carries
  each column or element as its own varying and copies them out of and
  into a private global around the pack's `main`.
- Switch options defined in an included file whose name is not a shader
  extension (`#include "shaders.settings"`) were not read, so every
  `program.<name>.enabled` that tested them was false and the composites
  were skipped (OQ-13).
- `fogStart`/`fogEnd` mixed the start of one of vanilla's two fogs with
  the end of the other, so on clear days fog began at the camera and
  Sildurs' water turned white. They now come as the pair of the fog that
  is complete nearer (OQ-36).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:35:43 +01:00
Ludovic MarieandClaude Opus 5.5 379692f355 Port YUNG's worldgen as written; delete the old YUNG conversion and assembler
Clean-room (spec-yungsapi.md §2, §3, §5; no mod source read; the legacy
files were deleted unread). horizon-port-pack's new `yung` module carries
`yungsapi:yung_jigsaw` structures and `yungsapi:yung_single_element` pool
elements as the mods write them, so players' packs reach the YUNG draws of
the jigsaw assembler. Fields whose behaviour the specification leaves open
(adapt_noise, max_y/min_y, enhanced terrain, element conditions, modifiers,
priorities, depths, ignore_bounds) are counted as lost by name. YUNG's
other structure, placement and processor types go to the general code
(unsupported or dropped) until the specification covers them.

Deleted: legacy_structure_mods/yung.rs, horizon-world's structure/yung.rs,
the `horizon:yungsapi` keys and `horizon:disables`, which nothing writes
now. Two test initializers of the removed field (enhanced_terrain.rs,
piece_limited.rs) were removed by line from the compiler's error positions.

The layout harness now carries the mods through `horizon-port-pack carry`:
82 of 84 Better Dungeons starts match the oracle piece for piece, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:19:43 +01:00
Ludovic MarieandClaude Opus 5.5 9a8453c612 Shader packs, clean room 27: sample mip chains only while current
A program sampled every level of a buffer that has mipmaps, even after a
later program had rewritten the buffer: the levels below the first were
stale. Complementary's FXAA, which samples its colour buffer with implicit
derivatives inside branches, picked those levels at edges and drew black
dots along every edge. A buffer now binds its whole chain only while the
chain matches its content, and its first level otherwise (6.1.7).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:12:32 +01:00
Ludovic MarieandClaude Opus 5.5 bf59c0c4f7 Shader packs, clean room 26: biome uniforms and constants
Custom uniform expressions and programs now see the biome at the camera:
`biome`, `biome_category`, `biome_precipitation`, `temperature` and
`rainfall`, and the `BIOME_*`, `CAT_*` and `PPT_*` constants (spec 3.4).
Before, `biome` and every `BIOME_*` read 0, so a test such as
`biome == BIOME_PALE_GARDEN` held everywhere: Complementary drew the pale
garden's fog over every world, and BSL treated every biome as cold, dry,
swampy and so on at once.

OQ-11: `biome` is the index in the game's biome registry (vanilla's
biomes sorted by identifier); names from before the 1.18 renames map to
their successors; a `BIOME_` name that matches no biome reads -1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:12:32 +01:00
Ludovic Marie 30c4b5e80d Merge remote-tracking branch 'origin/main' into sh-top 2026-10-25 06:08:23 +01:00
Ludovic Marie 066f07968b Merge remote-tracking branch 'origin/refactor/port-pack-legacy-structure-mods' into feat/yung-port-as-written 2026-10-25 05:38:20 +01:00
Ludovic MarieandClaude Opus 5.5 732efe69bb Shader packs, clean room 25: terrain normals and tangents
Flat terrain's vertex stage reads its quad's first three corners from the
arena page (bound per page at set 3, beside mc_Entity's table) and gives
gl_Normal / vaNormal the face normal and at_tangent the tangent along
the texture's u, with w the handedness of bitangent = cross(tangent,
normal) (spec 5.1). The pack's terrain and water pipelines cull back
faces as the game's own terrain does (clockwise front faces, rows being
flipped to GL's): water's surface no longer shows its downward copy.

Flat draws call a hook per arena page (Flat::draw_with); the arena lists
its page buffers.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
5.1; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 05:16:38 +01:00
Ludovic MarieandClaude Opus 5.5 441f749c25 Shader packs, clean room 24: mc_Entity from block.properties
block.properties (spec 4.1) is read: `block.<id>=` names with optional
namespaces and state filters; the most specific matching entry wins.
Terrain vertices carry their sprite layer, not their block, so the game
builds a table of the block sprite layers: each takes the id of the block
named like its sprite, else the id most blocks drawing it share; y is 1
on the water and lava sprites. Flat terrain reads it from a storage
buffer at set 3; other draws and packs without the file read (-1, -1).
Building the table takes 14 ms for BSL, 39 ms for Complementary.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
4.1, 5.1, 5.3; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:59:39 +01:00
Ludovic MarieandClaude Opus 5.5 b940754555 Terrain vertex arena readable as storage where flat draws run
The terrain vertex arena's pages also get STORAGE usage on devices that
run flat draws (vertex storage and compute), so a shader pack's terrain
program can read the other corners of a vertex's quad to give packs a
face normal and tangent. Horizon's own pipelines are unchanged; the A/B
in the PR shows no frame-time change without a pack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:23:38 +01:00
Ludovic MarieandClaude Opus 5.5 36f68ab838 Server dialogs: text and boolean inputs
A dialog's text inputs are edit boxes (labelled above, CommonLayouts.
labeledElement; width, max length and initial value from the dialog) and
its boolean inputs are checkboxes with their label. A click focuses a box
or toggles a box, Tab and Shift+Tab move between the text inputs, the
keys, shortcuts and typed text go to the focused one. What they hold
fills the command templates and custom actions' payloads.

With this, AuthMe's register and login dialogs work: players type their
password and join instead of being kicked after 30 s.

horizon-ctl's `type` types into the focused input of an open dialog (the
first one when none is), in configuration too. server_e2e's dialog check
now types a number and ticks a box, and checks the command the template
makes of them and the payload of a custom action.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:05:25 +01:00
Ludovic MarieandClaude Opus 5.5 230730d1f5 Shader packs, clean room 23: the sun and moon (gbuffers_skytextured)
gbuffers_skytextured (or gbuffers_textured, gbuffers_basic) draws the
frame's sun and moon quads, with their render stages, `texture` reading
the shared atlas their sprites live in. The sky's parts draw in vanilla's
order (disc, sunset, sun, moon, stars, void), in one render pass while
consecutive programs write the same buffers. The sun and moon add to the
sky (SRC_ALPHA, ONE) unless blend.<program> says otherwise. Horizon's
world pass skips its own sun and moon when the pack draws them; a part
without a program stays Horizon's.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
2.3, 5.4; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 04:02:13 +01:00
Ludovic MarieandClaude Opus 5.5 7d6583dc4d Shader packs, clean room 22: the sky (gbuffers_skybasic)
The pack's gbuffers_skybasic (or gbuffers_basic) draws vanilla's sky
geometry, as Horizon's own sky builds it: the disc at y 16, the sunset
fan while the sun is low, the stars and the void disc, each with its
render stage. One static vertex buffer; the frame's colours and rotations
sit in a small uniform the vertex inputs read. The sky draws after the
world copy and before terrain, at the far plane: only where nothing else
drew, without writing depth. It blends with the translucent default unless
blend.<program> says otherwise. Horizon's world pass then skips its own
sky disc, stars and void. The sun and moon stay Horizon's for now
(gbuffers_skytextured is next): the pack's sky covers them.

load_post takes the game's inputs per geometry (GameInputs);
GbuffersInputs gains statements run after the pack's main.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
2.1, 2.3, 5.4; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:45:24 +01:00
Ludovic MarieandClaude Opus 5.5 1fdb119587 Server menus keep their styled titles
A server's open_screen title is a text component, and container screens
draw it as AbstractContainerScreen does: in the label grey, under the
component's own colours, bold, italics and fonts, with the § codes a
literal holds. Horizon flattened it to a plain string, so a plugin's
gold and bold "Server Selector" menu showed in plain grey (#1645).
Chests, hoppers, dispensers, droppers, brewing stands and the other
workstations now draw the server's title. Before, the workstations
ignored it and showed their default name.

horizon-ctl's `screen` reports the title span by span (text, colour,
bold, italic). server_e2e's new `titles` check names a chest on the
vanilla server with a styled CustomName, opens it, and checks the title.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:30:04 +01:00
Ludovic MarieandClaude Opus 5.5 a7f8650c6b Server dialogs: the dialog screen, its buttons and their actions
A server's show_dialog (configuration and play) opens vanilla's
DialogScreen instead of being dropped: the header with the title and the
warning button, the message bodies in a scrolling column, the action
buttons packed into columns (MultiButtonDialogScreen) or side by side in
the footer (notice, confirmation). A button runs its click event: a
custom action goes back as custom_click_action, a command runs (in play;
in configuration vanilla only warns), show_dialog opens another dialog,
copy_to_clipboard copies. after_action closes the screen, leaves it, or
opens the WaitingForResponseScreen. The warning button opens the
"custom screen" warning, whose Disconnect leaves the server. clear_dialog
closes it, and so do the level loading screen and a disconnect. The world
and HUD blur behind it as behind any in-game screen.

Inputs come next; here they are not drawn and keep their initial values.

tools/server_e2e.py is a new harness: a vanilla 26.3 dedicated server and
one Horizon client. Its dialog check shows a dialog with /dialog show,
clicks a command button (the server's trigger), a custom action (the
server's debug log shows its payload) and a wait_for_response one, and
clears a dialog with /dialog clear.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:29:45 +01:00
Ludovic MarieandClaude Opus 5.5 b9b47f12da Shader packs, clean room 21: per-draw uniforms (renderStage)
A gbuffers program's renderStage, entityId, blockEntityId and entityColor
(spec 5.3, updated per draw) move to a block of their own at set 2 that
the game picks per draw with a dynamic offset: one slot per render stage.
Terrain draws as TERRAIN_SOLID, translucent terrain as
TERRAIN_TRANSLUCENT. The terrain test pack paints any other stage red.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
5.3, 5.4; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:29:21 +01:00
Ludovic MarieandClaude Opus 5.5 5e348aa479 Shader packs, clean room 20: buffer mipmaps
`const bool <buffer>MipmapEnabled = true;` in a deferred, composite or
final program's fragment stage now regenerates that buffer's mip chain
right before the program (6.1.7), on the side programs read, and only
when the buffer changed since its last chain this frame. Each level is
the average of the 2x2 texels above it; levels attach one at a time and
programs sample all of them. Unfilterable buffers (integer, 32-bit
float) get none, with a note.

BSL's bloom read every tap from the full-size buffer before; with its
chain composite4 drops from 0.38 to 0.29 ms at 720p on the Mac mini
(HORIZON_SHADER_PACK_TIMING, the chains included), and blurs as intended.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
section 6.1.7; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 03:03:54 +01:00
Ludovic MarieandClaude Opus 5.5 1d90d72a64 Server dialogs: the packets and the dialog model
show_dialog and clear_dialog in configuration and play, and
custom_click_action, the answer, in both. Dialog.DIRECT_CODEC as
horizon_client::dialog: the five dialog types, their bodies, inputs and
actions with vanilla's defaults, the values inputs hold, and
Action.createAction (a command template's $(key) substitution with
escapeWithoutQuotes, a custom action's payload of every input's tag).

Nothing uses them yet: the screen comes next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:59:28 +01:00
Ludovic MarieandClaude Opus 5.5 b6345fc61d Shader packs, clean room 19: GPU time of each pass (developers' aid)
HORIZON_SHADER_PACK_TIMING=1 puts timestamps around every pass of a
shader pack, one frame in 30, and logs each pass's median every 30
timed frames. The frame after a timed one waits for the GPU before
reading them (Metal resolves timestamps without waiting for the passes
that write them). Passes overlap on tiled GPUs, so a pass is charged
from the later of its beginning and the previous pass's end.

This is what attributes a pack's cost to its own programs or to ours
when the machine is shared and FPS comparisons are noisy.

Clean-room: Horizon's own tooling, no spec section; no other loader's
code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:57:15 +01:00
Ludovic MarieandClaude Opus 5.5 3e2c30a852 Shader packs, clean room 18: translucent terrain drawn by the pack
With a pack whose gbuffers_water (or a fallback down gbuffers_terrain's
chain) loads, translucent terrain (water, stained glass) is drawn by it
after the deferred programs and before the composites, in place, sorted
back to front as the game sorts it, and Horizon's own translucent
terrain draw is skipped.

- blend.<program> (off, or four GL factors); translucent terrain
  defaults to SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ONE_MINUS_SRC_ALPHA, and
  only blendable buffer formats blend.
- depthtex1/depthtex2: depth without translucent terrain, a copy taken
  just before it when a later program reads them (before it, depthtex0
  itself). The hand is drawn after the pack, so depthtex2 = depthtex1.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1,
sections 2.1, 2.3, 3.2 (blend), 6.2 (depthtex1/2); no other loader's
code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:47:14 +01:00
rdbtCVSandOpenCode gpt-6.1-sol e820e9e1af Website: use one navigation bar across pages
Co-Authored-By: OpenCode gpt-6.1-sol <[email protected]>
2026-10-25 01:40:39 +00:00
Ludovic MarieandClaude Opus 5.5 a2ec3fc791 Shader packs, clean room 17: real packs' buffers and samplers
- RGBA16 and R11F_G11F_B10F buffers get their own formats where the device has them
  (16-bit normalized render targets need the adapter's format features): the float stand-in
  kept NaN and values past 1, and BSL's TAA history went black for good.
- Textures share one sampler variable per kind (linear, nearest, repeat, nearest-repeat,
  comparison): devices have as few as 16 sampler slots, Complementary's terrain program
  declares 23 textures.
- A gbuffers pass reading the depth buffer or a buffer it draws into reads a blank texture
  instead (a feedback loop: undefined in GL, refused by wgpu, and a crash here).
- A bind group layout that fails validation skips the program with the error's details
  instead of ending the game.
- shadowModelView / shadowProjection (and inverses) for programs that read them before the
  shadow pass exists: an orthographic camera 160 blocks each side, toward the shadow light.
- HORIZON_SHADER_PACK_SKIP=a,b leaves programs out (a developers' aid to bisect a pack).

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1, sections 6.1, 6.7,
5.2; no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:03:17 +01:00
Ludovic MarieandClaude Opus 5.5 505e8e960f Shader packs, clean room 16: custom uniforms
`uniform.<type>.<name>` and `variable.<type>.<name>` of shaders.properties (3.4): an
expression parser with C precedence, the function table (math, vectors, `if`, `between`,
`equals`, `smooth`, `random`...), evaluated on the CPU once per frame in file order after the
frame's own uniforms. `smooth` keeps its state per call site and halves the distance to its
target every fade time (OQ-26 working answer). A name neither the frame nor the pack defines
reads 0 and is listed once in the log.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1, section 3.4; no other
loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:01:48 +01:00
Ludovic MarieandClaude Opus 5.5 6c8880fbd8 Shader packs, clean room 15: globals initialized at run time, switch options
naga keeps only constant initializers of globals, so every initialized global that is not
`const` (and every conditional one) gets its value from a function of ours that main calls
first, one per declarator, converting as GLSL's initialization does (`vec2 v = atlasSize`).
`vec3 a = x, b = y;` no longer loses `b`.

Program-enable expressions (3.3) now see the pack's switch options that are on by default
(8.1): `#define NAME` with nothing after it, tested with `#ifdef`/`#ifndef` anywhere in the
pack (OQ-13 working answer). BSL's `deferred` (ambient occlusion) and its TAA ran with options
off before.

Clean-room: implemented from cleanroom/shader-packs-spec.md revision 1, sections 3.3, 8.1;
no other loader's code read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 02:00:40 +01:00
Alexander Krylov c10261f008 Cleanup: one predicate property helper, a Windows-only allow, tolerant locks in the bots
predicate_property was the same fifteen lines in block_feature and
state_provider; it is worldgen::bound_property now. The mojang link helpers are
unix-only in use but worth testing everywhere, so they are allowed to look dead
elsewhere, which clears the launcher's -D warnings red on Windows. The bots
harness recovers from a poisoned lock instead of panicking.
2026-10-25 03:58:02 +03:00
Ludovic MarieandClaude Opus 5.5 1fa46979c8 Server clients: primed TNT falls and burns its fuse by itself between updates
PrimedTnt keeps Entity's NO_OP InterpolationHandler and ticks on a client
too: a position packet puts it there at once, its own tick moves it on
(gravity, collisions, bounces, drag) and burns the fuse down, and a client
discards it when the fuse runs out. Horizon interpolated it towards each
update instead, so it trailed about two ticks behind the server.

lan_e2e's falling check now drops primed TNT too and checks that the
guest has it exactly where its fall from rest puts it for the fuse the
server sent.

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 02:52:19 +02:00
rdbtCVSandopencode 6240124557 Slow Falling clears the fall distance so landing does not hurt
The effect capped downward gravity but left the fall distance to accumulate, so the landing tick still dealt fall damage. Clear it at the start of check_fall_damage, where both local travel and the server thread's replay of a guest's move pass.

Co-Authored-By: opencode (deepseek-v4.1-flash-free) <[email protected]>
2026-10-25 01:21:59 +01:00
rdbtCVSandopencode b0a5334cb5 Creative inventory: highlight hovered hotbar slots
The item-tab hotbar loop only set the tooltip, so the nine slots never
showed the white hover fill the 5x9 grid uses. Apply the same fill,
including over empty slots, before picking the tooltip item.

Fixes #1341

Co-Authored-By: opencode (deepseek-v4.1-flash-free) <[email protected]>
2026-10-25 01:17:28 +01:00
Simon Dräger 4d3f3b6391 Load fireballs from NBT as live projectiles 2026-10-24 17:02:57 -07:00
Alexander Krylov e30166f23b Options: Horizon's own captions read a bundled language namespace (#1672)
The settings screen's own options (controller, vibration, dead zones and the
rest) are not vanilla options and have no vanilla keys, so their captions
were English literals. They now read horizon.options.* from assets/horizon/lang,
which the client bundles as one more language source placed first, so a pack
or the prepared root can still override it. English and Russian ship here.
2026-10-25 02:51:11 +03:00
Ludovic MarieandClaude Opus 5.5 0f9de6dbb0 Shader packs, clean room 14: terrain drawn by the pack's program
With a pack whose gbuffers_terrain (or a fallback) loads, solid and
cutout terrain is drawn by it into the pack's buffers, in place, between
prepare and deferred, and Horizon's own solid terrain draw is skipped.

- depthtex0 is now a Depth32Float attachment: the blit writes the
  world's depth there (GL window depth, GL rows), terrain tests and
  writes against it, so entities and terrain occlude each other; later
  programs sample it as before.
- The vertex glue reads Horizon's TerrainVertex on both draw paths
  (instanced, and flat with the page tables in bind group 1) and gives
  atlas coordinates, lightmap coordinates in 0-240, the vertex colour,
  mc_midTexCoord at the sprite's centre and mc_Entity = -1. Normals,
  tangents and block ids come later.
- texture/gtexture/tex sample the block atlas, lightmap the lightmap;
  atlasSize is set.
- Occlusion culling is off while a pack draws terrain (it reads the
  depth of Horizon's terrain draw), and flat draws stay on.
- Uniforms a program reads that are not provided yet are reported.
- Program building is shared by quads and terrain (build_pass, bind).
- terrain-test: Horizon's own pack for this slice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:39:28 +02:00
Alexander Krylov f500be1b5e Title: the main menu labels follow the game language (#1672)
The screen built them from literals, so the buttons stayed English in every
language while the world select screen next to them was translated. Each has
a vanilla key the prepared resources already carry; Friends is Horizon's own
and reads horizon.menu.friends with the English text as fallback.
2026-10-25 02:28:27 +03:00
Ludovic MarieandClaude Opus 5.5 8698b4ad0c Shader packs, clean room 13: loading the terrain program
load_post takes the game's terrain vertex inputs and, given them, loads
the first program of gbuffers_terrain's fallback chain that translates
(gbuffers_terrain, textured_lit, textured, basic). Program loading is
shared by the post-processing and gbuffers programs.

The translator no longer rewrites a variable, parameter or member that
has the name of a sampler (`float specular` beside `uniform sampler2D
specular`), which kept Complementary's gbuffers programs from compiling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:20:16 +02:00
socksproxandDevin 372f918e44 Multiplayer: keep firework flight duration, boost gliding players
Two halves of FireworkRocketEntity on a server were missing:

- SetCreativeModeSlot encoded only damage, so a creative-picked rocket
  lost its minecraft:fireworks component (flight_duration and explosions
  gone); the wire codec also had no decoder for it or firework_explosion,
  so a stack carrying them failed to decode at all. The components now
  follow Fireworks.STREAM_CODEC/FireworkExplosion.STREAM_CODEC both ways
  (component ids 71/70).
- A remote rocket never read DATA_ATTACHED_TO_TARGET,
  DATA_ID_FIREWORKS_ITEM or DATA_SHOT_AT_ANGLE, so the server-attached
  boost rocket was an inert marker: attached_to now comes from
  EntityDataSerializers.OPTIONAL_UNSIGNED_INT (the id plus one), the item
  feeds the explosion list, and FireworkRocketEntity.tick's
  isFallFlying boost applies to the local player.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 01:18:53 +02:00
Ludovic MarieandClaude Opus 5.5 f31c18aa6c Shader packs, clean room 12: the block atlas packs sample
Packs sample blocks from one 2D atlas; Horizon keeps them in sprite
arrays. While a pack is loaded, every layer and its mip levels are
copied into a grid, and the animator reports the layers it rewrote so
only those are copied again. The sprite arrays gain COPY_SRC.

The gbuffers pass binds it in the next slice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:14:25 +02:00
Ludovic MarieandClaude Opus 5.5 a2a91f98f9 Shader packs, clean room 11: translating gbuffers programs
translate_gbuffers places a vertex-input glue the game writes (it knows
its own vertex formats) in front of a pack's gbuffers program, sets the
compatibility matrices from gbufferModelView/gbufferProjection and the
lightmap texture matrix, and gives declared attributes the game's values
(zero when it has none).

Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 01:14:15 +02:00
Ludovic MarieandClaude Opus 5.5 466b1de4c4 Shader packs: fewer passes and no needless loads in post-processing
- A buffer is cleared only when a program reads it before one writes all
  of it; the clears left ride in the blit pass as attachments the blit
  shader leaves alone, instead of passes of their own.
- The blit writes depthtex0 only when some program reads it.
- Attachments a pass overwrites whole (the blit's colortex0 and depth,
  every composite output, final's window) start cleared rather than
  loaded, which spares a tiled GPU reading them back first.
- The blit's bindings are kept until the world target changes size.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 00:46:07 +02:00
Ludovic MarieandClaude Opus 5.5 1776d7ca5c Shader packs, clean room 10: post-processing over Horizon's world
The first slice that draws: with HORIZON_SHADER_PACK=<name>, the pack in
shaderpacks/<name> (folder or zip) runs its prepare, deferred, composite
and final programs over the world, under the hand and the HUD. Gbuffers
programs come later, so the composites read Horizon's own picture.

- The world renders into its own target (as with a resolution scale); its
  colour becomes colortex0 and its reverse-Z depth becomes depthtex0 in
  GL's window depth (1 - d for the same planes), rows flipped to GL's
  order. depthtex1 and depthtex2 read the same depth for now.
- colortex buffers in the formats the pack asked for (the nearest one
  wgpu renders to), created only when a program uses them, each with two
  textures: programs read main and write alt, the buffers a program wrote
  flip after it, flip.* keys and deferred_pre/composite_pre apply, and
  the mapping carries over to the next frame. Clears at the start of the
  frame with the pack's colours or the defaults.
- noisetex (256x256 white noise, repeat), a lit stand-in behind shadow
  samplers until shadow maps exist, a 1x1 stand-in for other names; the
  frame uniforms of the previous commit and the translator's own block.
- final draws onto the window; without it colortex0 is shown.
- Pipelines are built through validation scopes: a program wgpu refuses
  is reported with the full error and skipped. The dimension's folder is
  reloaded when the player changes dimension.

Nothing changes without the environment variable: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:46:24 +02:00
Ludovic MarieandClaude Opus 5.5 303d497a67 Shader packs, clean room 9: frame uniforms, and gl_FragCoord in buffer passes
The values a pack's post-processing programs read once per frame, in
GL's conventions: camera position and the previous one, eye altitude,
up/sun/moon/shadow-light positions in view space (the sky's own frame for
the sun and moon), sun and shadow angles, world time, day and moon phase,
frame counter, frame time and run time, view size and aspect, near and
far, rain, smoothed wetness, thunder, fog colour, start and end, sky
colour, eye in fluid, blindness, night vision, darkness, brightness, GUI
hidden, eye brightness and its smoothed value, and the model-view and
projection matrices with their inverses and last frame's. The projection
is GL's perspective with the world's near and far planes times vanilla's
view bobbing. Each value is written as the type the pack declared.

The game passes what only it knows (day time, weather levels, the fluid
at the camera, light at the camera, blindness, F1, brightness) in the
renderer's Frame when HORIZON_SHADER_PACK is set.

Fix (slice 4): programs that draw to buffers read gl_FragCoord through a
variable only final set; every target sets it now, with a test. Found by
the first frame drawn with a pack.

Nothing reads the uniforms yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 23:26:42 +02:00
Ludovic MarieandClaude Opus 5.5 dbdc0389fe Launcher: Horizon modpacks from the registry, each its own profile
A registry modpack (.hpack) now shows under Modpacks with the Horizon
source, with its mods and what each may do, and installs into
horizon-packs/<id>/: the checked pack, pack.json and home/, where each of
its mods is downloaded and checked like any registry mod (pinned to the
pack's version and SHA-256) and turned on or off as the pack says. Its
overrides are copied on the first install only. A pack whose mod was taken
down installs nothing and leaves nothing behind. The Modpacks tab lists
installed registry packs with Play and Remove (confirmed); they play through
modpacks::apply_profile with their own HORIZON_HOME, taken-down mods turned
off first. CLI: `mods install --source horizon <pack>`, and `modpack
list|play|remove` see registry packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 f602ad6fe3 Launcher Mods page: Horizon's registry beside Modrinth
The source switch now picks Modrinth or Horizon (CurseForge stays coming
soon). On Horizon each result says whether its files are signed and what
the mod may do, in the game's words, before Install; no Java check, no
download count, no Modrinth filters. Installed Horizon mods get an
on/off switch (horizon-mods.txt); one the registry took down is turned
off when the page reads the index and can't be turned back on.
Registry icons load from its GitHub Pages. HORIZON_LAUNCHER_SOURCE=horizon
opens the page on it, for screenshots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 b49fd602df Launcher: install from Horizon's registry, enable and disable, takedowns
Mods install into horizon-mods/<id>.hmod (turned on) and resource packs into
resourcepacks/, each downloaded to mod-browser/downloads/ and checked by
horizon_mods::index::check_file (size, SHA-256, signature, manifest) before it
is moved into place. Files the index takes down are turned off when the index
is read or the game starts, and can't be installed or turned back on. CLI (and
so MCP): --source horizon, mods enable|disable, and search shows whether a
project is signed and what a mod may do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 7e513d2d5c Launcher: Horizon's registry as a mod browser source
A browser source for Horizon's own registry (docs/api/mod-index.md): it reads index.json
(HORIZON_MOD_INDEX for a test registry, cached in the game folder for when the registry can't
be reached), searches it by kind and words, lists versions with the SHA-256 the index gives,
and blocks the versions it says were taken down. Downloads accept the registry's release host
and check SHA-256; file:// files only from a local index.

Installing, the takedown check before play and the CLI come next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 6b2ec5f830 Shader packs, clean room 8: a dimension's post-processing programs
What the renderer needs to run a pack's post-processing, still without
wgpu: the programs of one dimension (prepare, deferred, composite and
final with their numbered variants, in the order they run), each
preprocessed and translated, with the buffers it writes and the flips
that follow it, and the buffer settings its stage files carry.

- A dimension's folder (world0, world-1, world1) replaces the base folder
  when it exists. program.<name>.enabled expressions over switch options,
  flip.<program>.<buffer> keys and the virtual deferred_pre and
  composite_pre. Programs with a geometry stage, or that fail to
  preprocess or translate, are reported and skipped.
- tests/packs/post-test: Horizon's own small test pack (tint, depth in
  colortex2, depth bands on half the screen, vignette).

Black box over the public test packs: every dimension of every pack
loads its post-processing programs (2 to 9) with no note.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:23:43 +02:00
Ludovic MarieandClaude Opus 5.5 d064dba945 Shader packs, clean room 7: buffer settings and output lists from stage files
The configuration packs write into their stage files, read from live code
only (the preprocessor has dropped false branches, so an option's choice
is the one in effect):

- the buffers a fragment shader writes: the last live DRAWBUFFERS or
  RENDERTARGETS comment, at most 8, buffers 0-7 without one;
- per colortex buffer (names and aliases): <buffer>Format in block
  comments, with every format name of the spec in any case, as channels,
  bits and kind; <buffer>Clear, ClearColor and MipmapEnabled.
  A later file changing a setting, an unknown format and a value of the
  wrong type are reported with their file and line. Buffer 1 becomes
  RGBA32F when a program declares gdepth and the pack set no format.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:23:43 +02:00
Simon Dräger 72f88befb8 Allow spaces in horizon-ctl screenshot paths 2026-10-24 13:21:23 -07:00
Simon Dräger d4b2e24535 Add mod command capability and Predator Missile example 2026-10-24 13:21:23 -07:00
Simon Dräger d83b3e18bc Keep mob pitch on /tp 2026-10-24 13:21:23 -07:00
Ludovic MarieandClaude Opus 5.5 fb4897ec09 Website: /mods/submit, the registry's submission form
Sign in with GitHub through the registry's service (no scope), then send
the file and its page; the service checks the file, stages it and opens the
pull request, and the page links to it or says what to fix. A file named
<id>-<version>.<ext> fills in its kind, id and version. Shader packs are
listed and disabled until Horizon's own format exists. Caddy sends
/api/mods/* to REGISTRY_UPSTREAM (unset: the form says it isn't open), and
"Submit yours" now leads here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:10:05 +02:00
Ludovic MarieandClaude Opus 5.5 b61b25f80f Shader packs, clean room 6: composite-style programs to naga
A program's preprocessed GLSL, any version from 110 to 460, compatibility
or core, rewritten into the Vulkan-flavoured GLSL 450 that naga's frontend
reads, then parsed and validated into naga modules with the program's
interface: its uniform block (offsets from naga's layout), its samplers
and their bindings, its output locations. The rewrite works on
preprocessing tokens and keeps the pack's lines, so every error points at
the pack's own file and line through the preprocessor's line map.

- Loose uniforms go into one block shared by both stages; bool is stored
  as int. Samplers are split into texture and sampler objects, combined
  again where used, and passed as two arguments to the pack's functions.
- The compatibility built-ins become declarations of ours (prefix hz_):
  gl_Vertex, gl_MultiTexCoordN, the matrices and ftransform(), gl_FragData,
  gl_FragColor, gl_TexCoord, gl_FrontColor, gl_Fog (from vanilla's fog),
  texture2D and the other old texture functions, shadow2D; varyings get
  locations by name across the stages; words that became keywords after
  GLSL 1.x are renamed in old code.
- naga gaps covered: modf, conditional initializers of globals (run by our
  main), and unsized arrays sized from their constructor.
- The pack's main is called by one of ours that draws the quad from (0,0)
  to (1,1) and adapts GL's conventions: clip y negated for every target but
  the window, so memory rows are GL's rows and gl_FragCoord needs no change;
  clip z from GL's [-w, w] to [0, w], so stored depth is GL's window depth.
  In final, gl_FragCoord.y is counted from the bottom of the window.

Black box over the public test packs: all 105 composite-style programs
(composite, deferred, final, prepare, begin, shadowcomp of the base and
dimension folders) translate and validate.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:35:37 +02:00
Ludovic MarieandClaude Opus 5.5 787c3a2f19 Website: /mods, Horizon's registry of mods, modpacks and resource packs
The list (search, a filter per kind) and a page per project: what a mod
may do in the game's own words, Signed or Unsigned, how to install it
from the launcher, the CLI or by hand, its file, SHA-256 and versions,
and links to its source, its registry entry and the report form. Baked
from the published index at build time (HORIZON_MOD_INDEX for a test
index) and refreshed by the page; Caddy serves /mods/<id> from its baked
page, else the list page, which shows a project published since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:20:34 +02:00
Ludovic MarieandClaude Opus 5.5 f239955997 Shader packs, clean room 5: the GLSL preprocessor and #include
A stage file goes through comment removal, #include, conditionals and
macro expansion, and every output line remembers the pack file and line it
came from, so later errors point at the pack's own code.

- #include "x" from the including file's folder, "/x" from the shaders
  folder, expanded in live code only (an include inside a false #if is not
  read), ten levels at most, paths that leave the pack refused; a file is
  read once per run however often it is included.
- #define, #undef, line continuations, __LINE__ and __VERSION__; #version
  and #extension kept apart for the translation's own header; the block
  comments of live code kept for the directives packs write in them
  (DRAWBUFFERS, buffer formats); #error and unknown directives diagnosed.

Black box over the public test packs: every stage file of the base and
dimension folders (126 to 219 per pack) preprocesses with no diagnostic.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:09:29 +02:00
Ludovic MarieandClaude Opus 5.5 4c65ed2f5c Shader packs, clean room 4: function-like macros and the standard macros
The macro engine the GLSL preprocessor needs, shared with the .properties
reader: function-like macros take their arguments (expanded first, raw
beside ##, which pastes) when a ( follows their name; a macro is still not
expanded inside its own expansion. Text is cut into preprocessing tokens,
so numbers such as 1e5 or 0x1F are never mistaken for names.

The #if/#elif/#else/#endif state moves out of the .properties reader so
the preprocessor uses the same code.

Standard macros: MC_GL_VERSION and MC_GLSL_VERSION 460, the 24
MC_RENDER_STAGE_* as ascending integers in draw order, render and shadow
quality 1.0, MC_HAND_DEPTH 0.125, MAX_COLOR_BUFFERS 16.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 21:09:07 +02:00
Ludovic MarieandClaude Opus 5.5 397d529db7 Shader packs, clean room 3: .properties files with conditional directives
shaders.properties and the id files (block, item, entity, dimension):
key=value lines in file order with their line numbers, # comments, a value
continued on the next line by a trailing backslash, and the conditional
directives evaluated with the standard and option macros. A # line is a
directive only when its first word is one, so a commented-out key such as
#ifdefault=1 stays a comment. #define and #undef apply to the lines after
them in every one of these files: a public pack defines a macro inside
#ifdef MC_OS_MAC in its shaders.properties and tests it further down. A
later duplicate key replaces the earlier one; broken conditionals, lines
that are not key=value and duplicates are kept as notes for the pack's log.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:42:33 +02:00
Ludovic MarieandClaude Opus 5.5 eb0a6f74e6 Shader packs, clean room 2: macros and #if conditions
The integer arithmetic of #if/#elif that a pack's .properties files and,
later, its GLSL preprocessor share: defined X and defined(X), decimal,
hexadecimal and octal literals, the C operators with C precedence, macros
expanded to their values (a macro is not expanded inside its own
expansion), and an identifier that is not a macro read as 0. A bad
condition says why. The standard macros that need no renderer:
MC_VERSION, from the pinned version by the documented rule (26.3 is
260300), and the system's MC_OS_*.

Nothing uses it yet: players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:42:05 +02:00
Ludovic MarieandClaude Opus 5.5 3d4b63c19e wasmtime 48 LTS: one wasm-tools version for the runtime, packing and the SDK
wasmtime 36 read WebAssembly with wasm-tools 0.236, wit-component (cargo
horizon-mod, the sandbox tests) used 0.239 and the SDK's wit-bindgen 0.247:
three copies of wasmparser, wasm-encoder, wit-parser and wasm-metadata.
wasmtime 48, the next long-term-support line (fixes until August 2028),
wit-component 0.254 and wit-bindgen 0.60 all use 0.254. No code change;
horizon-mods now needs Rust 1.95.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:25:10 +02:00
804 changed files with 70333 additions and 22652 deletions

No files matched your search

-1
View File
@@ -2,7 +2,6 @@
target/
.local/
**/node_modules/
website/
benchmarks/
packs/
mods/
+1 -1
View File
@@ -2,5 +2,5 @@
# where Git for Windows' core.autocrlf would check them out with CRLF.
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+68 -2
View File
@@ -3,7 +3,8 @@ name: checks
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. A fork's pull
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
# never start this workflow. See docs/release-flow.md.
on:
@@ -72,8 +73,73 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
parity:
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request change the launcher or a crate it builds?
if: github.event_name == 'pull_request'
id: launcher
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py --launcher "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust and the headers GPUI links against
if: steps.launcher.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libxkbcommon-x11-dev libwayland-dev \
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
if: steps.launcher.outputs.required != 'false'
with:
workspaces: launcher
shared-key: launcher
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check the launcher's formatting, lint and tests
if: steps.launcher.outputs.required != 'false'
working-directory: launcher
run: |
cargo +stable fmt --check
cargo +stable clippy --all-targets --locked -- -D warnings
cargo +stable test --locked --lib
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
parity-scope:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
persist-credentials: false
- id: scope
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }}
run: |
changed=$(git diff --name-only "$(git merge-base "$BASE_SHA" HEAD)" HEAD)
harness=$(grep -E '^(tools/test_[a-z0-9_]+_parity\.py|tools/(parity[a-z_]*|lab)\.py|tools/parity_hosts\.json|mods/horizon/src/test/|crates/[^/]+/examples/|crates/horizon-client/src/game/[a-z_]*(fixture|harness)\.rs|\.github/workflows/parity[a-z-]*\.yml)' <<< "$changed" || true)
if [ -n "$harness" ] || [[ " $LABELS " == *" run-parity "* ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
echo "The pull request runs its harnesses: ${harness:-label run-parity}"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "No harness, oracle, probe or runner change: parity-main checks this on main"
fi
parity:
needs: parity-scope
if: needs.parity-scope.outputs.run == 'true'
uses: ./.github/workflows/parity.yml
with:
base: ${{ github.event.pull_request.base.sha }}
+15 -8
View File
@@ -8,7 +8,7 @@ name: nightly audit
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
on:
schedule:
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:07 in Paris today.
- cron: '7 1 * * *'
- cron: '7 2 * * *'
workflow_dispatch:
@@ -29,15 +29,21 @@ env:
jobs:
audit:
runs-on: [self-hosted, horizon-game]
timeout-minutes: 150
# The Mac mini's runners only: the audit compares with the previous one, so it stays on one platform.
runs-on: [self-hosted, horizon-game, macOS]
timeout-minutes: 240 # every harness (~80 min) plus lan_integration (~40 min an attempt under load, retried)
steps:
- name: 03:00 in Paris
- name: 03:07 in Paris
id: when
env:
SCHEDULE: ${{ github.event.schedule }}
run: |
hour=$(TZ=Europe/Paris date +%H)
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
# Which cron is 03:07 in Paris, from Paris's offset today: GitHub can start a schedule hours late, so the
# clock at start time can't tell (on 2026-10-26 both started after 04:00 Paris and both skipped).
offset=$(TZ=Europe/Paris date +%z)
ours=$([ "$offset" = +0200 ] && echo '7 1 * * *' || echo '7 2 * * *')
if [[ "${{ github.event_name }}" == schedule && "$SCHEDULE" != "$ours" ]]; then
echo "Paris is at UTC${offset} today: the '$ours' schedule runs the audit."
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
@@ -65,7 +71,8 @@ jobs:
run: |
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
set +e
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
# lan_integration (the whole lan_e2e suite) takes ~2,400 s under load, past the usual 1,800.
python3 tools/parity.py --all --timeout 3000 2>&1 | tee "$RUNNER_TEMP/audit.log"
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Issues for the harnesses that regressed
+77
View File
@@ -0,0 +1,77 @@
name: parity-main
# The vanilla harnesses on main, a batch of merges at a time (docs/release-flow.md#parity-on-main), on the Mac mini's
# runner. One run at a time: merges that land while a run goes on wait in one pending run (GitHub keeps only the
# newest), which checks every merge since main's last green parity-main (tools/parity_main.py base). On a failure it
# bisects the failing harnesses over those merges and comments on the pull request that broke them. Releases ship
# only a commit whose parity-main passed (tools/release_train.py), so nothing a harness catches reaches players.
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
# The comment on the merge that broke a harness.
pull-requests: write
# tools/parity_main.py base reads earlier runs.
actions: read
concurrency:
group: parity-main
cancel-in-progress: false
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
parity-main:
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
runs-on: [self-hosted, macOS, horizon-parity-main]
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- uses: dtolnay/rust-toolchain@stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
run: bash tools/ci/vanilla-data.sh
- name: Main's last green parity-main
id: base
env:
GH_TOKEN: ${{ github.token }}
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
- name: The harnesses the merges since then can affect
id: harnesses
# A failure here is judged by the next step: the run passes when every failed harness passes again at HEAD.
continue-on-error: true
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.base.outputs.sha }}
run: |
echo "Merges since ${BASE_SHA}:"
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
- name: A flake, or the merge that broke them
if: steps.harnesses.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.base.outputs.sha }}
run: |
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
- name: Logs of the failed harnesses
if: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: parity-main-logs
path: .local/parity-main-logs/
if-no-files-found: ignore
retention-days: 7
+64 -2
View File
@@ -36,14 +36,76 @@ jobs:
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
run: bash tools/ci/vanilla-data.sh
env:
BASE_SHA: ${{ inputs.base }}
run: |
# main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on
# the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own.
set -euo pipefail
if git rev-parse --verify -q origin/main >/dev/null \
&& git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then
git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh"
bash "$RUNNER_TEMP/vanilla-data.sh"
else
bash tools/ci/vanilla-data.sh
fi
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
env:
BASE_SHA: ${{ inputs.base }}
run: |
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
# the tool nor the number, so there is nothing to check.
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
exit 0
fi
python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
- name: The version constants are the pinned jar's
run: python3 tools/gen_version_consts.py --check
- name: The harnesses this change can affect
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ inputs.base }}
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
run: |
# main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a
# branch made before a better selection landed gets it too; the head's own runner then runs those
# harnesses. A branch that changes the selection itself selects with its own code.
set -euo pipefail
own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner
fork_point=$(git merge-base "$BASE_SHA" HEAD)
if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then
own
exit
fi
had_reach=$(git ls-files tools/parity_reach.py)
git show origin/main:tools/parity.py > tools/parity.py
git show origin/main:tools/parity_reach.py > tools/parity_reach.py
status=0
python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$?
git checkout -- tools/parity.py
if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi
cat .local/parity-selection.txt
if [ "$status" != 0 ]; then
echo "main's selection failed: the head's own selection runs"
own
exit
fi
names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true)
# The summary line says how many would run: a count that doesn't match the names read (a format change)
# must not let the job pass untested.
expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p')
count=$(printf '%s' "$names" | grep -c . || true)
if [ -z "$expected" ] || [ "$expected" != "$count" ]; then
echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs"
own
exit
fi
if [ "$count" = 0 ]; then
echo "Nothing to run."
exit 0
fi
# shellcheck disable=SC2086
python3 tools/parity.py $names --timeout 2400
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
+52 -5
View File
@@ -1,6 +1,7 @@
name: release
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides,
# for a commit whose checks and parity-main (the vanilla harnesses on main) both passed:
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
@@ -18,7 +19,8 @@ name: release
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
on:
workflow_run:
workflows: [checks]
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
workflows: [checks, parity-main]
types: [completed]
branches: [main]
schedule:
@@ -64,8 +66,8 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
# The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
# main's newest commit with green checks.
# The commit whose checks or parity-main just passed; a dry run builds the branch it was started on;
# otherwise main's newest commit with green checks and parity-main.
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
FORCE: ${{ inputs.force && 'yes' || '' }}
run: |
@@ -77,9 +79,50 @@ jobs:
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
build:
exporters:
needs: decide
if: needs.decide.outputs.release == 'true'
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
- run: bash tools/ci/vanilla-data.sh
- name: Compile and validate original exporters against the pinned jars
shell: bash
run: |
set -euo pipefail
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
# prepares afresh.
cargo build --release --locked -p horizon-prepare
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
# write those (vanilla-data.sh links .local/exporters to them).
rm -rf .local/exporters
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
mkdir -p exporters
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
# Only our classes and their manifest leave this runner.
stray=$(find exporters -type f ! -path exporters/manifest.json \
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
- uses: actions/upload-artifact@v4
with:
name: original-exporters
path: exporters/
if-no-files-found: error
retention-days: 3
build:
needs: [decide, exporters]
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
strategy:
fail-fast: false
@@ -218,6 +261,10 @@ jobs:
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- uses: actions/download-artifact@v4
with:
name: original-exporters
path: exporters
- name: Package
run: |
set -euo pipefail
+3
View File
@@ -31,3 +31,6 @@
/.horizon-cache/
/crash-reports/
/debug/
# Release-only compiled original exporter bundle.
/exporters/
+8 -6
View File
@@ -45,6 +45,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
@@ -53,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
@@ -65,7 +67,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
@@ -81,7 +83,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
@@ -155,7 +157,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
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`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
@@ -163,8 +165,8 @@ when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
+7 -2
View File
@@ -4,8 +4,13 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
## Build it
Generated
+427 -310
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
resolver = "2"
[workspace.package]
+6 -1
View File
@@ -16,6 +16,7 @@
<a href="#download"><b>Download</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
</p>
@@ -30,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
@@ -109,6 +110,10 @@ Everything prepared lands in the gitignored `.local/`. It is derived from the ga
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
## Roadmap
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
## Contributing
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
+980
View File
@@ -0,0 +1,980 @@
# Horizon roadmap
<!-- Generated by tools/roadmap.py from roadmap/roadmap.toml: edit that file, then run `python3 tools/roadmap.py build`. -->
What is being done on Horizon, what comes next and where you can help. There are no dates: things are Now, Next or Later. Each item is said in plain words, with the technical picture folded under it. The same roadmap, with a Simple / Technical switch, the board, the journal and how to help, is on [playhorizon.dev/hub](https://playhorizon.dev/hub). Pull requests carry the `roadmap:<id>` label of their item.
## At a glance
Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it runs faster on the same computer. Single player, LAN, servers, resource packs and Horizon's own mods work today, on your own copy of the game.
**Being done now**
- **Steady frames at long render distances**: Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
- **Modpacks' world generation, natively**: Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
- **Playing on servers: everything shows as it should**: When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command.
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
**Developers**: Read CONTRIBUTING.md, pick a Help wanted item or a To do card, check vanilla's code for the behaviour, and paste the block `python3 tools/pr_ready.py` prints in your pull request.
## Now
### Performance
#### Steady frames at long render distances
Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
<details><summary>Technical</summary>
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
</details>
#### Faster block lookups
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
<details><summary>Technical</summary>
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
</details>
### Modpacks
#### Modpacks' world generation, natively
Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
<details><summary>Technical</summary>
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
</details>
### Servers and multiplayer
#### Playing on servers: everything shows as it should
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
<details><summary>Technical</summary>
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
</details>
#### Server dialogs and login screens
Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon.
<details><summary>Technical</summary>
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
</details>
#### Pre-generate an area with /pregen
A /pregen command that builds an area of the world ahead of time, so players don't wait for new terrain when they explore.
<details><summary>Technical</summary>
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
</details>
### Mods and API
#### Horizon's mod registry
One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
<details><summary>Technical</summary>
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
More: [docs/mods.md](docs/mods.md)
</details>
#### Mods that run on the server
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
<details><summary>Technical</summary>
API 0.3, experimental first ([#1833](https://github.com/ludovic111/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
</details>
#### Screens, input and drawing for mods
Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
<details><summary>Technical</summary>
Capability `ui` is in review ([#1864](https://github.com/ludovic111/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
More: [docs/mods.md](docs/mods.md)
</details>
#### Tools for people who make mods
Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
<details><summary>Technical</summary>
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
</details>
### Gameplay parity
#### Single player as a server: the last differences
Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
<details><summary>Technical</summary>
Since [#1514](https://github.com/ludovic111/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: a tool that changes a block ends the click ([#1797](https://github.com/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
</details>
#### Play in your language
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
</details>
#### Cooldowns drawn on items
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
</details>
#### Chat settings and team prefixes
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
</details>
#### A pointing hand over buttons
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
</details>
#### Ctrl+Backspace and scroll bars in menus
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
</details>
#### Chickens flap while they fall
Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
</details>
### Rendering
#### Shader packs, written again from scratch
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
<details><summary>Technical</summary>
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
</details>
#### Horizon's own shader format and kit
Horizon's own way to make shader packs, with tools to write and test them. Every pack is checked before it runs, and the registry will list them.
<details><summary>Technical</summary>
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
</details>
### Horizon extras
#### Discord Rich Presence
Your Discord profile can show that you are playing Horizon, with a switch to turn it off.
<details><summary>Technical</summary>
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
</details>
### Code and tools
#### A leaner codebase that builds faster
Cleaning up Horizon's code: removing what nothing uses and making it quicker to build, so fixes and new features land faster.
<details><summary>Technical</summary>
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
</details>
## Next
### Performance
#### Fresh benchmarks against vanilla
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, with a history that shows what each release changed.
<details><summary>Technical</summary>
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
</details>
#### Faster world creation and entry on small CPUs
Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 chunks can make you wait minutes for the first terrain.
<details><summary>Technical</summary>
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
</details>
#### Servers that hold 20 TPS with 50 to 100 players
Servers that keep 20 ticks per second with 50 to 100 players. Today Horizon holds it up to 50 bots and drops to about 10 at 100.
<details><summary>Technical</summary>
Measured against vanilla on equal work ([docs/server.md](docs/server.md), Scale): Horizon is ahead with few players and when exploring, but vanilla's tick is shorter with 25 to 50 players near spawn. Ideas on the list: send entity changes instead of rebuilding every entity each tick (about 15 percent), fewer memory reads in random ticks and collision scans, cheaper item collision checks, mob AI and natural spawning. The bot benchmark (`horizon-bots`) is the proof.
</details>
#### Low-end computers: Steam Deck, old Macs, 8 GB
Horizon should run well on small machines: integrated graphics, the Steam Deck, older Macs and 8 GB of memory, with a battery mode that saves power on laptops.
<details><summary>Technical</summary>
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
#### Native upscaling
Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain frames on weaker graphics cards. An optional graphics setting.
<details><summary>Technical</summary>
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/ludovic111/horizon/issues/1403).
</details>
#### A batch of targeted optimisations
Several smaller speed-ups, each measured: idle block entities that sleep, cheaper particles, faster structure placement, high-rate mice and very large texture packs.
<details><summary>Technical</summary>
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
More: [docs/profiling.md](docs/profiling.md)
</details>
### Modpacks
#### CurseForge in the mod browser (coming soon)
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
<details><summary>Technical</summary>
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### A Convert to Rust button for what isn't rewritten yet
When a mod, shader pack or modpack hasn't been rewritten for Horizon, one button converts what can be converted, so you don't have to wait for the rewrite.
<details><summary>Technical</summary>
Generalise the mod browser's Port step (`horizon-port-pack`: the data, assets, worldgen and block and item definitions of Java mods, .mrpack and CurseForge zips) to every kind of content: code mods where the API allows it (see 'Mods with code') and shader packs, converting an Iris/OptiFine pack into Horizon's format. The browser says what works before the install. Java code itself does not run ([docs/modpacks.md](docs/modpacks.md), [docs/mod-browser.md](docs/mod-browser.md)).
</details>
#### Mods with code: machines, magic, mobs
Making mods that add machines, magic and mobs work in Horizon, in steps, starting with machines. They are most of what a big modpack is made of.
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### Data packs in the mod browser
Search and install data packs from the launcher's mod browser, for the world you choose, as you do mods and resource packs.
<details><summary>Technical</summary>
A Datapacks tab beside Mods and Resource packs: install into a chosen world's datapacks folder, with the same checks and 'works in Horizon' notes as the other kinds ([docs/mod-browser.md](docs/mod-browser.md)).
</details>
### Servers and multiplayer
#### Prepare a version without Java
Set up Horizon, a server first, without Java on your computer: published tables are combined with your own copy of the game.
<details><summary>Technical</summary>
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
</details>
#### Server console, bans and a full admin API
Manage a server from a console, ban and pardon players, and drive it from other tools through a full admin API and an MCP server for AI agents.
<details><summary>Technical</summary>
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
#### Hosting polish: Windows service, safe updates
Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each player's state, and a changed view distance or message without a restart.
<details><summary>Technical</summary>
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
</details>
#### Guided hosting with Tailscale and an invite button
In the launcher, a guide and a button to host a server and invite friends over Tailscale, so nobody has to open ports on a router.
<details><summary>Technical</summary>
On the Servers page (create, start, back up, update and invite are shipped) add a Tailscale setup that explains and checks each step, and an invite button that hands friends what they need to join over the tailnet. Buttons first, no docs needed, with the details in an Advanced section. Remote access over Tailscale is already described in [docs/server.md](docs/server.md).
More: [docs/hosting.md](docs/hosting.md)
</details>
#### An official Docker image for servers
An official image to run a Horizon dedicated server in Docker with one command, your own copy of the game files mounted in.
<details><summary>Technical</summary>
The repository has the Dockerfile (`packaging/server/Dockerfile`: a multi-stage build on Debian bookworm-slim, user `horizon`, volume `/data`, port 25565, game files mounted read-only, `HORIZON_EULA` and friends answering the first-start questions), and anyone can build it ([docs/hosting.md](docs/hosting.md), section 9). Left: publish the image to a public registry from the release workflow, tagged per release, so it follows the same checks, and update the docs and the launcher. The image never holds Mojang's files.
More: [docs/server.md](docs/server.md)
</details>
#### A server you install with one command
Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md).
</details>
### Mods and API
#### Mods that read and change the world
Mods that can see and change blocks, read inventories and items, and find and affect entities, all through the server so every player sees the same thing.
<details><summary>Technical</summary>
After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
More: [docs/modpacks.md](docs/modpacks.md)
</details>
#### Machines and blocks with inventories (API 0.4)
Mods that add machines: blocks that tick, hold items and have their own menus. This unlocks the technology mods of big modpacks.
<details><summary>Technical</summary>
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/ludovic111/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
</details>
#### Lua and other languages for mods
Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
<details><summary>Technical</summary>
Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
</details>
### Gameplay parity
#### Pistons push players and mobs
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
<details><summary>Technical</summary>
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
</details>
#### One single-player path: remove the in-frame world
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
<details><summary>Technical</summary>
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
</details>
#### Villages and combat, exactly as in vanilla
Villages and fights behaving as in vanilla: iron golems defending villages, villagers remembering their homes, mace smashes and knockback.
<details><summary>Technical</summary>
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/ludovic111/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
</details>
#### Choose the audio output device
Pick the speakers or headphones Horizon plays through, in Music & Sounds.
<details><summary>Technical</summary>
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/ludovic111/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
</details>
### Rendering
#### Well-known shader looks, rewritten for Horizon
Popular shader packs rewritten in Horizon's format, so good packs exist from the start. Only packs whose licence is open source and compatible with ours are rewritten, never anything else.
<details><summary>Technical</summary>
The first candidates are Photon, Chocapic13 and MakeUp Ultra Fast, each only after its licence is read and recorded: a pack is rewritten only if it is open source and compatible with Horizon's licence, and the rewrite is written from the shader format's documentation and the look on screen, not from anyone's code. Today only the Golden Hour example exists (`[crates/horizon-shader-api/packs/golden_hour](crates/horizon-shader-api/packs/golden_hour)`).
More: [docs/shaders.md](docs/shaders.md)
</details>
### Launcher
#### Microsoft sign-in (coming soon)
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
<details><summary>Technical</summary>
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
</details>
#### A visible beta label
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
<details><summary>Technical</summary>
Show Beta in the launcher's header and about page, on the game's title screen and on the website, beside the version number ([docs/versioning.md](docs/versioning.md)).
</details>
### Platforms
#### Horizon on Intel Macs
Builds of the game and the launcher for Macs with an Intel processor.
<details><summary>Technical</summary>
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
</details>
#### Wayland and Hyprland polish
Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboard, the GUI scale after a restart and the like.
<details><summary>Technical</summary>
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
</details>
### AI and agents
#### A versioned agent-control protocol
A stable, documented way for AI agents and scripts to drive the game and the launcher, with versions and an event stream so tools keep working as Horizon changes.
<details><summary>Technical</summary>
`hello` answers with the API version, the release and the features; `horizon-ctl --api`; MCP serverInfo; reply schemas generated into the reference; a `test.` namespace for hooks; transcript compatibility tests; `subscribe` as a stream of events on one connection; Horizon option files by key and saved-server commands. Agent control itself (`horizon-ctl`, both MCP servers, the No AI switch) is done: [docs/agent-control.md](docs/agent-control.md), [docs/api/agent-control.md](docs/api/agent-control.md).
More: [docs/api/stability.md](docs/api/stability.md)
</details>
#### Port mods with your own AI agent
You can port a mod to Horizon with your own AI agent on your own computer. Horizon documents how and gives you the tools, but provides no hosted agents.
<details><summary>Technical</summary>
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
</details>
### Community
#### Horizon on X (coming soon)
Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
<details><summary>Technical</summary>
Kira already posts the roadmap items marked `announce = true` when they ship, and the weekly summary, as soon as the account's credentials exist ([roadmap/README.md](roadmap/README.md), 'The journal'). Waiting for the account itself.
</details>
### Code and tools
#### Pipeline protection and a GitHub App for agents
Safer releases: changes to the release machinery need the owner's review, and AI agents use a smaller identity of their own instead of a personal account.
<details><summary>Technical</summary>
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/ludovic111/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
</details>
#### Signed updates everywhere, installers included
Every download Horizon makes is checked against the project's signature, including the first install with install.sh and install.ps1.
<details><summary>Technical</summary>
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
</details>
## Later
### Performance
#### Exact far terrain from real chunks
Far terrain drawn from the world you really built and explored, caves and builds included, not only a preview from the seed.
<details><summary>Technical</summary>
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
</details>
### Modpacks
#### Older Fabric and Forge mods
Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over like newer ones.
<details><summary>Technical</summary>
A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
</details>
#### A modpack's own resource and shader packs
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
<details><summary>Technical</summary>
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
</details>
#### Whole modpacks, such as ATM10, rewritten
Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: the work above comes first.
<details><summary>Technical</summary>
The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of its 496 mods are carried at least in part ([docs/modpacks.md](docs/modpacks.md)). It depends on 'Mods with code' and the native mod API.
</details>
#### Running Java mods that aren't rewritten
Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not started: the Convert button and native rewrites come first.
<details><summary>Technical</summary>
Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
</details>
### Servers and multiplayer
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
<details><summary>Technical</summary>
Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
</details>
#### Online-mode checks for Play with Friends
Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
<details><summary>Technical</summary>
Play with Friends joins over an encrypted tunnel with an invite code ([docs/play-with-friends.md](docs/play-with-friends.md), 'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between players, with no relay run by the project.
</details>
### Mods and API
#### Orbital Laser on the normal game
The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
<details><summary>Technical</summary>
A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
</details>
#### Native versions of popular client mods
Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
<details><summary>Technical</summary>
Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
More: [docs/mods.md](docs/mods.md)
</details>
#### Create-style physics as a native mod
Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the base game.
<details><summary>Technical</summary>
Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
</details>
#### Proximity voice chat as a native mod
Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod.
<details><summary>Technical</summary>
Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
</details>
### Gameplay parity
#### Every Minecraft version, prepared automatically
Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, Horizon follows it, and every system is checked against it again.
<details><summary>Technical</summary>
The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
</details>
#### Controllers: tested on real pads, and in the launcher
Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in the launcher too.
<details><summary>Technical</summary>
The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
</details>
#### The full debug screen
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
<details><summary>Technical</summary>
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
</details>
### Rendering
#### A light default shader pack
A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players who want a nicer picture without a heavy pack.
<details><summary>Technical</summary>
An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it.
More: [docs/shaders.md](docs/shaders.md)
</details>
#### Optional ray tracing
An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water reflections and global lighting on top of the vanilla look.
<details><summary>Technical</summary>
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
</details>
#### HDR monitors
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
<details><summary>Technical</summary>
Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
#### MSAA, anisotropic filtering, exclusive fullscreen
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
<details><summary>Technical</summary>
Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
</details>
### Launcher
#### Lean installs that use less disk
Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the preparation needed.
<details><summary>Technical</summary>
A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current and the previous version kept, and an automatic clean-up. Not started.
</details>
### Platforms
#### Horizon on Linux ARM64
Game and launcher builds for 64-bit ARM Linux computers.
<details><summary>Technical</summary>
Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
</details>
#### Android, iOS and the browser
Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
<details><summary>Technical</summary>
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
</details>
#### AppImage and a Nix flake
Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
</details>
### Horizon extras
#### A complete video studio inside the game
Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and finish the video. One of Horizon's original goals.
<details><summary>Technical</summary>
Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
</details>
#### Game menus in the launcher's style
An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: the vanilla menus stay as they are.
<details><summary>Technical</summary>
A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, pause and options screens with the launcher's design tokens (crates/horizon-theme), following [docs/design-system.md](docs/design-system.md); off by default because it changes the vanilla look.
</details>
### AI and agents
#### An optional AI teammate
An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, runs on your computer first, and exists only if you turn it on. The No AI switch hides it.
<details><summary>Technical</summary>
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
</details>
### Community
#### A video series about Horizon
A series of videos about how Horizon is made and what is new, with each upload announced on Discord.
<details><summary>Technical</summary>
Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the project: a script, edits, titles and a thumbnail per video.
</details>
## Help wanted
### Performance
#### Benchmark your computer
Run Horizon's built-in benchmark and share the report: every new computer makes the performance work more accurate. No coding needed.
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
### Gameplay parity
#### End city banners keep their pattern
Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
</details>
#### Enchanted loot in End cities
Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
</details>
## Recently shipped
- **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/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/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/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/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/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
+2 -1
View File
@@ -17,4 +17,5 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
`crates/horizon-theme/icons/pixel/*.svg` are icons from
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
+87 -27
View File
@@ -1,10 +1,11 @@
//! `cargo horizon-mod check <file>`: what the registry checks of a submission,
//! run anywhere: its kind's own checks (docs/api/mod-index.md), its signature
//! when given a key, and for a mod a trial run in the sandbox: it must start,
//! survive ticks, frames, a chat line and each of its commands, and import
//! no more than its capabilities.
//! survive ticks, frames, a chat line and each of its commands (a server mod:
//! a player joining, chatting, typing them and leaving), and import no more
//! than its capabilities.
use horizon_mods::{
Clock, Mods, Output, Package, Player, Snapshot, Status,
Clock, GameMode, Mods, Output, Package, Player, ServerPlayer, Snapshot, Status,
index::{self, Facts, Kind},
manifest::component_imports,
};
@@ -21,6 +22,8 @@ pub struct Report {
pub sha256: String,
pub size: u64,
pub signed: bool,
/// A mod has code to run (a content mod may have none).
pub code: bool,
/// A mod's component imports.
pub imports: Vec<String>,
/// A mod's commands, each run once.
@@ -44,11 +47,13 @@ impl Report {
"api": self.facts.api,
"side": self.facts.side,
"capabilities": self.facts.capabilities,
"api_level": self.facts.api_level,
"game": self.facts.game,
"requires": requires,
"sha256": self.sha256,
"size": self.size,
"signed": self.signed,
"code": self.code,
"imports": self.imports,
"commands": self.commands,
"output": self.output,
@@ -66,6 +71,8 @@ impl Report {
if let Some(side) = f.side {
let side = serde_json::to_value(side).unwrap_or_default();
out += &format!(", API {}, {}", f.api, side.as_str().unwrap_or("?"));
} else if self.kind == Kind::Shader {
out += &format!(", shader API {}", f.api);
} else if !f.api.is_empty() {
out += &format!(", format {}", f.api);
}
@@ -81,13 +88,18 @@ impl Report {
")\n {} bytes, SHA-256 {}, {signed}\n",
self.size, self.sha256
);
if let Some(level) = f.api_level {
out += &format!(" needs mod API level {level} or newer\n");
}
for c in &f.capabilities {
out += &format!(" may {}\n", c.describe());
}
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
if self.kind == Kind::Mod {
if self.kind == Kind::Mod && !self.code {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
let commands = if self.commands.is_empty() {
String::new()
} else {
@@ -108,6 +120,7 @@ pub fn kind_of(path: &Path) -> Option<Kind> {
"hmod" => Some(Kind::Mod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"hshader" => Some(Kind::Shader),
_ => None,
}
}
@@ -142,6 +155,7 @@ pub fn check(
sha256: index::sha256_file(path)?,
size,
signed,
code: false,
imports: Vec::new(),
commands: Vec::new(),
output: Vec::new(),
@@ -153,10 +167,14 @@ pub fn check(
}
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
/// storage lands in a folder removed afterwards.
/// storage lands in a folder removed afterwards. A content mod has no code to
/// run: `inspect` already read its content as the game does.
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
let package = Package::read(path)?;
let wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
let Some(wasm) = package.wasm.as_deref() else {
return Ok(());
};
report.code = true;
report.imports = component_imports(wasm)?;
package
.manifest
@@ -181,42 +199,84 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
if let Some(reason) = failed(&mods) {
return Err(format!("it didn't start: {reason}"));
}
report.commands = mods.commands().map(str::to_owned).collect();
let client: Vec<String> = mods.commands().map(str::to_owned).collect();
let server: Vec<String> = mods.server_commands().map(str::to_owned).collect();
report.commands = [client.clone(), server.clone()].concat();
// A server mod plays with one player on, who joins, chats and leaves.
let steve = ServerPlayer {
id: "00000000-0000-4000-8000-000000000001".into(),
name: "Steve".into(),
x: 0.5,
y: 64.0,
z: 0.5,
dimension: "minecraft:overworld".into(),
game_mode: GameMode::Survival,
health: 20.0,
};
let snapshot = |t: u64| Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
players: vec![steve.clone()],
// The GUI of a 1280 x 720 window at scale 3.
gui_size: (427.0, 240.0),
..Snapshot::default()
};
mods.set_snapshot(snapshot(0));
mods.player_joined(&steve);
for t in 0..TICKS {
mods.set_snapshot(Snapshot {
player: Some(Player {
x: 0.5 + t as f64 * 0.1,
y: 64.0,
z: 0.5,
yaw: 90.0,
pitch: 0.0,
health: 20.0,
food: 20,
dimension: "minecraft:overworld".into(),
}),
clock: Some(Clock {
game_time: t,
day_time: 1000 + t,
}),
});
mods.set_snapshot(snapshot(t));
mods.tick(t);
mods.server_tick(t);
for _ in 0..FRAMES_PER_TICK {
mods.frame(1.0 / 60.0);
}
}
mods.chat("<Steve> hello");
for name in report.commands.clone() {
mods.command(&name);
mods.player_chat(&steve, "hello");
for name in &client {
mods.command(name);
}
for name in &server {
mods.server_command(&steve, name);
}
mods.set_snapshot(Snapshot::default());
mods.player_left(&steve);
for out in mods.drain() {
report.output.push(match out {
Output::Chat { text, .. } => format!("chat: {text}"),
Output::Feedback { text, error } => {
Output::Tell { text, .. } => format!("tell: {text}"),
Output::Broadcast { text, .. } => format!("broadcast: {text}"),
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
Output::Command { line } => format!("command: /{line}"),
Output::QuietCommands { lines, .. } => {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
Output::Shake { strength, seconds } => {
format!("shook the camera: {strength:.2} for {seconds:.1} s")
}
});
}
let shapes = mods.hud_shapes().count();
if shapes > 0 {
report.output.push(format!("drew {shapes} HUD shape(s)"));
}
match failed(&mods) {
Some(reason) => Err(format!("it stopped: {reason}")),
None => Ok(()),
@@ -262,7 +322,7 @@ mod tests {
.unwrap();
assert!(e.contains("signature"), "{e}");
let e = check(&pack, Kind::Shader, None, None).err().unwrap();
assert!(e.contains("shader packs open"), "{e}");
assert!(e.contains("horizon-shaders.toml"), "{e}");
assert!(check(&pack, Kind::Mod, None, None).is_err());
}
}
+214 -21
View File
@@ -1,15 +1,20 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod; run `build` again and the game reloads it.
//! - `test [--debug] [--hmod <file>] [<name>]`: build, then run the mod's
//! `tests/*.toml` scripts in the sandbox, with no game.
//! - `check <file> [--json]`: what the registry checks of a submission
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
//! - `registry build|review|check|list`: the registry repository's CI.
use horizon_mods::{
Manifest, Package,
Capability, Content, Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
testing::{Script, TestGame},
};
mod check;
mod registry;
@@ -34,9 +39,10 @@ fn main() -> ExitCode {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | 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> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -90,19 +96,72 @@ fn new(args: &[String]) -> Result<(), String> {
.replace("{name}", &name)
.replace("{sdk}", &sdk)
};
let files = [
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
(MANIFEST, include_str!("../template/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/src/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
(
"tests/hello.toml",
include_str!("../template/tests/hello.toml"),
),
];
for (file, text) in files {
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let content = [
(
MANIFEST,
include_str!("../template/content/horizon-mod.toml"),
),
(
"data/{id}/horizon/block/glowing_amethyst.json",
include_str!("../template/content/block.json"),
),
(
"assets/{id}/blockstates/glowing_amethyst.json",
include_str!("../template/content/blockstate.json"),
),
(
"assets/{id}/models/block/glowing_amethyst.json",
include_str!("../template/content/model.json"),
),
(
"assets/{id}/items/glowing_amethyst.json",
include_str!("../template/content/item.json"),
),
(
"assets/{id}/lang/en_us.json",
include_str!("../template/content/lang.json"),
),
(
"data/{id}/loot_table/blocks/glowing_amethyst.json",
include_str!("../template/content/loot.json"),
),
(
"data/{id}/recipe/glowing_amethyst.json",
include_str!("../template/content/recipe.json"),
),
(
"data/minecraft/tags/block/mineable/pickaxe.json",
include_str!("../template/content/pickaxe.json"),
),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
};
for &(file, text) in files {
let file = fill(file);
let file = file.as_str();
let to = dir.join(file);
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
.map_err(|e| e.to_string())?;
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
}
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
let manifest =
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
Content::read(dir, &manifest)?;
println!(
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
dir.display()
@@ -110,14 +169,53 @@ fn new(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
/// the manifest, the crate's component (none without a `Cargo.toml`: a
/// content mod) and the files in `assets/` and `data/`.
fn build(release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(MANIFEST)
build_in(Path::new("."), release)
}
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
let manifest = Manifest::parse(&manifest_text)?;
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
let (wasm, target) = compile(root, release)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
(Some(wasm), target)
} else if manifest.capabilities.contains(&Capability::Registries) {
(None, root.join("target"))
} else {
return Err(format!(
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
));
};
// The same checks as the game's, and the files in the order it lists them.
let content = Content::read(root, &manifest)?;
let mut reader = content.reader()?;
let mut files = Vec::with_capacity(content.files.len());
for name in &content.files {
files.push((name.as_str(), reader.read(name)?));
}
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
}
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.args([
cmd.current_dir(root).args([
"build",
"--lib",
"--target",
@@ -145,17 +243,8 @@ fn build(release: bool) -> Result<PathBuf, String> {
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
Ok((wasm, target.to_path_buf()))
}
/// `check <file>`: the registry's checks of a submission, printed; fails
@@ -189,6 +278,63 @@ fn check(args: &[String]) -> Result<(), String> {
Ok(())
}
/// `test`: every `tests/*.toml` script (those whose name holds `<name>`)
/// against the built mod, or the `.hmod` given with `--hmod`.
fn test(args: &[String]) -> Result<(), String> {
let hmod = match flag(args, "--hmod") {
Some(p) => PathBuf::from(p),
None => build(!args.iter().any(|a| a == "--debug"))?,
};
let filter = args
.iter()
.enumerate()
.find(|(i, a)| !a.starts_with("--") && (*i == 0 || args[i - 1] != "--hmod"))
.map(|(_, a)| a.as_str());
let mut scripts: Vec<PathBuf> = std::fs::read_dir("tests")
.map_err(|e| {
format!(
"tests/: {e} (a mod's tests are tests/<name>.toml: docs/mods.md, \"Testing a mod\")"
)
})?
.filter_map(|e| Some(e.ok()?.path()))
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
.filter(|p| filter.is_none_or(|f| p.to_string_lossy().contains(f)))
.collect();
scripts.sort();
if scripts.is_empty() {
return Err("no tests/*.toml to run".into());
}
println!(
"running {} scripts against {}",
scripts.len(),
hmod.display()
);
let mut failed = 0;
for path in &scripts {
let result = std::fs::read_to_string(path)
.map_err(|e| e.to_string())
.and_then(|text| Script::parse(&text))
.and_then(|script| {
let mut game = TestGame::load(&hmod)?;
script.run(&mut game)?;
Ok(script.steps.len())
});
match result {
Ok(steps) => println!("test {} ... ok ({steps} steps)", path.display()),
Err(e) => {
failed += 1;
println!("test {} ... FAILED\n {e}", path.display());
}
}
}
let passed = scripts.len() - failed;
if failed > 0 {
return Err(format!("{failed} failed, {passed} passed"));
}
println!("test result: ok. {passed} passed");
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
let path = build(true)?;
let game = flag(args, "--game")
@@ -231,10 +377,57 @@ mod tests {
);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
let script = std::fs::read_to_string(path.join("tests/hello.toml")).unwrap();
assert_eq!(Script::parse(&script).unwrap().steps.len(), 3);
assert!(
new(&[path.display().to_string()]).is_err(),
"never overwrites"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
let path = dir.join("gems");
let new_args = [
path.display().to_string(),
"--template".into(),
"content".into(),
];
new(&new_args).unwrap();
assert!(!path.join("Cargo.toml").exists());
let hmod = build_in(&path, true).unwrap();
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
let package = Package::read(&hmod).unwrap();
assert!(package.wasm.is_none());
let content = Content::read(&hmod, &package.manifest).unwrap();
assert!(
content
.files
.contains(&"data/gems/horizon/block/glowing_amethyst.json".into())
);
assert_eq!(content.files.len(), 8, "{:?}", content.files);
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
assert!(!report.code);
assert!(
report.text().contains("no code: content only"),
"{}",
report.text()
);
// Without registries, a mod needs code.
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
let manifest = manifest.replace("[\"registries\"]", "[]");
std::fs::write(path.join(MANIFEST), manifest).unwrap();
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
assert!(
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"lua".into()
])
.is_err()
);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -0,0 +1,4 @@
{
"template": "minecraft:amethyst_block",
"properties": { "hardness": 2.0, "light": 7 }
}
@@ -0,0 +1,5 @@
{
"variants": {
"": { "model": "{id}:block/glowing_amethyst" }
}
}
@@ -0,0 +1,10 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks and items to your worlds".
capabilities = ["registries"]
@@ -0,0 +1,3 @@
{
"model": { "type": "minecraft:model", "model": "{id}:block/glowing_amethyst" }
}
@@ -0,0 +1,3 @@
{
"block.{id}.glowing_amethyst": "Glowing Amethyst"
}
@@ -0,0 +1,11 @@
{
"type": "minecraft:block",
"pools": [
{
"condition": { "type": "minecraft:survives_explosion" },
"entries": [{ "type": "minecraft:item", "name": "{id}:glowing_amethyst" }],
"rolls": 1
}
],
"random_sequence": "{id}:blocks/glowing_amethyst"
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:block/cube_all",
"textures": { "all": "minecraft:block/amethyst_block" }
}
@@ -0,0 +1,3 @@
{
"values": ["{id}:glowing_amethyst"]
}
@@ -0,0 +1,6 @@
{
"type": "minecraft:crafting_shapeless",
"category": "building",
"ingredients": ["minecraft:amethyst_block", "minecraft:glowstone_dust"],
"result": { "id": "{id}:glowing_amethyst" }
}
@@ -0,0 +1,14 @@
# `cargo horizon-mod test` runs every tests/*.toml against the built mod, with no game:
# steps happen in order, and `expect` checks what the mod showed during the step.
# Every key: docs/mods.md, "Testing a mod".
[[step]]
expect.commands = ["hello"]
[[step]]
command = "hello world"
expect.chat = ["Hello world! (1 so far)"]
[[step]]
command = "hello again"
expect.chat = ["Hello again! (2 so far)"]
+10 -7
View File
@@ -1031,19 +1031,22 @@ pub struct ItemModels {
}
impl ItemModels {
pub fn components(&self, stack: &StackView) -> Components {
let mut out = self
.prototypes
.get(&stack.item)
.cloned()
.unwrap_or_default();
/// The stack's components: its item's defaults with the stack's patch on top. A stack
/// with no patch (most stacks a screen draws) borrows the defaults instead of copying them.
pub fn components(&self, stack: &StackView) -> std::borrow::Cow<'_, Components> {
static NONE: Components = BTreeMap::new();
let prototype = self.prototypes.get(&stack.item).unwrap_or(&NONE);
if stack.patch.is_empty() {
return std::borrow::Cow::Borrowed(prototype);
}
let mut out = prototype.clone();
for (key, value) in &stack.patch {
match value {
Some(v) => out.insert(key.clone(), v.clone()),
None => out.remove(key),
};
}
out
std::borrow::Cow::Owned(out)
}
/// `ItemModelResolver.updateForTopItem`.
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
+25 -25
View File
@@ -173,6 +173,11 @@ impl Random {
}
}
/// One bot thread that panics poisons the locks it held; the others and the report go on.
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
let mut w = Writer::new();
w.varint(id);
@@ -329,7 +334,7 @@ fn read(
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
let send = |body: Vec<u8>| lock(&writer).send(&body);
let mut seen = HashSet::new();
let mut batch = BatchSize::new();
let mut teleports = 0u64;
@@ -338,7 +343,7 @@ fn read(
Ok(packet) => packet,
Err(e) => {
if bot.online.swap(false, Ordering::SeqCst) {
*bot.error.lock().unwrap() = Some(e.to_string());
*lock(&bot.error) = Some(e.to_string());
}
return;
}
@@ -356,7 +361,7 @@ fn read(
cb::LEVEL_CHUNK_WITH_LIGHT => {
bot.chunks.fetch_add(1, Ordering::Relaxed);
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
if !bot.held.lock().unwrap().insert((x, z)) {
if !lock(&bot.held).insert((x, z)) {
bot.resent.fetch_add(1, Ordering::Relaxed);
}
if seen.insert((x, z)) {
@@ -368,7 +373,7 @@ fn read(
cb::FORGET_LEVEL_CHUNK => {
if let Ok(play::Clientbound::ForgetLevelChunk(p)) =
play::Clientbound::decode(id, &body)
&& bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1]))
&& lock(&bot.held).remove(&(p.pos[0], p.pos[1]))
{
bot.forgotten.fetch_add(1, Ordering::Relaxed);
}
@@ -389,7 +394,7 @@ fn read(
}
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
Ok(play::Clientbound::PlayerPosition(p)) => {
let mut at = bot.teleport.lock().unwrap();
let mut at = lock(&bot.teleport);
let previous = at.unwrap_or([0.0; 3]);
// Relative flags: X 1, Y 2, Z 4.
let pos: [f64; 3] = std::array::from_fn(|i| {
@@ -420,7 +425,7 @@ fn read(
},
cb::DISCONNECT | cb::START_CONFIGURATION => {
bot.online.store(false, Ordering::SeqCst);
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
format!(
"disconnected: {}",
String::from_utf8_lossy(&body)
@@ -455,7 +460,7 @@ fn run_bot(
let connection = match join(&o, &name) {
Ok(c) => c,
Err(e) => {
*bot.error.lock().unwrap() = Some(e);
*lock(&bot.error) = Some(e);
return;
}
};
@@ -469,12 +474,12 @@ fn run_bot(
use ids::play::serverbound as sb;
// Wait for the spawn position.
let spawn = loop {
if let Some(at) = *bot.teleport.lock().unwrap() {
if let Some(at) = *lock(&bot.teleport) {
break at;
}
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
if bot.error.lock().unwrap().is_none() {
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
if lock(&bot.error).is_none() {
*lock(&bot.error) = Some("no spawn position within 60 s".into());
}
return;
}
@@ -482,7 +487,7 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
@@ -491,17 +496,15 @@ fn run_bot(
let mut target = pos;
let mut next = Instant::now();
loop {
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|| !bot.online.load(Ordering::SeqCst)
{
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
break;
}
// A teleport from the server wins over where the bot thought it was.
if let Some(at) = bot.teleport.lock().unwrap().take() {
if let Some(at) = lock(&bot.teleport).take() {
pos = at;
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
if !bot.held.lock().unwrap().contains(&here) {
if !lock(&bot.held).contains(&here) {
bot.waited.fetch_add(1, Ordering::Relaxed);
} else if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
@@ -558,7 +561,7 @@ fn run_bot(
}
// Offline first, so the reader doesn't count the closed connection as a failure.
bot.online.store(false, Ordering::SeqCst);
writer.lock().unwrap().shutdown();
lock(&writer).shutdown();
}
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
@@ -612,13 +615,10 @@ fn main() {
let total = |f: fn(&Bot) -> &AtomicU64| -> u64 {
bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum()
};
let failed = bots
.iter()
.filter(|b| b.error.lock().unwrap().is_some())
.count();
let failed = bots.iter().filter(|b| lock(&b.error).is_some()).count();
if first.is_none() && online > 0 {
first = Some(Instant::now());
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
*lock(&until) = Some(Instant::now() + Duration::from_secs(o.seconds));
}
println!(
"{}",
@@ -636,10 +636,10 @@ fn main() {
})
);
previous_chunks = chunks;
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
let over = lock(&until).is_some_and(|u| Instant::now() >= u);
let all_failed = failed == o.bots;
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
*until.lock().unwrap() = Some(Instant::now());
*lock(&until) = Some(Instant::now());
break;
}
}
@@ -669,7 +669,7 @@ fn main() {
"forgotten": b.forgotten.load(Ordering::Relaxed),
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
"waited_ticks": b.waited.load(Ordering::Relaxed),
"error": *b.error.lock().unwrap(),
"error": *lock(&b.error),
})).collect::<Vec<_>>(),
});
if o.report.as_deref() == Some("-") {
+12 -2
View File
@@ -12,15 +12,20 @@ horizon-assets = { path = "../horizon-assets" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
@@ -66,6 +71,11 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[target.'cfg(windows)'.build-dependencies]
# The icon resource in the .exe (build.rs).
winresource = "0.1"
@@ -0,0 +1,15 @@
{
"horizon.options.controller": "Controller",
"horizon.options.vibration": "Vibration",
"horizon.options.adaptive_triggers": "Adaptive Triggers",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Look Sensitivity",
"horizon.options.invert_x": "Invert Look X",
"horizon.options.invert_y": "Invert Look Y",
"horizon.options.sneak_button": "Sneak Button",
"horizon.options.move_deadzone": "Move Deadzone",
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
}
@@ -0,0 +1,15 @@
{
"horizon.options.controller": "Геймпад",
"horizon.options.vibration": "Вибрация",
"horizon.options.adaptive_triggers": "Адаптивные триггеры",
"horizon.options.lightbar": "Подсветка",
"horizon.options.look_sensitivity": "Чувствительность обзора",
"horizon.options.invert_x": "Инверсия обзора по X",
"horizon.options.invert_y": "Инверсия обзора по Y",
"horizon.options.sneak_button": "Кнопка приседания",
"horizon.options.move_deadzone": "Мёртвая зона движения",
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
}
@@ -367,6 +367,7 @@ pub fn main() -> Result<()> {
lid: &|_| open,
sign: &|_| sign.as_ref(),
banner: &|_| layers.clone(),
skull: &|_| None,
glyph: &|c| atlas.glyph_cell(c),
};
let mut verts = Vec::new();
@@ -7,7 +7,7 @@
//! usage: chat_screen_fixture RESOURCES REGISTRIES CASES OUTPUT
use crate::pack_screen_fixture;
use horizon_client::{
chat::{ChatAction, ChatScreen, Commands},
chat::{ChatAbilities, ChatAction, ChatScreen, ChatVisibility, Commands},
command::vanilla,
hud::HudAtlas,
lang,
@@ -53,6 +53,7 @@ pub fn main() -> horizon_client::Result<()> {
history.len(),
w,
h,
ChatAbilities::new(ChatVisibility::Full),
);
screen.auto_suggestions = case["autoSuggest"] == true;
let mut closed = false;
@@ -0,0 +1,48 @@
use horizon_client::{
Result,
item_data::StackData,
items::{ItemStack, Items},
};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let items = Items::catalog(&root)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let mut stack = ItemStack::new(
row["item"].as_str().unwrap(),
row["count"].as_u64().unwrap() as u32,
);
match row["tool"].as_str().unwrap() {
"default" => {}
mode => {
let mut data = StackData::default();
let tool = if mode == "implicit" {
Tag::compound([] as [(&str, Tag); 0])
} else {
Tag::compound([(
"can_destroy_blocks_in_creative",
Tag::Byte(i8::from(mode == "allow")),
)])
};
data.set_other("minecraft:tool", Some(tool));
stack.extra = Some(Box::new(data));
}
}
writeln!(
out,
"{}",
items.can_destroy_block(Some(&stack), row["creative"].as_bool().unwrap())
)?;
}
Ok(())
}
@@ -0,0 +1,89 @@
use horizon_client::{
Result,
cushion::{self, World},
};
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
use serde_json::Value;
use std::{
collections::HashMap,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
struct Cell {
outline: VoxelShape,
collision: VoxelShape,
suffocating: bool,
}
struct Fixture(HashMap<[i32; 3], Cell>);
impl World for Fixture {
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
self.0
.get(&p)?
.outline
.bounds()
.map(|b| b.move_by(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64)))
}
fn collision_shapes(&self, _: Aabb) -> Vec<VoxelShape> {
self.0
.iter()
.map(|(p, c)| {
c.collision
.moved(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64))
})
.collect()
}
fn suffocating(&self, p: [i32; 3]) -> bool {
self.0.get(&p).is_some_and(|c| c.suffocating)
}
}
fn shape(value: &Value) -> Result<VoxelShape> {
let mut shape = VoxelShape::empty();
for b in value.as_array().unwrap() {
let f = |i: usize| b[i].as_f64().unwrap();
shape = shape.join(
&VoxelShape::from_box(Aabb::new(
Vec3::new(f(0), f(1), f(2)),
Vec3::new(f(3), f(4), f(5)),
))?,
ShapeOp::OR,
)?;
}
Ok(shape)
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let mut world = Fixture(HashMap::new());
for c in row["cells"].as_array().unwrap() {
let p = std::array::from_fn(|i| c[i].as_i64().unwrap() as i32);
world.0.insert(
p,
Cell {
outline: shape(&c[3])?,
collision: shape(&c[4])?,
suffocating: c[5].as_bool().unwrap(),
},
);
}
let p = &row["pos"];
let (x, y, z) = (
p[0].as_f64().unwrap(),
p[1].as_f64().unwrap(),
p[2].as_f64().unwrap(),
);
let bounds = Aabb::new(
Vec3::new(x - 0.5, y, z - 0.5),
Vec3::new(x + 0.5, y + 0.25, z + 0.5),
);
writeln!(
out,
"{} {}",
cushion::can_place(&world, bounds),
cushion::survives(&world, bounds)
)?;
}
Ok(())
}
@@ -9,7 +9,7 @@ use horizon_client::mesher::Vertex;
use horizon_client::mobs::{
Mob, MobData, MobTypes, Part, Species, allay,
anim::{self, Poses, State},
aquatic, body_anim as b_anim, equine, guardian,
aquatic, bat, body_anim as b_anim, equine, guardian,
keyframes::AnimationState,
render, shulker, sniffer, species, tame,
};
@@ -108,6 +108,22 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
match species {
Species::Bat => {
mob.extra.flag = flag("isResting");
mob.anim.states[0].started = case["flyAnimationState"].as_f64().map(|v| v as f32);
mob.anim.states[1].started = case["restAnimationState"].as_f64().map(|v| v as f32);
if let Some(ticks) = case["@batTicks"].as_array() {
for (i, flags) in ticks.iter().enumerate() {
mob.age = i as u32 + 1;
bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
if flag("@batInFrame") {
bat::tick(mob);
} else {
bat::setup_animation_states(mob);
}
}
}
}
Species::Sheep => mob.extra.anim_ticks = num("eatTick") as i32,
Species::Guardian | Species::ElderGuardian => {
mob.anim.v[guardian::TAIL] = num("tailAnimation");
@@ -377,7 +393,7 @@ pub fn main() -> horizon_client::Result<()> {
mob.extra.age = -24000;
}
mob.age = f("ageInTicks") as u32;
let state = State {
let mut state = State {
pos: f("walkAnimationPos"),
speed: f("walkAnimationSpeed"),
age: f("ageInTicks"),
@@ -389,6 +405,20 @@ pub fn main() -> horizon_client::Result<()> {
};
let mut poses = Poses::new(&model);
entity_state(&mut mob, def.species, case);
if let Some(ticks) = case["@walkTicks"].as_array() {
for speed in ticks {
mob.previous_limb_speed = mob.limb_speed;
mob.limb_speed += (speed.as_f64().unwrap() as f32 - mob.limb_speed) * 0.4;
mob.limb_position += mob.limb_speed;
}
if !case["@alive"].as_bool().unwrap() {
mob.health = 0.0;
}
let extracted = anim::state(&mob, f("@partial"));
state.pos = extracted.pos;
state.speed = extracted.speed;
}
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
let started = |k: &str| AnimationState {
@@ -456,14 +486,6 @@ pub fn main() -> horizon_client::Result<()> {
roll_amount: num("rollAmount"),
},
),
Species::Bat => b_anim::bat(
p,
s,
flag("isResting"),
&started("flyAnimationState"),
&started("restAnimationState"),
&animations,
),
Species::Armadillo => b_anim::armadillo(
p,
s,
@@ -590,7 +612,11 @@ pub fn main() -> horizon_client::Result<()> {
}
_ => anim::setup(&mob, s, p, &animations),
}
out.push_str(&format!("{}\n", emit(&model, &mut poses, case, layer)));
let mut result = emit(&model, &mut poses, case, layer);
if case["@walkTicks"].is_array() {
result["walk"] = json!([state.pos, state.speed]);
}
out.push_str(&format!("{result}\n"));
}
fs::write(&args[2], out)?;
Ok(())
@@ -23,6 +23,7 @@ fn stack(spec: &str) -> LootStack {
map: false,
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -319,6 +319,7 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
atlas,
comp(&m["text"]),
indicator,
horizon_client::chat::ChatSource::Client,
m["at"].as_u64().unwrap_or(0),
false,
);
@@ -0,0 +1,53 @@
//! Lore tooltip companion for tools/test_lore_tooltip_parity.py: the spans
//! `item_tooltip::lore_texts` gives each stack (SNBT, one per line).
use horizon_client::{
item_data::parse_stack,
item_tooltip::{TooltipDisplay, lore_texts},
};
use std::{error::Error, fmt::Write};
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let input = std::fs::read_to_string(&args[1])?;
let mut output = String::new();
for line in input
.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
{
let stack = parse_stack(line)?.ok_or("empty stack")?;
let display = TooltipDisplay::for_stack(&stack);
if !display.shows("minecraft:lore") {
writeln!(output, "-")?;
continue;
}
let lines = lore_texts(&stack);
if lines.is_empty() {
writeln!(output, "-")?;
continue;
}
let mut rendered = Vec::new();
for text in lines {
let mut spans = Vec::new();
for span in &text.spans {
if span.text.is_empty() {
continue;
}
let style = &span.style;
spans.push(format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
span.text,
style.color.map(|c| c as i32).unwrap_or(-1),
style.bold == Some(true),
style.italic == Some(true),
style.underlined == Some(true),
style.strikethrough == Some(true),
style.obfuscated == Some(true),
));
}
rendered.push(spans.join(" | "));
}
writeln!(output, "{}", rendered.join(" || "))?;
}
std::fs::write(&args[2], output)?;
Ok(())
}
@@ -27,7 +27,9 @@ mod chat_screen_fixture;
mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
mod emitter_particles_probe;
mod enchanting_fixture;
mod entity_anim_fixture;
@@ -49,6 +51,7 @@ mod input_probe;
mod interaction_input_fixture;
mod lan_probe;
mod light_parity;
mod lore_tooltip_fixture;
mod marker_particles_probe;
mod menu_fixture;
mod mipmap_probe;
@@ -82,6 +85,7 @@ mod sound_engine_fixture;
mod sound_event_fixture;
mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
@@ -106,6 +110,9 @@ probes! {
block_transform_fixture,
breaking_fixture,
camera_probe,
squid_client_probe,
creative_mining_probe,
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
cloud_fixture,
@@ -133,6 +140,7 @@ probes! {
interaction_input_fixture,
lan_probe,
light_parity,
lore_tooltip_fixture,
marker_particles_probe,
menu_fixture,
mipmap_probe,
@@ -49,7 +49,7 @@ pub fn main() -> Result<()> {
}
}
}
let mut engine = ParticleEngine::new(root, &HashMap::new());
let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let case: Value = serde_json::from_str(&line?)?;
@@ -169,6 +169,7 @@ pub fn main() -> horizon_client::Result<()> {
cooking_timer: menu.data[2],
cooking_total: menu.data[3],
speed: 1.0,
recipes_used: Default::default(),
});
menu.drops.clear();
menu.equipped.clear();
@@ -49,6 +49,7 @@ struct Scenario {
name: String,
ticks: i32,
time: i64,
difficulty: u8,
rain: bool,
thunder: bool,
origin: [i32; 2],
@@ -85,11 +86,13 @@ fn parse(text: &str) -> Vec<Scenario> {
"scenario" => {
s.name = f[1].to_owned();
s.time = 6000;
s.difficulty = 2;
for kv in &f[2..] {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
match k {
"ticks" => s.ticks = v.parse().unwrap(),
"time" => s.time = v.parse().unwrap(),
"difficulty" => s.difficulty = v.parse().unwrap(),
"rain" => s.rain = true,
"thunder" => s.thunder = true,
"levelseed" => {}
@@ -598,6 +601,7 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
let _ = writeln!(out, " removed {reason}");
return;
}
let _ = write!(out, "\tleashed {}", u8::from(m.extra.leash.is_some()));
let p = m.position;
let v = m.velocity;
let _ = write!(
@@ -953,6 +957,10 @@ pub fn main() -> horizon_client::Result<()> {
for flag in &spec.flags {
match flag.as_str() {
"persistent" => m.persistent = true,
"named" => {
m.custom_name = Some("Peaceful regression".into());
m.persistent = true;
}
"ridden" => {
m.extra.head = Some("minecraft:red_harness".into());
m.rider = Some(horizon_client::mobs::equine::Rider::default());
@@ -977,6 +985,7 @@ pub fn main() -> horizon_client::Result<()> {
}
// Set once the brain exists (below).
x if x.starts_with("cooldown=") => {}
"dead" => m.health = 0.0,
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
x if x.starts_with("leashto=") => {
let n: u32 = x[8..].parse().unwrap();
@@ -1088,7 +1097,7 @@ pub fn main() -> horizon_client::Result<()> {
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
// FlatLevelSource.getSeaLevel.
@@ -1097,6 +1106,7 @@ pub fn main() -> horizon_client::Result<()> {
}
};
}
let mut dropped_leads = 0;
for t in 1..=s.ticks {
world.game_time += 1;
if s.daycycle {
@@ -1208,6 +1218,16 @@ pub fn main() -> horizon_client::Result<()> {
if !alive {
m.removed = true;
}
// Apply the same detach/drop event as Game::break_leash.
for event in &events[from..] {
if let MobEvent::LeashBreak { mob, drop, .. } = event
&& let Some(m) = mobs.iter_mut().find(|m| m.id == *mob)
&& horizon_client::mobs::leash::remove(m).is_some()
&& *drop
{
dropped_leads += 1;
}
}
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
villager_events(
&mut mobs,
@@ -1427,6 +1447,9 @@ pub fn main() -> horizon_client::Result<()> {
world.game_time,
);
}
if s.name.starts_with("leash_death_") {
let _ = writeln!(out, "{t} leads {dropped_leads}");
}
for e in &events {
if let MobEvent::Sound(name, _, volume, pitch) = e {
let _ = writeln!(
@@ -152,7 +152,7 @@ pub fn main() -> Result<()> {
world(&assets, &facts, false, true)?,
world(&assets, &facts, true, true)?,
];
let mut engine = ParticleEngine::new(root, &HashMap::new());
let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
for (ty, item) in [
(ParticleType::ItemSlime, "item_slime_ball"),
(ParticleType::ItemSnowball, "item_snowball"),
@@ -7,7 +7,7 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
use horizon_core::biome::BiomeZoom;
@@ -161,7 +161,15 @@ fn scene(
.iter()
.map(|v| {
let ([x, y, z], [u, w]) = (v.pos, v.uv());
json!([x, y, z, u, w, argb(v.color), light_coords(v.light)])
json!([
x,
y,
z,
u,
w,
argb(v.color | EMISSION_BITS),
light_coords(v.light)
])
})
.collect();
quads.push(json!({"layer": layer, "sprite": sprite, "vertices": vertices}));
@@ -0,0 +1,51 @@
use horizon_client::{
Result,
mobs::{Mob, MobTypes, aquatic},
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
sync::Arc,
};
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
let types = MobTypes::load(&root)?;
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let kind = Arc::new(
types.types[&format!("minecraft:{}", row["species"].as_str().unwrap())].clone(),
);
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
mob.fluids.in_water = row["water"].as_bool().unwrap();
mob.velocity = Vec3::new(
row["x"].as_f64().unwrap(),
row["y"].as_f64().unwrap(),
row["z"].as_f64().unwrap(),
);
mob.body_yaw = row["body"].as_f64().unwrap() as f32;
for (i, value) in row["v"].as_array().unwrap().iter().enumerate() {
mob.anim.v[i] = value.as_f64().unwrap() as f32;
}
if row["reset"].as_bool().unwrap() {
aquatic::squid_cycle_event(&mut mob);
}
let velocity = mob.velocity;
aquatic::squid_client_step(&mut mob);
assert_eq!(
velocity, mob.velocity,
"a remote animation must preserve packet velocity"
);
write!(out, "{:08x}", mob.body_yaw.to_bits())?;
for value in &mob.anim.v[..6] {
write!(out, " {:08x}", value.to_bits())?;
}
writeln!(out)?;
}
Ok(())
}
+144 -6
View File
@@ -58,11 +58,18 @@ enum Animate {
BubbleColumn {
drag: bool,
},
Fire,
/// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true).
Fire {
soul: bool,
},
/// An active sculk sensor.
SculkSensor,
/// Potent sulfur that is not dry.
PotentSulfur,
DriedGhast {
waterlogged: bool,
},
EndPortal,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -136,7 +143,12 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"bubble_column" => Animate::BubbleColumn {
drag: prop("drag").as_deref() == Some("true"),
},
"fire" | "soul_fire" => Animate::Fire,
"fire" => Animate::Fire { soul: false },
"soul_fire" => Animate::Fire { soul: true },
"dried_ghast" => Animate::DriedGhast {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -180,6 +192,10 @@ pub struct AnimateTables {
impermeable: Vec<bool>,
power_provider: Vec<bool>,
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
}
@@ -210,6 +226,21 @@ impl AnimateTables {
})
.collect()
};
let behaviour: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?;
let ignite_odds = behaviour["fire"]["igniteOdds"].as_object();
let burnable = (0..registry.len() as u32)
.map(|id| {
// FireBlock.getIgniteOdds: a waterlogged state never burns.
registry.state(BlockStateId(id)).is_some_and(|s| {
s.properties.get("waterlogged").map(String::as_str) != Some("true")
&& ignite_odds
.and_then(|m| m.get(s.name.as_str()))
.and_then(serde_json::Value::as_i64)
.is_some_and(|odds| odds > 0)
})
})
.collect();
let mut ambient = vec![Vec::new(); biomes.list.len()];
for (name, &id) in &biomes.index {
let path = root.join(format!(
@@ -237,6 +268,8 @@ impl AnimateTables {
impermeable: by_state("minecraft:impermeable"),
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
burnable,
ambient,
})
}
@@ -869,6 +902,45 @@ fn block_animate_tick(
];
add(engine, T::Smoke, p, [0.0; 3]);
}
Animate::DriedGhast { waterlogged } => {
// DriedGhastBlock.animateTick.
let c = [fx + 0.5, fy + 0.5, fz + 0.5];
if waterlogged {
if next_int(engine.random(), 40) == 0 {
let name = "minecraft:block.dried_ghast.ambient_water";
sound(engine, name, c, 1.0, 1.0);
}
let r = engine.random();
if next_int(r, 6) == 0 {
let x = c[0] + f64::from((r.next_float() * 2.0 - 1.0) / 3.0);
let y = c[1] + 0.4;
let z = c[2] + f64::from((r.next_float() * 2.0 - 1.0) / 3.0);
let v = [0.0, f64::from(r.next_float()), 0.0];
add(engine, T::HappyVillager, [x, y, z], v);
}
} else {
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
let triggers = || {
tables
.dried_ghast_sounds
.get(below.0 as usize)
.copied()
.unwrap_or(false)
};
if next_int(engine.random(), 40) == 0 && triggers() {
sound(engine, "minecraft:block.dried_ghast.ambient", c, 1.0, 1.0);
}
if next_int(engine.random(), 6) == 0 {
add(engine, T::WhiteSmoke, c, [0.0, 0.02, 0.0]);
}
}
}
Animate::EndPortal => {
// EndPortalBlock.animateTick: one smoke particle above the surface.
let r = engine.random();
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
add(engine, T::Smoke, p, [0.0; 3]);
}
Animate::EnderChest => {
for _ in 0..3 {
let r = engine.random();
@@ -1053,7 +1125,7 @@ fn block_animate_tick(
}
}
}
Animate::Fire => {
Animate::Fire { soul } => {
let r = engine.random();
if next_int(r, 24) == 0 {
let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3);
@@ -1065,6 +1137,55 @@ fn block_animate_tick(
pitch,
);
}
// BaseFireBlock.animateTick: three large smoke above the fire, or
// two at each burnable neighbour's face when nothing burnable or
// sturdy supports it (SoulFireBlock.canBurn is always true).
let can_burn = |p: [i32; 3]| {
soul || tables
.burnable
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let below = [pos[0], pos[1] - 1, pos[2]];
let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0;
if !can_burn(below) && !sturdy {
// (neighbour, coordinate axis, face at the axis' high end): the
// west, east, north, south and above faces, in vanilla order.
for (at, axis, high) in [
([pos[0] - 1, pos[1], pos[2]], 0usize, false),
([pos[0] + 1, pos[1], pos[2]], 0, true),
([pos[0], pos[1], pos[2] - 1], 2, false),
([pos[0], pos[1], pos[2] + 1], 2, true),
([pos[0], pos[1] + 1, pos[2]], 1, true),
] {
if !can_burn(at) {
continue;
}
for _ in 0..2 {
let r = engine.random();
let d = [r.next_double(), r.next_double(), r.next_double()];
let mut p = [fx + d[0], fy + d[1], fz + d[2]];
// The face coordinate hugs the edge: `* 0.1F` (float).
p[axis] = if high {
f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32)
} else {
f64::from(pos[axis]) + d[axis] * f64::from(0.1f32)
};
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
} else {
for _ in 0..3 {
let r = engine.random();
let p = [
fx + r.next_double(),
fy + r.next_double() * 0.5 + 0.5,
fz + r.next_double(),
];
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
}
}
}
@@ -1107,13 +1228,19 @@ pub fn campfire_particles<'a>(
if r.next_float() < 0.11 {
let count = next_int(r, 2) + 2;
for _ in 0..count {
make_campfire_smoke(engine, pos, signal);
make_campfire_smoke(engine, pos, signal, false);
}
}
}
}
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter).
pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: bool) {
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
/// `smoking` adds the plain smoke puff at the logs.
pub fn make_campfire_smoke(
engine: &mut ParticleEngine,
pos: [i32; 3],
signal: bool,
smoking: bool,
) {
let r = engine.random();
let sign = |r: &mut LegacyRandom| if r.next_boolean() { 1.0 } else { -1.0 };
let dx = r.next_double() / 3.0;
@@ -1127,6 +1254,17 @@ pub fn make_campfire_smoke(engine: &mut ParticleEngine, pos: [i32; 3], signal: b
T::CampfireCosySmoke
};
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
if smoking {
let r = engine.random();
let dx = r.next_double() / 4.0 * sign(r);
let dz = r.next_double() / 4.0 * sign(r);
let at = [
f64::from(pos[0]) + 0.5 + dx,
f64::from(pos[1]) + 0.4,
f64::from(pos[2]) + 0.5 + dz,
];
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
}
}
#[cfg(test)]
+31
View File
@@ -323,10 +323,15 @@ const QUIET_MESHES_PER_S: f64 = 100.0;
/// happened (`tools/perf_bench.py`'s `wait_loaded`). With `status.streaming`, the sections are
/// drawn once no stage has held work for half a second: blocks changing after the load (fluids
/// settling, random ticks) only remesh in bursts. Without it, meshing has to go quiet.
///
/// Right after a teleport (and at spawn) the game reports nothing queued and steady counts for over
/// a second before it starts loading: the counts must change once, or stay full for 5 s, and the
/// resident chunks reach [`view_chunks`], before "steady" means loaded.
fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Instant, String> {
let deadline = Instant::now() + LOAD_LIMIT;
let (mut last, mut changed, mut meshing) = (None, Instant::now(), Instant::now());
let mut previous: Option<(Instant, f64)> = None;
let (mut first, mut moved, began) = (None, false, Instant::now());
while Instant::now() < deadline {
let s = control.status();
let now = Instant::now();
@@ -339,6 +344,19 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Insta
(last, changed, previous, meshing) = (None, now, None, now);
} else {
let counts = (s["chunks"].as_u64(), s["generated_chunks"].as_u64());
let resident = counts.0.unwrap_or(0);
let min_chunks = view_chunks(s["render_distance"].as_u64().unwrap_or(0));
let first = *first.get_or_insert(counts);
moved = moved
|| counts != first
|| (min_chunks > 0
&& resident >= min_chunks
&& now - began > Duration::from_secs(5));
if min_chunks > 0 && (!moved || resident < min_chunks) {
(last, changed, previous, meshing) = (None, now, None, now);
thread::sleep(Duration::from_millis(50));
continue;
}
if last != Some(counts) {
(last, changed) = (Some(counts), now);
}
@@ -370,6 +388,12 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Insta
Err(format!("not loaded after {} s", LOAD_LIMIT.as_secs()))
}
/// 80% of the view circle (pi x RD^2): the game keeps 637 columns at RD 12 and 3725 at RD 32
/// (`tools/perf_bench.py`'s `view_chunks`).
fn view_chunks(distance: u64) -> u64 {
(0.8 * std::f64::consts::PI * (distance * distance) as f64) as u64
}
/// Vanilla's yaw facing from `start` to `end`.
fn yaw(start: [f64; 3], end: [f64; 3]) -> f64 {
(-(end[0] - start[0])).atan2(end[2] - start[2]).to_degrees()
@@ -420,6 +444,13 @@ mod tests {
assert!(scenes("nether").is_none());
}
#[test]
fn view_chunks_matches_perf_bench() {
assert_eq!(view_chunks(12), 361);
assert_eq!(view_chunks(32), 2573);
assert_eq!(view_chunks(0), 0);
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
@@ -143,6 +143,7 @@ const PAGES: Ty = Ty::Enum(&[
"font",
"packs",
"mods",
"shader-packs",
"graphics",
"graphics-advanced",
"extras",
@@ -610,12 +610,17 @@ mod tests {
#[test]
fn unreachable_games_fail_with_status_3() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
let home =
std::env::temp_dir().join(format!("horizon-ctl-unreachable-{}", std::process::id()));
std::fs::create_dir_all(&home).unwrap();
let mut s = Session::new(home.join("nobody.sock"), None);
s.home = home.clone();
let f = s.run(&["state".to_owned()]).unwrap_err();
assert_eq!(f.status, UNREACHABLE);
assert_eq!(f.reply["kind"], "connection");
let f = s.run(&["sleep".to_owned(), "x".to_owned()]).unwrap_err();
assert_eq!(f.status, USAGE);
std::fs::remove_dir_all(home).unwrap();
}
#[test]
+6
View File
@@ -28,6 +28,9 @@ pub struct BiomeInfo {
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
/// mushroom fields).
pub increased_fire_burnout: bool,
pub name: String,
pub temperature: f32,
pub downfall: f32,
@@ -128,6 +131,9 @@ impl Biomes {
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
+18 -7
View File
@@ -664,7 +664,9 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
"minecraft:trial_spawner" => {
// `TrialSpawnerStateData.Packed` and `FullConfig`: every key is optional
// and defaulted by vanilla; they are kept as loaded.
// and defaulted by vanilla; they are kept as loaded. No `spawn_data` is
// added: an absent one loads as Optional.empty and is not stored, and
// an empty entity would keep the spawner from ever picking a mob.
if let Tag::Compound(c) = tag {
for (k, v) in c {
let key = k.to_string_lossy();
@@ -676,12 +678,6 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
}
}
if !f.out.contains_key(&"spawn_data".into()) {
f.put(
"spawn_data",
Tag::compound([("entity", Tag::Compound(Compound::new()))]),
);
}
}
"minecraft:vault" => {
// `VaultConfig.DEFAULT` (when the config is absent): the trial key and the
@@ -827,6 +823,21 @@ mod tests {
canonical(&snbt(s)).expect("known type")
}
#[test]
fn a_trial_spawner_without_spawn_data_saves_none() {
let fresh = canon(
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,normal_config:"minecraft:trial_chamber/ranged/skeleton/normal"}"#,
);
assert_eq!(fresh.get("spawn_data"), None);
let picked = canon(
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,spawn_data:{entity:{id:"minecraft:skeleton"}}}"#,
);
assert_eq!(
picked.get("spawn_data"),
Some(&snbt(r#"{entity:{id:"minecraft:skeleton"}}"#))
);
}
#[test]
fn every_type_has_defaults() {
for id in TYPES.iter().chain(NEWEST.iter()) {
@@ -558,12 +558,11 @@ fn frac(x: f32) -> f32 {
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
/// wrapped at the last space that fits or mid-word when none does.
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
let chars: Vec<char> = line.chars().collect();
let mut width = 0.0;
let mut last_space = None;
for (i, &c) in chars.iter().enumerate() {
for (i, c) in line.char_indices() {
if c == '\n' {
return chars[..i].iter().collect();
return line[..i].to_owned();
}
if c == ' ' {
last_space = Some(i);
@@ -572,9 +571,11 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
if width > max_width {
let end = match last_space {
Some(s) if s > 0 => s,
_ => i.max(1),
_ if i > 0 => i,
// The first char alone is too wide: take that one char.
_ => c.len_utf8(),
};
return chars[..end].iter().collect();
return line[..end].to_owned();
}
}
line.to_owned()
@@ -730,6 +731,10 @@ pub struct Scene<'a> {
pub sign: &'a dyn Fn([i32; 3]) -> Option<&'a crate::signs::SignData>,
/// Banner layers at a position: pattern texture and RGB tint, bottom first.
pub banner: &'a dyn Fn([i32; 3]) -> Vec<(String, u32)>,
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
}
@@ -1279,6 +1284,13 @@ impl BlockEntityRenderer {
SkullType::Piglin => ("piglin_head", "entity/piglin/piglin"),
SkullType::Player => ("player_head", "entity/player/slim/steve"),
};
// SkullBlockRenderer.getRenderType: the profile's skin when it
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
let texture = if *kind == SkullType::Player {
(scene.skull)(pos).unwrap_or(texture)
} else {
texture
};
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
@@ -1886,6 +1898,7 @@ mod tests {
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
};
// Compare the cached world path with the original model renderer,
@@ -2086,4 +2099,13 @@ mod tests {
assert_eq!(out[1].pos, [2.0, 0.0, 0.0]);
assert_eq!(out[2].uv, [0.5, 0.5]);
}
#[test]
fn first_line_wraps_on_bytes_without_splitting_a_char() {
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
}
}
+203
View File
@@ -166,6 +166,38 @@ impl Contents {
Some(stack)
}
/// The stacks inside, in display order (the first is the next out).
pub fn items(&self) -> &[ItemStack] {
&self.items
}
/// `getNumberOfItemsToShow`: a full grid shows 11 and a `+N`, so the last
/// row keeps its fourth cell for the count.
pub fn shown_count(&self) -> usize {
let count = self.items.len();
let available: usize = if count > 12 { 11 } else { 12 };
let on_last_row = count % 4;
let empty = if on_last_row == 0 { 0 } else { 4 - on_last_row };
count.min(available.saturating_sub(empty))
}
/// The items the tooltip shows, and how many sit beyond them.
pub fn shown(&self) -> (&[ItemStack], u32) {
let n = self.shown_count();
let hidden = self.items[n..].iter().map(|s| s.count).sum();
(&self.items[..n], hidden)
}
/// The `Fraction` weight as the float the progress bar reads.
pub fn weight_fraction(&self) -> f32 {
self.weight.n as f32 / self.weight.d as f32
}
/// `Fraction.ONE`: a full bundle draws the full bar and says so.
pub fn is_full(&self) -> bool {
self.weight.n >= self.weight.d
}
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
bundle.data_mut().bundle_contents = self.items;
@@ -190,3 +222,174 @@ mod tests {
assert_eq!(over.how_many(Weight::new(1, 64)), 0);
}
}
// ------------------------------------------------------------- the tooltip
/// `ClientBundleTooltip`'s geometry, relative to the tooltip box's top-left:
/// the 96 pixel column of a 4 wide slot grid and the progress bar under it.
#[derive(Clone, Debug, PartialEq)]
pub struct TooltipLayout {
/// The component's own width (96) and height, the text's excluded.
pub width: f32,
pub height: f32,
/// Each slot's top-left and the stack in it, in reading order.
pub slots: Vec<([f32; 2], ItemStack)>,
/// The `+N` of the items past the grid: its top-left and the count.
pub overflow: Option<([f32; 2], u32)>,
/// The progress bar's top-left, its fill's width out of 94, and whether
/// the weight fills it (the full sprite).
pub bar: [f32; 2],
pub fill: f32,
pub full: bool,
/// `item.minecraft.bundle.empty` / `.full`, centred over the bar.
pub label: Option<&'static str>,
/// An empty bundle's description, wrapped at `width` from here.
pub empty_description: Option<[f32; 2]>,
}
/// The tooltip of a bundle's contents (`ClientBundleTooltip`), or None when
/// the stack is not a bundle. `x`/`y` are the box's top-left, `box_width` the
/// tooltip's width, which the component is centred in.
pub fn tooltip_layout(
items: &Items,
stack: &ItemStack,
x: f32,
y: f32,
box_width: f32,
) -> Option<TooltipLayout> {
let contents = Contents::of(items, stack)?;
let offset = ((box_width - 96.0) / 2.0).floor();
if contents.items().is_empty() {
// The description takes the top; the bar follows it.
let description_height = 9.0 * 2.0;
return Some(TooltipLayout {
width: 96.0,
height: description_height + 13.0 + 8.0,
slots: Vec::new(),
overflow: None,
bar: [x + offset, y + description_height + 4.0],
fill: 0.0,
full: false,
label: Some("item.minecraft.bundle.empty"),
empty_description: Some([x + offset, y]),
});
}
let (shown, hidden) = contents.shown();
// `gridSizeY` counts `slotCount()` (at most twelve), not the slots the
// overflow count leaves room for.
let rows = contents.items().len().min(12).div_ceil(4).max(1);
let grid_height = rows as f32 * 24.0;
let x_start = x + offset + 96.0;
let y_start = y + grid_height;
let mut slots = Vec::with_capacity(shown.len());
let mut overflow = None;
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
let mut slot = 0;
for row in 1..=rows {
for column in 1..=4 {
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
if hidden > 0 && row == 1 && column == 1 {
overflow = Some((at, hidden));
} else if let Some(stack) = shown.get(slot) {
slots.push((at, stack.clone()));
slot += 1;
}
}
}
let weight = contents.weight_fraction();
Some(TooltipLayout {
width: 96.0,
height: grid_height + 13.0 + 8.0,
slots,
overflow,
bar: [x + offset, y + grid_height + 4.0],
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
fill: (weight * 94.0).trunc().clamp(0.0, 94.0),
full: contents.is_full(),
label: if contents.is_full() {
Some("item.minecraft.bundle.full")
} else {
None
},
empty_description: None,
})
}
#[cfg(test)]
mod tooltip_tests {
use super::*;
/// The prepared resources, when they are on this machine: the tooltip
/// asks the item registry whether a stack is a bundle.
fn items() -> Option<Items> {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return None;
}
let assets = crate::assets::Assets::load(&root).ok()?;
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?;
Items::load(&root, assets.registry, &facts).ok()
}
fn bundle_with(counts: &[(&str, u32)]) -> ItemStack {
let mut bundle = ItemStack::new("minecraft:bundle", 1);
bundle.data_mut().bundle_contents = counts
.iter()
.map(|(item, n)| ItemStack::new(item, *n))
.collect();
bundle
}
#[test]
fn a_grid_shows_twelve_stacks_and_counts_the_rest() {
let Some(items) = items() else { return };
let twelve: Vec<(&str, u32)> = (0..12).map(|_| ("minecraft:stone", 1)).collect();
let layout = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 96.0).unwrap();
assert_eq!(layout.slots.len(), 12);
assert_eq!(layout.overflow, None);
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
// Rows fill bottom-up and columns right-to-left: the first item is in
// the bottom-right cell, the twelfth in the top-left.
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
// A wider tooltip centres the 96 pixel column in it.
let centred = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 120.0).unwrap();
assert_eq!(centred.slots[0].0[0], 12.0 + 96.0 - 24.0);
// Thirteen stacks: `getNumberOfItemsToShow` leaves the last row room
// for the count, so 8 slots show and `+6` (four stones and two dirt)
// takes the first cell.
let mut counts = twelve;
counts.push(("minecraft:dirt", 2));
let layout = tooltip_layout(&items, &bundle_with(&counts), 0.0, 0.0, 96.0).unwrap();
assert_eq!(layout.slots.len(), 8);
assert_eq!(layout.overflow, Some(([96.0 - 24.0, 48.0], 6)));
}
#[test]
fn the_bar_fills_with_the_weight_and_an_empty_bundle_explains_itself() {
let Some(items) = items() else { return };
let layout = tooltip_layout(&items, &bundle_with(&[]), 4.0, 8.0, 96.0).unwrap();
assert!(layout.slots.is_empty());
assert_eq!(layout.fill, 0.0);
assert_eq!(layout.label, Some("item.minecraft.bundle.empty"));
assert_eq!(layout.empty_description, Some([4.0, 8.0]));
assert_eq!(layout.bar, [4.0, 8.0 + 18.0 + 4.0]);
// A bundle holding one 64 stack is full: the whole bar, the full label.
let layout = tooltip_layout(
&items,
&bundle_with(&[("minecraft:stone", 64)]),
0.0,
0.0,
96.0,
)
.unwrap();
assert!(layout.full);
assert_eq!(layout.fill, 94.0);
assert_eq!(layout.label, Some("item.minecraft.bundle.full"));
// Something that is not a bundle has no component.
let dirt = ItemStack::new("minecraft:dirt", 1);
assert!(tooltip_layout(&items, &dirt, 0.0, 0.0, 96.0).is_none());
}
}
+34
View File
@@ -87,6 +87,19 @@ fn filled(player: &mut dyn Player, new: ItemStack, limit_creative: bool) {
}
}
/// `LayeredCauldronBlock.handleEntityOnFireInside`: a burning entity in a
/// water cauldron lowers it a level; a powder snow cauldron becomes a water
/// cauldron of its level first, then lowers.
pub fn extinguished(cauldron: &BlockState) -> BlockState {
if cauldron.name == "minecraft:powder_snow_cauldron" {
let mut water = cauldron.clone();
water.name = "minecraft:water_cauldron".into();
lowered(&water)
} else {
lowered(cauldron)
}
}
/// `LayeredCauldronBlock.lowerFillLevel`.
fn lowered(cauldron: &BlockState) -> BlockState {
let level: u8 = cauldron
@@ -296,3 +309,24 @@ pub fn interact(
_ => pass(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_burning_entity_lowers_water_and_melts_powder_snow() {
let water = |l| state("minecraft:water_cauldron", Some(l));
assert_eq!(extinguished(&water(3)), water(2));
assert_eq!(extinguished(&water(1)), state("minecraft:cauldron", None));
// Powder snow turns to water of its level, then lowers.
assert_eq!(
extinguished(&state("minecraft:powder_snow_cauldron", Some(3))),
water(2)
);
assert_eq!(
extinguished(&state("minecraft:powder_snow_cauldron", Some(1))),
state("minecraft:cauldron", None)
);
}
}
+167 -10
View File
@@ -19,6 +19,92 @@ mod tests;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChatVisibility {
Full,
System,
Hidden,
}
impl ChatVisibility {
/// Options.chatVisibility's saved value (0 full, 1 system, 2 hidden).
pub fn from_option(value: u8) -> Self {
match value {
0 => Self::Full,
1 => Self::System,
_ => Self::Hidden,
}
}
}
/// `ChatAbilities`: what the Chat option allows, as `Minecraft.computeChatAbilities`
/// derives it from the visibility alone (`ChatRestriction`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ChatAbilities {
pub send_messages: bool,
pub send_commands: bool,
pub receive_player_messages: bool,
pub receive_system_messages: bool,
}
impl ChatAbilities {
/// `ChatRestriction.CHAT_AND_COMMANDS_DISABLED_BY_OPTIONS` (Hidden) takes
/// chat, commands, player messages and system messages;
/// `CHAT_DISABLED_BY_OPTIONS` (System) takes the chat and player messages.
pub fn new(visibility: ChatVisibility) -> Self {
match visibility {
ChatVisibility::Full => Self {
send_messages: true,
send_commands: true,
receive_player_messages: true,
receive_system_messages: true,
},
ChatVisibility::System => Self {
send_messages: false,
send_commands: true,
receive_player_messages: false,
receive_system_messages: true,
},
ChatVisibility::Hidden => Self {
send_messages: false,
send_commands: false,
receive_player_messages: false,
receive_system_messages: false,
},
}
}
/// `visibleMessagesFilter`: the client's own lines always show.
pub fn shows(&self, source: ChatSource) -> bool {
match source {
ChatSource::Player => self.receive_player_messages,
ChatSource::Server => self.receive_system_messages,
ChatSource::Client => true,
}
}
/// Whether the chat screen may send `input` (`hasAllowedInput`).
pub fn allows(&self, input: &str) -> bool {
if input.starts_with('/') {
self.send_commands
} else {
self.send_messages
}
}
}
impl Default for ChatAbilities {
fn default() -> Self {
Self::new(ChatVisibility::Full)
}
}
/// Where a chat line came from (`GuiMessageSource`): the visibility filters
/// player messages and the server's, while the client's own always show.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChatSource {
Player,
Server,
Client,
}
/// The chat options (doubles, as vanilla keeps them).
#[derive(Clone, Copy, Debug)]
pub struct ChatOptions {
@@ -71,6 +157,7 @@ struct Message {
added: u64,
content: Text,
indicator: Option<u32>,
source: ChatSource,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
@@ -105,6 +192,8 @@ pub struct Chat {
pub log: bool,
/// Messages added since the start, for the control socket's events.
pub received: u64,
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
abilities: ChatAbilities,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -114,16 +203,32 @@ impl Chat {
fn max_width(&self) -> i32 {
(f64::from(self.options.width_px()) / self.options.scale).floor() as i32
}
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines.
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines. A
/// message the Chat option hides is dropped, unlogged.
pub fn add(
&mut self,
atlas: &HudAtlas,
content: Text,
indicator: Option<u32>,
source: ChatSource,
ticks: u64,
focused: bool,
) {
// ChatComponent.logChatMessage: every message is in the log.
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
}
fn add_message(
&mut self,
font: &crate::font::Font,
content: Text,
indicator: Option<u32>,
source: ChatSource,
ticks: u64,
focused: bool,
) {
if !self.abilities.shows(source) {
return;
}
// ChatComponent.logChatMessage: every message shown is in the log.
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
if self.log {
println!("[CHAT] {}", content.plain());
@@ -132,16 +237,17 @@ impl Chat {
added: ticks,
content,
indicator,
source,
};
self.add_lines(atlas, &message, focused);
self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
self.received += 1;
}
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
let width = self.max_width();
let content = self.displayed(&message.content);
for line in atlas.font.wrap_components(&content, width) {
for line in font.wrap_components(&content, width) {
if focused && self.scroll > 0 {
self.new_since_scroll = true;
self.scroll_by(1, focused);
@@ -167,16 +273,38 @@ impl Chat {
content.strip_legacy()
}
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat).
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
self.rewrap(&atlas.font);
}
fn rewrap(&mut self, font: &crate::font::Font) {
self.scroll = 0;
self.new_since_scroll = false;
self.lines.clear();
let messages: Vec<Message> = self.messages.iter().rev().cloned().collect();
let messages: Vec<Message> = self
.messages
.iter()
.rev()
.filter(|m| self.abilities.shows(m.source))
.cloned()
.collect();
for m in &messages {
self.add_lines(atlas, m, false);
self.add_lines(font, m, false);
}
}
/// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
/// the messages it now hides leave the chat, those it shows again return.
pub fn set_abilities(&mut self, font: &crate::font::Font, abilities: ChatAbilities) {
if self.abilities != abilities {
self.abilities = abilities;
self.rewrap(font);
}
}
/// Whether a message from `source` would show (`visibleMessagesFilter`).
pub fn shows(&self, source: ChatSource) -> bool {
self.abilities.shows(source)
}
pub fn clear(&mut self) {
self.messages.clear();
self.lines.clear();
@@ -437,6 +565,8 @@ pub struct ChatScreen {
allow_suggestions: bool,
keep_suggestions: bool,
pub auto_suggestions: bool,
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
pub abilities: ChatAbilities,
}
/// Local completions waiting for the server's reply to request `id`.
struct Awaiting {
@@ -482,6 +612,7 @@ impl ChatScreen {
history_len: usize,
width: i32,
height: i32,
abilities: ChatAbilities,
) -> Self {
let mut input = TextField::new(4, height - 12, width - 4, 12);
input.max_length = 256;
@@ -505,6 +636,7 @@ impl ChatScreen {
allow_suggestions: false,
keep_suggestions: false,
auto_suggestions: true,
abilities,
};
s.update_command_info(m, c);
s
@@ -522,7 +654,8 @@ impl ChatScreen {
if (width, height) != (self.width, self.height) {
let value = self.input.value.clone();
let auto = self.auto_suggestions;
*self = Self::new(m, c, &value, self.history_pos, width, height);
let abilities = self.abilities;
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
self.auto_suggestions = auto;
}
}
@@ -604,6 +737,14 @@ impl ChatScreen {
self.pending = Some((range.0, list));
self.usage_width = 0;
self.usage_position = 0;
// CommandSuggestions.updateCommandInfo: the Chat option may take
// the messages.
if !self.abilities.send_messages {
let line =
Text::translate("chat_screen.messages_not_allowed", &[]).with_color(text::RED);
self.usage_width = m.width(&line);
self.usage.push(line);
}
} else {
self.awaiting = None;
self.pending = None;
@@ -708,6 +849,15 @@ impl ChatScreen {
}
self.usage.extend(entries);
}
// CommandSuggestions.updateUsageInfo: the Chat option may take the
// commands, under the usage. (Vanilla also counts a command with a
// message argument, `/say hi`, as a message; Horizon's parse keeps no
// argument kinds, so only a bare message does.)
if !self.abilities.send_commands {
self.usage.push(
Text::translate("chat_screen.commands_not_allowed", &[]).with_color(text::RED),
);
}
self.usage_width = self.usage.iter().map(|u| m.width(u)).max().unwrap_or(0);
self.usage_position = if self.usage.is_empty() {
0
@@ -842,7 +992,14 @@ impl ChatScreen {
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => return ChatAction::Submit(self.normalized()),
K::Enter | K::NumpadEnter => {
// ChatScreen.keyPressed: without `hasAllowedInput` the input
// stays, unsent, and the screen stays open.
if !self.abilities.allows(&self.input.value) {
return ChatAction::None;
}
return ChatAction::Submit(self.normalized());
}
K::ArrowUp => self.move_in_history(m, c, history, -1),
K::ArrowDown => self.move_in_history(m, c, history, 1),
_ => {
+145 -5
View File
@@ -2,6 +2,11 @@ use super::*;
use crate::{font::Font, hud::Rect};
use std::{collections::HashMap, path::Path};
/// The Chat option's default: no restriction.
fn full() -> ChatAbilities {
ChatAbilities::new(ChatVisibility::Full)
}
fn font() -> Font {
// The missing-glyph metrics let these tests run without game assets.
Font::load(
@@ -139,7 +144,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
assert_eq!(link.component()["click_event"]["command"], expected);
let mut chat = Chat::default();
add(&mut chat, &font, Text::literal("Found at ").append(link));
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360);
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
let x = 4.0 + f64::from(font.width("Found at "));
assert!(!screen.click(&m, &commands, x, 315.0));
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
@@ -176,7 +181,15 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
registries: &registries,
};
let history = vec!["first message".to_owned(), "latest message".to_owned()];
let mut screen = ChatScreen::new(&m, &commands, "unfinished draft", history.len(), 640, 360);
let mut screen = ChatScreen::new(
&m,
&commands,
"unfinished draft",
history.len(),
640,
360,
full(),
);
for (key, expected) in [
(ArrowUp, "latest message"),
(ArrowUp, "first message"),
@@ -213,7 +226,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
dispatcher,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, line);
screen
};
@@ -259,7 +272,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
let tab = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
let (request, sent) = screen.take_request().expect("the server is asked");
assert_eq!(sent, "/bossbar get pt");
@@ -281,7 +294,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
dispatcher: local,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
assert_eq!(screen.take_request(), None);
}
@@ -327,3 +340,130 @@ fn a_server_component_keeps_its_click_and_hover_events() {
let past = 4.0 + f64::from(font.width("CLICK")) + 20.0;
assert!(chat.hover_at(&font, 360, past, 315.0).is_none());
}
/// `ChatVisiblity`: Commands Only keeps the chat out, Hidden takes the
/// commands too (`ChatAbilities`), and Enter sends nothing the option hides.
#[test]
fn the_chat_option_decides_what_may_be_sent() {
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let enter = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Enter, false, false)
};
for (visibility, chat, command) in [
(ChatVisibility::Full, true, true),
(ChatVisibility::System, false, true),
(ChatVisibility::Hidden, false, false),
] {
let abilities = ChatAbilities::new(visibility);
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
screen.type_text(&m, &c, line);
match enter(&mut screen) {
ChatAction::Submit(sent) => {
assert!(allowed, "{visibility:?} sent {sent}");
assert_eq!(sent, line);
}
ChatAction::None => assert!(!allowed, "{visibility:?} kept {line}"),
ChatAction::Close => panic!("Enter does not close the chat"),
}
}
}
}
/// The restriction the option sets is a usage line: red, under the input, for
/// a command the Chat option takes and for a message it takes.
#[test]
fn restricted_input_says_why_it_will_not_send() {
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let usage = |visibility, line: &str| {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
screen.type_text(&m, &c, line);
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
};
assert_eq!(usage(ChatVisibility::Full, "hi"), Vec::<String>::new());
// The font has no language files here, so the line is its key.
assert_eq!(
usage(ChatVisibility::System, "hi"),
["chat_screen.messages_not_allowed"]
);
// A command the option allows says nothing about the restriction.
assert!(
!usage(ChatVisibility::System, "/time set day")
.iter()
.any(|line| line == "chat_screen.commands_not_allowed")
);
assert!(
usage(ChatVisibility::Hidden, "/time set day")
.iter()
.any(|line| line == "chat_screen.commands_not_allowed")
);
}
/// `ChatComponent.setVisibleMessageFilter`: a message the Chat option hides
/// on arrival is dropped; one already in the chat leaves it while the option
/// hides its kind and comes back when it shows it again.
#[test]
fn the_chat_option_filters_the_lines_both_ways() {
let font = font();
let mut chat = Chat::default();
let says = |chat: &mut Chat, text: &str, source| {
chat.add_message(&font, Text::literal(text), None, source, 0, false);
};
let shown =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
says(&mut chat, "player", ChatSource::Player);
says(&mut chat, "server", ChatSource::Server);
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
assert_eq!(shown(&chat), ["server"]);
says(&mut chat, "dropped", ChatSource::Player);
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Hidden));
says(&mut chat, "client", ChatSource::Client);
assert_eq!(shown(&chat), ["client"]);
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
/// The in-frame LAN host's 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.
#[test]
fn a_guests_line_follows_the_player_message_permission() {
let font = font();
let shown = |visibility, source| {
let mut chat = Chat::default();
chat.set_abilities(&font, ChatAbilities::new(visibility));
chat.add_message(&font, Text::literal("line"), None, source, 0, false);
chat.messages.len()
};
for (visibility, player, system) in [
(ChatVisibility::Full, 1, 1),
(ChatVisibility::System, 0, 1),
(ChatVisibility::Hidden, 0, 0),
] {
assert_eq!(
shown(visibility, ChatSource::Player),
player,
"{visibility:?}"
);
assert_eq!(
shown(visibility, ChatSource::Server),
system,
"{visibility:?}"
);
}
}
+18
View File
@@ -24,6 +24,20 @@ pub fn block_strategy() -> Strategy {
Strategy::blocks(BLOCK_BITS.load(std::sync::atomic::Ordering::Relaxed))
}
/// Frees `columns` (`count` of them) on a short-lived thread of its own
/// when there are many: a teleport or a dimension change drops a few
/// thousand columns at once, and freeing their sections took the frame
/// 15-20 ms. A few are freed here.
pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
if count < 64 {
return;
}
// Should the thread not start, the closure drops `columns` here.
let _ = std::thread::Builder::new()
.name("Chunk freeing".into())
.spawn(move || drop(columns));
}
/// Sizes the block states' global palette for a registry of `states`.
pub fn set_block_states(states: usize) {
let bits = horizon_core::paletted::ceil_log2(states).max(16);
@@ -147,6 +161,9 @@ pub struct Chunk {
/// Structure starts reaching into this chunk (`structures.References`):
/// structure id and the start's chunk.
pub structure_refs: Vec<(Arc<str>, (i32, i32))>,
/// The starts generation made in this chunk (`structures.starts`), saved
/// with the state their placement has reached by then.
pub structure_starts: Vec<Arc<horizon_world::worldgen::structure::start::StructureStart>>,
}
impl Chunk {
pub fn empty(x: i32, z: i32) -> Self {
@@ -164,6 +181,7 @@ impl Chunk {
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
structure_starts: Vec::new(),
postprocess: Vec::new(),
saved: None,
entities: Vec::new(),
+5
View File
@@ -346,6 +346,11 @@ mod tests {
assert_eq!(filter_text(s, true), "abc\nde\u{80}");
assert_eq!(strip_formatting("§aRed§r §zx§"), "Red §zx§");
assert_eq!(helper_clipboard_contents("§lone\r\ntwo"), "one\ntwo");
// TextFieldHelper.getClipboardContents runs before insertText's filter.
assert_eq!(
filter_text(&helper_clipboard_contents("§aHello"), false),
"Hello"
);
}
struct Field {
+18
View File
@@ -92,6 +92,13 @@ impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
) -> std::result::Result<bool, Self::Error> {
self.blocks.is_suffocating(chunk, block, p)
}
fn may_reach_out(
&mut self,
chunk: Self::Chunk,
section_y: i32,
) -> std::result::Result<bool, Self::Error> {
self.blocks.may_reach_out(chunk, section_y)
}
fn context_shape(
&mut self,
block: CollisionBlock,
@@ -262,6 +269,17 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
fn collision_block(&mut self, chunk: Self::Chunk, p: BlockPos) -> io::Result<CollisionBlock> {
self.binding.collision_block(chunk.block(p.x, p.y, p.z))
}
/// 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 - (crate::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)))
}))
}
fn is_suffocating(
&mut self,
_: Self::Chunk,
@@ -592,3 +592,21 @@ fn mod_commands_join_the_tree_for_every_player() {
// The game's own tree is left as it was.
assert!(vanilla::game_dispatcher().find("where").is_none());
}
#[test]
fn horizon_commands_are_off_unless_the_server_turns_them_on() {
// By default the game runs vanilla's tree: /help lists no /pregen.
assert!(vanilla::game_dispatcher().find("pregen").is_none());
for dev in [false, true] {
let vanilla_tree = vanilla::game_tree(dev, false);
assert!(vanilla_tree.find("pregen").is_none());
let d = vanilla::game_tree(dev, true);
let parse = d.parse("pregen start 100 circle", &reg());
assert_eq!(parse.executable().unwrap().1, "pregen.start");
// Vanilla's commands are the same with or without it.
assert_eq!(
d.nodes[0].children.len(),
vanilla_tree.nodes[0].children.len() + 1
);
}
}
+62 -5
View File
@@ -538,12 +538,69 @@ fn apply_vanilla_order(d: &mut Dispatcher) {
}
/// The tree the game runs: a release integrated server's, or with
/// `HORIZON_DEV_COMMANDS` set the development one
/// (SharedConstants.DEBUG_DEV_COMMANDS).
/// (SharedConstants.DEBUG_DEV_COMMANDS), plus Horizon's own commands when
/// the server turned them on (`set_horizon_commands`).
pub fn game_dispatcher() -> &'static Dispatcher {
if std::env::var_os("HORIZON_DEV_COMMANDS").is_some() {
dispatcher()
} else {
release_dispatcher()
game_tree(
std::env::var_os("HORIZON_DEV_COMMANDS").is_some(),
HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
)
}
/// Whether this process's server has Horizon's own commands (`/pregen`). Off
/// by default, so `/help` and the command tree are vanilla's; a dedicated
/// server turns them on with `horizon-pregen=true` in server.properties, the
/// game with `--pregen` (docs/pregeneration.md).
static HORIZON_COMMANDS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Turns Horizon's own commands on or off for the trees `game_dispatcher`
/// returns from now on.
pub fn set_horizon_commands(on: bool) {
HORIZON_COMMANDS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// The server.properties key that turns `/pregen` on (Horizon's own; vanilla
/// ignores keys it doesn't know).
pub const PREGEN_PROPERTY: &str = "horizon-pregen";
pub(crate) fn game_tree(dev: bool, horizon: bool) -> &'static Dispatcher {
static HORIZON: [std::sync::OnceLock<Dispatcher>; 2] =
[const { std::sync::OnceLock::new() }; 2];
match (dev, horizon) {
(true, false) => dispatcher(),
(false, false) => release_dispatcher(),
(dev, true) => HORIZON[usize::from(dev)].get_or_init(|| {
let mut d = build_with(dev);
horizon_commands(&mut d, 0);
d
}),
}
}
/// Commands vanilla doesn't have, after its own: `/pregen`
/// (docs/pregeneration.md).
fn horizon_commands(d: &mut Dispatcher, root: usize) {
let pregen = d.literal(root, "pregen");
d.requires(pregen, GAMEMASTERS);
let start = d.literal(pregen, "start");
let radius = d.argument(
start,
"radius",
ArgType::Integer {
min: 0,
max: 30_000_000,
},
);
d.executes(radius, "pregen.start");
for shape in ["square", "circle"] {
let shape = d.literal(radius, shape);
d.executes(shape, "pregen.start");
let center = d.argument(shape, "center", ArgType::ColumnPos);
d.executes(center, "pregen.start");
}
for (word, id) in [
("pause", "pregen.pause"),
("continue", "pregen.continue"),
("cancel", "pregen.cancel"),
("status", "pregen.status"),
] {
let w = d.literal(pregen, word);
d.executes(w, id);
}
}
pub fn dispatcher() -> &'static Dispatcher {
@@ -275,4 +275,30 @@ mod tests {
Release::Click(Input::QuickMove)
);
}
/// A press whose release never arrives (the pointer left the window, or
/// the screen closed first) leaves `isQuickCrafting` set; the next screen
/// starts with its own state, as `AbstractContainerScreen` does, so the
/// reset the game runs on open/close makes clicks work again.
#[test]
fn a_lost_release_does_not_wedge_the_next_screen() {
let mut clicks = Clicks::default();
let slot = Target::<usize>::Slot(3);
// Pressed with a stack carried: a quick craft drag starts, and its
// release is lost.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert!(clicks.quick_crafting().is_some());
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
None,
"the stuck drag swallows the next press"
);
// A new screen: fresh state, so the press is a normal click again.
clicks = Clicks::default();
assert!(clicks.quick_crafting().is_none());
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
Some(Input::Pickup)
);
}
}
+232 -5
View File
@@ -44,6 +44,21 @@ pub struct Furnace {
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
/// ("Duplicate entry for key").
pub fn recipe_used_key(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
@@ -79,6 +94,117 @@ pub enum BlockEntity {
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
let Some(slot) = self.items.iter().position(Option::is_none) else {
return false;
};
self.total[slot] = cooking_time;
self.progress[slot] = 0;
self.items[slot] = Some(ItemStack {
count: 1,
..held.clone()
});
held.count = held.count.saturating_sub(1);
true
}
/// `cookTick` (lit): each item's progress rises by one; the slots whose
/// progress reached their cooking time give up their item (the caller
/// drops its result).
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
let mut done = Vec::new();
for i in 0..4 {
if self.items[i].is_none() {
continue;
}
self.progress[i] += 1;
if self.progress[i] >= self.total[i]
&& let Some(item) = self.items[i].take()
{
done.push((i, item));
}
}
done
}
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
/// Whether anything changed (`setChanged`).
pub fn cooldown_tick(&mut self) -> bool {
let mut changed = false;
for i in 0..4 {
if self.progress[i] > 0 {
changed = true;
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
}
}
changed
}
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
impl Skull {
pub fn new(
profile: Option<horizon_world::nbt::Tag>,
note_block_sound: Option<String>,
custom_name: Option<horizon_world::nbt::Tag>,
) -> Self {
let skin = profile.as_ref().map(SkullSkin::of);
Self {
profile,
note_block_sound,
custom_name,
skin,
}
}
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
pub fallback: &'static str,
}
impl SkullSkin {
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
Some((url, location))
});
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
let fallback = crate::skins::profile_uuid_nbt(profile)
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
Self { download, fallback }
}
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
/// `*_skull` and `*_head` but the piston's head.
fn is_skull(name: &str) -> bool {
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
@@ -190,6 +316,7 @@ impl BlockEntity {
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
recipes_used: HashMap::new(),
}));
}
if name == "minecraft:creaking_heart" {
@@ -233,6 +360,9 @@ impl BlockEntity {
if name.ends_with("_banner") {
return Some(Self::Banner(Banner::default()));
}
if is_skull(name) {
return Some(Self::Skull(Skull::default()));
}
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
@@ -242,6 +372,9 @@ impl BlockEntity {
if name == "minecraft:brewing_stand" {
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
}
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -278,6 +411,8 @@ impl BlockEntity {
Self::Vault(_) => "minecraft:vault",
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::Campfire(_) => "minecraft:campfire",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -293,11 +428,13 @@ impl BlockEntity {
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => Vec::new(),
| Self::CommandBlock(_)
| Self::Skull(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -310,14 +447,17 @@ impl BlockEntity {
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_) => &[],
| Self::Banner(_)
| Self::Skull(_) => &[],
Self::Crafter(c) => &c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &[],
| Self::CommandBlock(_)
// Not a Container (only Clearable): hoppers don't reach in.
| Self::Campfire(_) => &[],
}
}
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
@@ -330,14 +470,16 @@ impl BlockEntity {
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_) => &mut [],
| Self::Banner(_)
| Self::Skull(_) => &mut [],
Self::Crafter(c) => &mut c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &mut [],
| Self::CommandBlock(_)
| Self::Campfire(_) => &mut [],
}
}
}
@@ -483,6 +625,14 @@ impl Furnace {
self.cooking_timer = 0;
}
}
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
@@ -564,6 +714,10 @@ impl Furnace {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
@@ -594,3 +748,76 @@ fn total_time(time: u32, speed: f32) -> i32 {
time as i32
}
}
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
/// more with the chance of what is left over (a level random draw only when there is some).
pub fn create_experience(
count: u32,
experience: f32,
random: &mut impl horizon_core::random::RandomSource,
) -> u32 {
let total = count as f32 * experience;
let whole = total.floor();
let fraction = total - whole;
let mut xp = whole.max(0.0) as u32;
if fraction != 0.0 && random.next_float() < fraction {
xp += 1;
}
xp
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
let mut beef = ItemStack::new("minecraft:beef", 2);
// placeFood: the first free slot, one item, its recipe's time.
assert!(c.place_food(&mut beef, 600));
assert_eq!(beef.count, 1);
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
assert_eq!((c.progress[0], c.total[0]), (0, 600));
let mut fish = ItemStack::new("minecraft:cod", 1);
assert!(c.place_food(&mut fish, 3));
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
// cookTick: the cod is done on its third tick, the beef keeps cooking.
assert!(c.cook_tick().is_empty());
assert!(c.cook_tick().is_empty());
let done = c.cook_tick();
assert_eq!(done.len(), 1);
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
assert!(c.items[1].is_none());
assert_eq!(c.progress[0], 3);
// cooldownTick: two a tick, never below zero.
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 1);
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 0);
// Full: four slots, no fifth.
for _ in 0..3 {
let mut more = ItemStack::new("minecraft:potato", 1);
assert!(c.place_food(&mut more, 600));
}
let mut fifth = ItemStack::new("minecraft:potato", 1);
assert!(!c.place_food(&mut fifth, 600));
assert_eq!(fifth.count, 1);
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
let mut random = LegacyRandom::new(7);
let mut untouched = LegacyRandom::new(7);
assert_eq!(create_experience(4, 0.5, &mut random), 2);
assert_eq!(create_experience(3, 0.0, &mut random), 0);
assert_eq!(random.next_long(), untouched.next_long());
let paid: u32 = (0..1000)
.map(|_| create_experience(1, 0.35, &mut random))
.sum();
assert!((250..450).contains(&paid), "{paid}");
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
}
}
+174
View File
@@ -0,0 +1,174 @@
//! `Player.getCooldowns()` (`ItemCooldowns`) as the game keeps it: the
//! `use_cooldown` component's entries by cooldown group, counted down once a
//! tick, and the percent `GuiGraphicsExtractor.itemCooldown` shades a slot
//! by. The world crate's `item_cooldowns` models vanilla's hash map and its
//! observers for the parity harness; this is the same data with the lookup a
//! slot needs, and no observer (vanilla's own player uses the plain class).
use crate::items::ItemStack;
use horizon_world::nbt::Tag;
use std::collections::HashMap;
use std::sync::Arc;
/// A stack's `minecraft:use_cooldown` component.
#[derive(Clone, Debug, PartialEq)]
pub struct UseCooldown {
pub seconds: f32,
/// `cooldown_group`: the group the entry is filed under, else the item.
pub group: Option<Arc<str>>,
}
impl UseCooldown {
/// `UseCooldown.ticks`: the seconds in ticks, truncated.
pub fn ticks(&self) -> i32 {
(self.seconds * 20.0) as i32
}
/// The component as `items.json` holds it.
pub fn from_json(v: &serde_json::Value) -> Option<Self> {
let seconds = v.get("seconds")?.as_f64()? as f32;
Some(Self {
seconds,
group: v
.get("cooldown_group")
.and_then(|g| g.as_str())
.map(Arc::from),
})
}
/// The component as a stack's saved `components` holds it.
pub fn from_tag(t: &Tag) -> Option<Self> {
let Tag::Compound(c) = t else {
return None;
};
let text = |k: &str| match c.get(&horizon_world::nbt::JavaString::from(k)) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
let seconds = match c.get(&horizon_world::nbt::JavaString::from("seconds")) {
Some(Tag::Float(bits)) => f32::from_bits(*bits),
Some(Tag::Double(bits)) => f64::from_bits(*bits) as f32,
_ => return None,
};
Some(Self {
seconds,
group: text("cooldown_group").map(Arc::from),
})
}
}
/// The component a stack carries: its own patch first (`ItemStack.get`), then
/// the item's default from `items.json` (`ItemInfo::use_cooldown`).
pub fn component(stack: &ItemStack, default: Option<&UseCooldown>) -> Option<UseCooldown> {
stack
.data()
.and_then(|d| d.other("minecraft:use_cooldown"))
.and_then(UseCooldown::from_tag)
.or_else(|| default.cloned())
}
/// `ItemCooldowns.getCooldownGroup`: the component's group, else the item id.
pub fn group(stack: &ItemStack, default: Option<&UseCooldown>) -> Arc<str> {
component(stack, default)
.and_then(|c| c.group)
.unwrap_or_else(|| Arc::clone(&stack.item))
}
/// `ItemCooldowns`: start and end ticks by group, and the tick counter.
#[derive(Clone, Debug, Default)]
pub struct Cooldowns {
tick_count: i32,
entries: HashMap<Arc<str>, (i32, i32)>,
}
impl Cooldowns {
/// `getCooldownPercent`: what is left of the entry, from 1 to 0.
pub fn percent(&self, group: &str, partial_tick: f32) -> f32 {
let Some((start, end)) = self.entries.get(group) else {
return 0.0;
};
let duration = end.wrapping_sub(*start) as f32;
let remaining = *end as f32 - (self.tick_count as f32 + partial_tick);
(remaining / duration).clamp(0.0, 1.0)
}
/// `isOnCooldown`: whether the group still has time to run.
pub fn is_on_cooldown(&self, group: &str) -> bool {
self.percent(group, 0.0) > 0.0
}
/// `addCooldown`.
pub fn add(&mut self, group: Arc<str>, ticks: i32) {
self.entries.insert(
group,
(self.tick_count, self.tick_count.wrapping_add(ticks)),
);
}
/// `ItemCooldowns.tick`: the counter, then every entry that has run out,
/// which it hands back (`onCooldownEnded`).
pub fn tick(&mut self) -> Vec<Arc<str>> {
self.tick_count = self.tick_count.wrapping_add(1);
let done: Vec<Arc<str>> = self
.entries
.iter()
.filter(|(_, (_, end))| *end <= self.tick_count)
.map(|(group, _)| Arc::clone(group))
.collect();
self.entries.retain(|_, (_, end)| *end > self.tick_count);
done
}
/// `removeCooldown`.
pub fn remove(&mut self, group: &str) {
self.entries.remove(group);
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn stack(item: &str) -> ItemStack {
ItemStack::new(item, 1)
}
#[test]
fn the_percent_falls_from_one_to_zero_over_the_entry() {
let mut c = Cooldowns::default();
c.add(Arc::from("minecraft:ender_pearl"), 20);
assert!(c.is_on_cooldown("minecraft:ender_pearl"));
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 1.0);
for _ in 0..10 {
assert!(c.tick().is_empty());
}
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.5);
// The partial tick moves it within the tick.
assert_eq!(c.percent("minecraft:ender_pearl", 0.5), 0.475);
for _ in 0..9 {
c.tick();
}
assert!(c.is_on_cooldown("minecraft:ender_pearl"));
// ItemCooldowns.tick drops the entry at its end tick, and says so.
assert_eq!(c.tick(), [Arc::<str>::from("minecraft:ender_pearl")]);
assert!(!c.is_on_cooldown("minecraft:ender_pearl"));
assert!(c.is_empty());
assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.0);
}
#[test]
fn the_group_comes_from_the_stack_then_the_item() {
let pearl = stack("minecraft:ender_pearl");
let default = UseCooldown::from_json(&json!({"seconds": 1.0})).expect("the component");
assert_eq!(&*group(&pearl, Some(&default)), "minecraft:ender_pearl");
// A stack's own component overrides the item's, group included.
let mut grouped = stack("minecraft:ender_pearl");
grouped.data_mut().set_other(
"minecraft:use_cooldown",
Some(Tag::compound([
("seconds", Tag::Float(2.0f32.to_bits())),
("cooldown_group", Tag::String("minecraft:pearls".into())),
])),
);
let c = component(&grouped, Some(&default)).expect("the stack's component");
assert_eq!(c.ticks(), 40);
assert_eq!(&*group(&grouped, Some(&default)), "minecraft:pearls");
// An item without the component has no cooldown.
assert!(component(&stack("minecraft:dirt"), None).is_none());
}
}
+10
View File
@@ -1610,6 +1610,16 @@ impl Recipes {
}
out
}
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
/// pack does not have (a save from another pack can name it).
/// `id` with or without the `minecraft:` namespace (a furnace keeps the full id).
pub fn cooking_experience(&self, id: &str) -> f32 {
let id = id.strip_prefix("minecraft:").unwrap_or(id);
self.cooking
.iter()
.find(|c| c.id == id)
.map_or(0.0, |c| c.experience)
}
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
let index = self.cook_index.get_or_init(|| {
let mut index = CookIndex::new();
+199 -26
View File
@@ -1,9 +1,14 @@
//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their
//! contents as built by the game's own display generators
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
use crate::{Result, items::ItemStack};
use crate::{Result, items::ItemStack, ui::EditBox};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::HashMap,
fs,
path::Path,
sync::{Arc, Mutex},
};
#[derive(Clone, Debug)]
pub struct Tab {
@@ -35,13 +40,22 @@ pub struct Creative {
pub scroll: f32,
/// The scrollbar is held (scrolling): dragging moves it.
pub scrolling: bool,
pub search: String,
/// When the search box took the focus (`EditBox.setFocused`): its
/// cursor blinks from then.
pub search_focused: std::time::Instant,
/// CreativeModeInventoryScreen's search box: an unbordered `EditBox` of 50.
pub search: EditBox,
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
}
/// The grid's stacks for one tab, search text and language.
struct Shown {
tab: usize,
search: String,
/// A display name, so a language change refills the search results.
language: String,
items: Arc<[ItemStack]>,
}
/// Display names from the pinned en_us language file.
#[derive(Default)]
@@ -71,10 +85,52 @@ impl Names {
.cloned()
.unwrap_or_else(|| path.replace('_', " "))
}
/// An item's display name in the selected language: `item.*` then
/// `block.*`, then the pinned English map (`Names::item`).
pub fn display_item(&self, id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
if let Some(lang) = crate::lang::current() {
for prefix in ["item", "block"] {
if let Some(name) = lang.get(&format!("{prefix}.{namespace}.{path}")) {
return name.to_owned();
}
}
}
self.item(id)
}
/// A translation key's text in the selected language, else the pinned
/// English map (`HoverName` and tooltip keys such as potion names).
pub fn display_get(&self, key: &str) -> Option<String> {
crate::lang::current()
.and_then(|lang| lang.get(key).map(str::to_owned))
.or_else(|| self.get(key).map(str::to_owned))
}
pub fn get(&self, key: &str) -> Option<&str> {
self.map.get(key).map(String::as_str)
}
}
/// `CreativeModeTabs`' key for an exported tab id (`itemGroup.*`); the
/// export's `title` is the English fallback.
pub fn item_group_key(id: &str) -> Option<&'static str> {
Some(match id.trim_start_matches("minecraft:") {
"building_blocks" => "itemGroup.buildingBlocks",
"colored_blocks" => "itemGroup.coloredBlocks",
"natural_blocks" => "itemGroup.natural",
"functional_blocks" => "itemGroup.functional",
"redstone_blocks" => "itemGroup.redstone",
"hotbar" => "itemGroup.hotbar",
"search" => "itemGroup.search",
"tools_and_utilities" => "itemGroup.tools",
"combat" => "itemGroup.combat",
"food_and_drinks" => "itemGroup.foodAndDrink",
"ingredients" => "itemGroup.ingredients",
"spawn_eggs" => "itemGroup.spawnEggs",
"op_blocks" => "itemGroup.op",
"inventory" => "itemGroup.inventory",
_ => return None,
})
}
impl Creative {
pub const WIDTH: f32 = 195.0;
pub const HEIGHT: f32 = 136.0;
@@ -242,10 +298,16 @@ impl Creative {
selected: 0,
scroll: 0.0,
scrolling: false,
search: String::new(),
search_focused: std::time::Instant::now(),
search: {
let mut search = EditBox::new(0.0, 0.0, 80.0, 9.0);
search.bordered = false;
search.max_length = 50;
search.text_color = u32::MAX;
search
},
search_items,
search_index,
shown: Mutex::new(None),
}
}
/// Where a stack comes in the creative tabs (the search tab lists them
@@ -258,18 +320,35 @@ impl Creative {
.position(|s| s.same_item_same_components(stack))
.or_else(|| items.iter().position(|s| s.item == stack.item))
}
/// The tabs without their stacks: what the screen draws of them each frame.
pub fn tab_headers(&self) -> Vec<Tab> {
self.tabs
.iter()
.map(|t| Tab {
id: t.id.clone(),
title: t.title.clone(),
icon: t.icon.clone(),
items: Vec::new(),
top: t.top,
column: t.column,
search: t.search,
inventory: t.inventory,
})
.collect()
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
}
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
pub fn select(&mut self, tab: usize) {
if tab != self.selected {
self.search.clear();
self.search.set_value("");
}
self.selected = tab;
self.scroll = 0.0;
if self.tab().search {
self.search_focused = std::time::Instant::now();
self.search.focused = true;
self.search.reset_blink();
}
}
/// The search tab's stacks.
@@ -303,7 +382,17 @@ impl Creative {
.collect();
}
// FullTextSearchTree: tooltip lines, and the item id after a colon.
// The export's lines are English (they come from the vanilla jars);
// the hover name in the selected language is matched too, so a
// localized search finds the item. It is the whole name (a tipped
// arrow's potion, the ominous banner), as the first line is.
let text = java_lower(term);
let localized = |i: usize| {
java_lower(&crate::item_tooltip::name_line(
&self.search_items[i],
&|k| names.display_get(k),
))
};
all.filter(|&i| {
let e = &self.search_index[i];
let fallback;
@@ -314,27 +403,58 @@ impl Creative {
&e.lines
};
match text.split_once(':') {
None => any(lines.iter().map(String::as_str), &text),
None => {
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
}
Some((ns, path)) => {
let (ns, path) = (java_trim(ns), java_trim(path));
e.namespace.contains(ns)
&& (e.path.contains(path) || any(lines.iter().map(String::as_str), path))
&& (e.path.contains(path)
|| any(lines.iter().map(String::as_str), path)
|| localized(i).contains(path))
}
}
})
.collect()
}
/// Items shown by the selected tab (the search tab filters by `search`).
pub fn items(&self, names: &Names) -> Vec<ItemStack> {
let tab = self.tab();
if tab.search {
return self
.search_results(&self.search, names)
/// Items shown by the selected tab (the search tab filters by `search`),
/// worked out again only when the tab, the search or the language changed.
pub fn items(&self, names: &Names) -> Arc<[ItemStack]> {
let tab = self.selected.min(self.tabs.len() - 1);
let search = if self.tab().search {
self.search.value.as_str()
} else {
""
};
let language = names.display_get("itemGroup.search").unwrap_or_default();
let mut shown = self.shown.lock().unwrap_or_else(|e| e.into_inner());
if let Some(s) = shown.as_ref()
&& s.tab == tab
&& s.search == search
&& s.language == language
{
return s.items.clone();
}
let items: Arc<[ItemStack]> = if self.tab().search {
self.search_results(search, names)
.into_iter()
.map(|i| self.search_items[i].clone())
.collect();
}
tab.items.clone()
.collect()
} else {
self.tab().items.as_slice().into()
};
*shown = Some(Shown {
tab,
search: search.to_owned(),
language,
items: items.clone(),
});
items
}
/// Drop the shown items, so the next `items` works them out again: a
/// reload can rename items without changing the Search label.
pub fn forget_items(&self) {
*self.shown.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
pub fn rows(&self, names: &Names) -> usize {
self.items(names).len().div_ceil(9)
@@ -698,13 +818,13 @@ mod tests {
(1, false, true),
(2, true, false),
] {
c.search = "anvil".into();
c.search.set_value("anvil");
c.scroll = 0.5;
c.select(selected);
assert_eq!(c.selected, selected);
assert_eq!(c.tab().inventory, inventory);
assert_eq!(c.tab().search, search);
assert!(c.search.is_empty());
assert!(c.search.value.is_empty());
assert_eq!(c.scroll, 0.0);
}
}
@@ -724,12 +844,12 @@ mod tests {
assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil");
let mut c = c;
c.select(1);
c.search = "anvil".into();
c.search.set_value("anvil");
c.scroll = 0.5;
c.select(1);
assert_eq!((c.search.as_str(), c.scroll), ("anvil", 0.0));
assert_eq!((c.search.value.as_str(), c.scroll), ("anvil", 0.0));
c.select(0);
assert_eq!(c.search, "");
assert_eq!(c.search.value, "");
}
#[test]
@@ -759,6 +879,24 @@ mod tests {
assert_eq!(q("#craft"), Vec::<usize>::new());
}
#[test]
fn search_matches_the_display_name_in_the_selected_language() {
let c = creative(0);
let names = Names {
map: HashMap::from([
("item.minecraft.anvil".to_owned(), "铁砧".to_owned()),
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
]),
};
let q = |s: &str| c.search_results(s, &names);
assert_eq!(q("铁砧"), [0]);
assert_eq!(q("木锄"), [2]);
// The English tooltip lines the export indexed still match.
assert_eq!(q("anvil"), [0]);
// A colon's path searches the display name too.
assert_eq!(q("minecraft:铁砧"), [0]);
}
#[test]
fn scrollbar_drag_follows_the_mouse_while_held() {
let mut c = creative(90);
@@ -794,6 +932,20 @@ mod tests {
assert!(!c.scrolling);
}
#[test]
fn creative_tab_titles_have_vanilla_keys() {
assert_eq!(
item_group_key("minecraft:building_blocks"),
Some("itemGroup.buildingBlocks")
);
assert_eq!(
item_group_key("minecraft:food_and_drinks"),
Some("itemGroup.foodAndDrink")
);
assert_eq!(item_group_key("minecraft:hotbar"), Some("itemGroup.hotbar"));
assert_eq!(item_group_key("minecraft:nonsense"), None);
}
#[test]
fn wheel_moves_one_row_per_notch() {
let mut c = creative(90);
@@ -811,4 +963,25 @@ mod tests {
c.scroll = 1.0;
assert_eq!(c.scroller_offset(), 95.0);
}
#[test]
fn search_edits_at_its_cursor() {
use winit::keyboard::KeyCode;
let mut c = creative(0);
c.select(1);
c.search.insert_text("hello world");
for _ in 0..5 {
assert!(c.search.key(KeyCode::ArrowLeft, false, false));
}
c.search.insert_text("X");
assert_eq!(c.search.value, "hello Xworld");
c.search.key(KeyCode::Home, false, true);
c.search.insert_text("Y");
assert_eq!(c.search.value, "Yworld");
// Ctrl+Left jumps a word; one step right and Backspace deletes before the cursor.
c.search.key(KeyCode::ArrowLeft, true, false);
c.search.key(KeyCode::ArrowRight, false, false);
c.search.key(KeyCode::Backspace, false, false);
assert_eq!(c.search.value, "world");
}
}
+59
View File
@@ -0,0 +1,59 @@
//! Cushion placement and support checks (`Cushion.canBePlacedAt`/`wouldSurviveAt`).
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
pub trait World {
fn outline_bounds(&self, pos: [i32; 3]) -> Option<Aabb>;
fn collision_shapes(&self, bounds: Aabb) -> Vec<VoxelShape>;
fn suffocating(&self, pos: [i32; 3]) -> bool;
}
pub fn blocks_in(a: Aabb) -> impl Iterator<Item = [i32; 3]> {
let (lo, hi) = (a.min_position(), a.max_position());
let (x0, y0, z0) = (
lo.x.floor() as i32,
lo.y.floor() as i32,
lo.z.floor() as i32,
);
let (x1, y1, z1) = (
hi.x.floor() as i32,
hi.y.floor() as i32,
hi.z.floor() as i32,
);
(y0..=y1).flat_map(move |y| (z0..=z1).flat_map(move |z| (x0..=x1).map(move |x| [x, y, z])))
}
pub fn survives(world: &impl World, bounds: Aabb) -> bool {
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let anchor = Aabb::new(
Vec3::new(lo.x, lo.y - 0.015625, lo.z),
Vec3::new(hi.x.next_down(), lo.y, hi.z.next_down()),
);
// The extra eighth block expands the scan, not the intersection test.
let supported = blocks_in(anchor.expand_towards(Vec3::new(0.0, -0.125, 0.0))).any(|p| {
world
.outline_bounds(p)
.is_some_and(|shape| shape.intersects(anchor))
});
supported && !blocks_in(bounds.next_deflated()).all(|p| world.suffocating(p))
}
pub fn can_place(world: &impl World, bounds: Aabb) -> bool {
if !survives(world, bounds) {
return false;
}
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let slab = Aabb::new(lo, Vec3::new(hi.x, lo.y + 0.015625, hi.z)).next_deflated();
let Ok(mut remaining) = VoxelShape::from_box(slab) else {
return false;
};
for shape in world.collision_shapes(slab) {
let Ok(next) = remaining.join(&shape, ShapeOp::ONLY_FIRST) else {
return false;
};
remaining = next;
if remaining.is_empty() {
return false;
}
}
true
}
+42 -5
View File
@@ -84,6 +84,33 @@ impl Displayer {
}
}
/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
/// pass's median milliseconds, in the order they run).
pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec<String> {
const SHOWN: usize = 8;
if passes.is_empty() {
return Vec::new();
}
let total: f32 = passes.iter().map(|p| p.1).sum();
let mut lines = vec![format!(
"Shader pack GPU: {total:.2} ms in {} passes",
passes.len()
)];
let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
lines.extend(
costliest
.iter()
.take(SHOWN)
.map(|(name, ms)| format!("{name}: {ms:.2} ms")),
);
if costliest.len() > SHOWN {
let rest: f32 = costliest[SHOWN..].iter().map(|p| p.1).sum();
lines.push(format!("{} more: {rest:.2} ms", costliest.len() - SHOWN));
}
lines
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let wrapped = angle % 360.0;
@@ -194,18 +221,17 @@ pub fn scale(
pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec<GuiDraw> {
let mut out = vec![GuiDraw::Push(vec![PoseOp::Scale(scale, scale)])];
for (lines, align_left) in [(columns[0], true), (columns[1], false)] {
let placed: Vec<(&String, i32, i32)> = lines
let placed: Vec<(&String, i32, i32, i32)> = lines
.iter()
.enumerate()
.filter(|(_, l)| !l.is_empty())
.map(|(i, l)| {
let w = font.width(l) as i32;
let left = if align_left { 2 } else { width - 2 - w };
(l, left, 2 + LINE_HEIGHT * i as i32)
(l, left, 2 + LINE_HEIGHT * i as i32, w)
})
.collect();
for (line, left, top) in &placed {
let w = font.width(line) as i32;
for (_, left, top, w) in &placed {
out.push(fill(
left - 1,
top - 1,
@@ -214,7 +240,7 @@ pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec
BACKGROUND,
));
}
for (line, left, top) in placed {
for (line, left, top, _) in placed {
out.push(GuiDraw::Text {
text: Text::literal(line.clone()),
x: left,
@@ -337,4 +363,15 @@ mod tests {
(1.0, [640, 360])
);
}
#[test]
fn shader_pass_lines_list_the_costliest_passes_first() {
assert!(shader_pass_lines(&[]).is_empty());
let passes: Vec<(String, f32)> = (0..10).map(|i| (format!("p{i}"), i as f32)).collect();
let lines = shader_pass_lines(&passes);
assert_eq!(lines[0], "Shader pack GPU: 45.00 ms in 10 passes");
assert_eq!(lines[1], "p9: 9.00 ms");
assert_eq!(lines[8], "p2: 2.00 ms");
assert_eq!(lines[9], "2 more: 1.00 ms");
}
}
+794
View File
@@ -0,0 +1,794 @@
//! Server dialogs (`net.minecraft.server.dialog`): what `Dialog.DIRECT_CODEC`
//! decodes from a `show_dialog` packet, the values its inputs hold, and the
//! click event an action makes of them (`Action.createAction`).
use crate::text::Text;
use horizon_world::nbt::Tag;
use serde_json::{Value, json};
/// `CommonDialogData` and the type's own fields.
#[derive(Clone, Debug)]
pub struct Dialog {
pub kind: Kind,
pub title: Text,
pub external_title: Option<Text>,
pub can_close_with_escape: bool,
pub pause: bool,
pub after_action: AfterAction,
pub body: Vec<Body>,
pub inputs: Vec<Input>,
}
/// The dialog types (`DialogTypes`).
#[derive(Clone, Debug)]
pub enum Kind {
/// `NoticeDialog`: one button, "OK" when the server names none.
Notice(Button),
Confirmation {
yes: Button,
no: Button,
},
MultiAction {
actions: Vec<Button>,
exit: Option<Button>,
columns: i32,
},
ServerLinks {
exit: Option<Button>,
columns: i32,
button_width: i32,
},
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
DialogList {
dialogs: Tag,
exit: Option<Button>,
columns: i32,
button_width: i32,
},
}
/// `DialogAction`: the screen after an action runs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AfterAction {
Close,
None,
WaitForResponse,
}
/// `ActionButton`: `CommonButtonData` and what it does.
#[derive(Clone, Debug)]
pub struct Button {
pub label: Text,
pub tooltip: Option<Text>,
pub width: i32,
pub action: Option<Action>,
}
/// `Action`: a fixed click event, or one made from the inputs' values.
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
/// `StaticAction`: a click event as it is.
Static(Click),
/// `CommandTemplate`: a command with `$(key)` replaced by input values.
RunCommand(String),
/// `CustomAll`: a custom click event whose payload is `additions` plus
/// every input's value under its key.
Custom { id: String, additions: Option<Tag> },
}
/// `ClickEvent`, the kinds a server may send (`isAllowedFromServer`).
#[derive(Clone, Debug, PartialEq)]
pub enum Click {
OpenUrl(String),
RunCommand(String),
SuggestCommand(String),
/// The dialog: an id of the `minecraft:dialog` registry or the dialog itself.
ShowDialog(Tag),
ChangePage(i32),
CopyToClipboard(String),
Custom {
id: String,
payload: Option<Tag>,
},
}
/// `DialogBody`.
#[derive(Clone, Debug)]
pub enum Body {
/// `PlainMessage`: centred text wrapped to `width`.
Message { contents: Text, width: i32 },
/// `ItemBody`: the item as `ItemStackTemplate` sent it.
Item {
item: Tag,
description: Option<(Text, i32)>,
show_decorations: bool,
show_tooltip: bool,
width: i32,
height: i32,
},
}
/// `Input`: the key its value goes by, and its control.
#[derive(Clone, Debug)]
pub struct Input {
pub key: String,
pub control: Control,
}
/// `InputControl`.
#[derive(Clone, Debug)]
pub enum Control {
Text {
width: i32,
label: Text,
label_visible: bool,
initial: String,
max_length: i32,
/// `MultilineOptions`: the most lines and the height, when set.
multiline: Option<(Option<i32>, Option<i32>)>,
},
Boolean {
label: Text,
initial: bool,
on_true: String,
on_false: String,
},
SingleOption {
width: i32,
/// Each option's id, its label and whether it is the initial one.
options: Vec<(String, Option<Text>, bool)>,
label: Text,
label_visible: bool,
},
NumberRange {
width: i32,
label: Text,
label_format: String,
start: f32,
end: f32,
initial: Option<f32>,
step: Option<f32>,
},
}
/// What an input holds while the dialog is open.
#[derive(Clone, Debug, PartialEq)]
pub enum InputValue {
Text(String),
Bool(bool),
/// The chosen option's index.
Option(usize),
/// The slider's position, 0 to 1.
Slider(f32),
}
fn text(t: &Tag) -> Text {
crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))
}
fn string(t: Option<&Tag>) -> Option<String> {
match t? {
Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
}
}
fn number(t: Option<&Tag>) -> Option<f64> {
Some(match t? {
Tag::Byte(v) => f64::from(*v),
Tag::Short(v) => f64::from(*v),
Tag::Int(v) => f64::from(*v),
Tag::Long(v) => *v as f64,
Tag::Float(v) => f64::from(f32::from_bits(*v)),
Tag::Double(v) => f64::from_bits(*v),
_ => return None,
})
}
fn int(t: &Tag, key: &str, default: i32) -> i32 {
number(t.get(key)).map_or(default, |v| v as i32)
}
fn boolean(t: &Tag, key: &str, default: bool) -> bool {
number(t.get(key)).map_or(default, |v| v != 0.0)
}
fn list(t: Option<&Tag>) -> &[Tag] {
match t {
Some(Tag::List(items)) => items,
_ => &[],
}
}
/// A registry name without its default namespace.
fn path(id: &str) -> &str {
id.strip_prefix("minecraft:").unwrap_or(id)
}
impl Button {
/// `ActionButton.CODEC`.
fn parse(t: &Tag) -> Option<Self> {
Some(Self {
label: text(t.get("label")?),
tooltip: t.get("tooltip").map(text),
width: int(t, "width", 150),
action: t.get("action").and_then(Action::parse),
})
}
/// `NoticeDialog.DEFAULT_ACTION`.
fn ok() -> Self {
Self {
label: Text::translate("gui.ok", &[]),
tooltip: None,
width: 150,
action: None,
}
}
}
impl Click {
/// `ClickEvent.CODEC`, from its `action` (a click event in a component)
/// or `type` (an action) field.
pub fn parse(t: &Tag, kind: &str) -> Option<Self> {
let s = |key| string(t.get(key));
Some(match path(kind) {
"open_url" => Self::OpenUrl(s("url")?),
"run_command" => Self::RunCommand(s("command")?),
"suggest_command" => Self::SuggestCommand(s("command")?),
"show_dialog" => Self::ShowDialog(t.get("dialog")?.clone()),
"change_page" => Self::ChangePage(int(t, "page", 1)),
"copy_to_clipboard" => Self::CopyToClipboard(s("value")?),
"custom" => Self::Custom {
id: s("id")?,
payload: t.get("payload").cloned(),
},
_ => return None,
})
}
}
impl Action {
/// `Action.CODEC`, dispatched on `type`.
fn parse(t: &Tag) -> Option<Self> {
let kind = string(t.get("type"))?;
Some(match path(&kind) {
"dynamic/run_command" => Self::RunCommand(string(t.get("template"))?),
"dynamic/custom" => Self::Custom {
id: string(t.get("id"))?,
additions: t.get("additions").cloned(),
},
other => Self::Static(Click::parse(t, other)?),
})
}
/// `createAction`: the click event, given each input's key, control and value.
pub fn click(&self, inputs: &[Input], values: &[InputValue]) -> Option<Click> {
let values = || inputs.iter().zip(values);
Some(match self {
Self::Static(click) => click.clone(),
Self::RunCommand(template) => Click::RunCommand(substitute(template, &|key| {
values()
.find(|(i, _)| i.key == key)
.map(|(i, v)| i.control.substitution(v))
})),
Self::Custom { id, additions } => {
let mut tag = match additions {
Some(t @ Tag::Compound(_)) => t.clone(),
_ => Tag::Compound(Default::default()),
};
for (input, value) in values() {
if let Tag::Compound(map) = &mut tag {
map.insert(input.key.as_str().into(), input.control.tag(value));
}
}
Click::Custom {
id: id.clone(),
payload: Some(tag),
}
}
})
}
}
/// `ParsedTemplate.instantiate`: each `$(key)` replaced by its value, or by
/// nothing for a key no input has.
fn substitute(template: &str, value: &dyn Fn(&str) -> Option<String>) -> String {
let mut out = String::new();
let mut rest = template;
while let Some(at) = rest.find("$(") {
let Some(end) = rest[at..].find(')') else {
break;
};
out.push_str(&rest[..at]);
out.push_str(&value(&rest[at + 2..at + end]).unwrap_or_default());
rest = &rest[at + end + 1..];
}
out.push_str(rest);
out
}
/// `StringTag.escapeWithoutQuotes`.
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' | '\'' | '\\' => {
out.push('\\');
out.push(c);
}
'\u{8}' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\u{c}' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
c if c < ' ' => out.push_str(&format!("\\x{:02x}", c as u32)),
c => out.push(c),
}
}
out
}
impl Control {
/// `InputControl.MAP_CODEC`, dispatched on `type`.
fn parse(t: &Tag) -> Option<Self> {
let label = || t.get("label").map(text);
Some(match path(&string(t.get("type"))?) {
"text" => Self::Text {
width: int(t, "width", 200),
label: label()?,
label_visible: boolean(t, "label_visible", true),
initial: string(t.get("initial")).unwrap_or_default(),
max_length: int(t, "max_length", 32),
multiline: t.get("multiline").map(|m| {
let get = |key| number(m.get(key)).map(|v| v as i32);
(get("max_lines"), get("height"))
}),
},
"boolean" => Self::Boolean {
label: label()?,
initial: boolean(t, "initial", false),
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
},
"single_option" => Self::SingleOption {
width: int(t, "width", 200),
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
options: list(t.get("options"))
.iter()
.filter_map(|o| match o {
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
o => Some((
string(o.get("id"))?,
o.get("display").map(text),
boolean(o, "initial", false),
)),
})
.collect(),
label: label()?,
label_visible: boolean(t, "label_visible", true),
},
"number_range" => {
let float = |key| number(t.get(key)).map(|v| v as f32);
Self::NumberRange {
width: int(t, "width", 200),
label: label()?,
label_format: string(t.get("label_format"))
.unwrap_or_else(|| "options.generic_value".into()),
start: float("start")?,
end: float("end")?,
initial: float("initial"),
step: float("step"),
}
}
_ => return None,
})
}
/// The value it starts with.
pub fn initial(&self) -> InputValue {
match self {
Self::Text { initial, .. } => InputValue::Text(initial.clone()),
Self::Boolean { initial, .. } => InputValue::Bool(*initial),
Self::SingleOption { options, .. } => {
InputValue::Option(options.iter().position(|o| o.2).unwrap_or(0))
}
Self::NumberRange {
start,
end,
initial,
..
} => InputValue::Slider(to_slider(
initial.unwrap_or((start + end) / 2.0),
*start,
*end,
)),
}
}
/// `ValueGetter.asTemplateSubstitution`.
pub fn substitution(&self, value: &InputValue) -> String {
match (self, value) {
(Self::Text { .. }, InputValue::Text(s)) => escape(s),
(
Self::Boolean {
on_true, on_false, ..
},
InputValue::Bool(b),
) => if *b { on_true } else { on_false }.clone(),
(Self::SingleOption { options, .. }, InputValue::Option(i)) => {
options.get(*i).map(|o| o.0.clone()).unwrap_or_default()
}
(Self::NumberRange { .. }, InputValue::Slider(_)) => {
number_string(self.scaled(value).unwrap_or(0.0))
}
_ => String::new(),
}
}
/// `ValueGetter.asTag`.
pub fn tag(&self, value: &InputValue) -> Tag {
match (self, value) {
(Self::Boolean { .. }, InputValue::Bool(b)) => Tag::Byte(i8::from(*b)),
(Self::NumberRange { .. }, _) => {
Tag::Float(self.scaled(value).unwrap_or(0.0).to_bits())
}
(Self::Text { .. }, InputValue::Text(s)) => Tag::String(s.as_str().into()),
_ => Tag::String(self.substitution(value).as_str().into()),
}
}
/// `RangeInfo.computeScaledValue`: a number range's value at its slider position.
pub fn scaled(&self, value: &InputValue) -> Option<f32> {
let (
Self::NumberRange {
start,
end,
initial,
step,
..
},
InputValue::Slider(slider),
) = (self, value)
else {
return None;
};
let (start, end) = (*start, *end);
let in_range = start + slider * (end - start);
let Some(step) = step else {
return Some(in_range);
};
let first = initial.unwrap_or((start + end) / 2.0);
// Math.round: half up.
let steps = ((in_range - first) / step + 0.5).floor() as i32;
let result = first + steps as f32 * step;
let position = to_slider(result, start, end);
if (0.0..=1.0).contains(&position) {
return Some(result);
}
Some(first + (steps - steps.signum()) as f32 * step)
}
}
/// `RangeInfo.scaledValueToSlider`.
fn to_slider(value: f32, start: f32, end: f32) -> f32 {
if start == end {
0.5
} else {
(value - start) / (end - start)
}
}
/// `SliderImpl.valueToString`: whole numbers without a fraction, the rest as
/// Java's `Float.toString`.
pub fn number_string(v: f32) -> String {
if v == (v as i32) as f32 {
(v as i32).to_string()
} else {
crate::snbt::java_float(v)
}
}
impl Body {
/// `DialogBody.DIALOG_BODY_CODEC`.
fn parse(t: &Tag) -> Option<Self> {
let message = |t: &Tag| -> Option<(Text, i32)> {
// PlainMessage.CODEC: the full form or just its contents.
match t {
Tag::Compound(_) if t.get("contents").is_some() => {
Some((text(t.get("contents")?), int(t, "width", 200)))
}
t => Some((text(t), 200)),
}
};
Some(match path(&string(t.get("type"))?) {
"plain_message" => {
let (contents, width) = message(t)?;
Self::Message { contents, width }
}
"item" => Self::Item {
item: t.get("item")?.clone(),
description: t.get("description").and_then(message),
show_decorations: boolean(t, "show_decorations", true),
show_tooltip: boolean(t, "show_tooltip", true),
width: int(t, "width", 16),
height: int(t, "height", 16),
},
_ => return None,
})
}
}
impl Dialog {
/// `Dialog.DIRECT_CODEC`; `None` for a type or field it can't read
/// (vanilla fails the whole packet then).
pub fn parse(t: &Tag) -> Option<Self> {
let button = |key: &str| t.get(key).and_then(Button::parse);
let kind = match path(&string(t.get("type"))?) {
"notice" => Kind::Notice(match t.get("action") {
Some(b) => Button::parse(b)?,
None => Button::ok(),
}),
"confirmation" => Kind::Confirmation {
yes: button("yes")?,
no: button("no")?,
},
"multi_action" => Kind::MultiAction {
actions: list(t.get("actions"))
.iter()
.map(Button::parse)
.collect::<Option<Vec<_>>>()
.filter(|a| !a.is_empty())?,
exit: button("exit_action"),
columns: int(t, "columns", 2),
},
"server_links" => Kind::ServerLinks {
exit: button("exit_action"),
columns: int(t, "columns", 2),
button_width: int(t, "button_width", 150),
},
"dialog_list" => Kind::DialogList {
dialogs: t.get("dialogs")?.clone(),
exit: button("exit_action"),
columns: int(t, "columns", 2),
button_width: int(t, "button_width", 150),
},
_ => return None,
};
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
let body = match t.get("body") {
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
Some(one) => Body::parse(one).into_iter().collect(),
None => Vec::new(),
};
let inputs = list(t.get("inputs"))
.iter()
.filter_map(|i| {
Some(Input {
key: string(i.get("key"))?,
control: Control::parse(i)?,
})
})
.collect();
Some(Self {
kind,
title: text(t.get("title")?),
external_title: t.get("external_title").map(text),
can_close_with_escape: boolean(t, "can_close_with_escape", true),
pause: boolean(t, "pause", true),
after_action: match string(t.get("after_action")).as_deref() {
Some("none") => AfterAction::None,
Some("wait_for_response") => AfterAction::WaitForResponse,
_ => AfterAction::Close,
},
body,
inputs,
})
}
/// `onCancel`: what Escape (or closing it) does.
pub fn on_cancel(&self) -> Option<&Action> {
match &self.kind {
Kind::Notice(b) => b.action.as_ref(),
Kind::Confirmation { no, .. } => no.action.as_ref(),
Kind::MultiAction { exit, .. }
| Kind::ServerLinks { exit, .. }
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
}
}
/// The buttons it draws, in order: the list, then the footer's.
pub fn buttons(&self) -> Vec<&Button> {
match &self.kind {
Kind::Notice(b) => vec![b],
Kind::Confirmation { yes, no } => vec![yes, no],
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
}
}
/// What `status` reports of it.
pub fn json(&self, values: &[InputValue]) -> Value {
let kind = match &self.kind {
Kind::Notice(_) => "notice",
Kind::Confirmation { .. } => "confirmation",
Kind::MultiAction { .. } => "multi_action",
Kind::ServerLinks { .. } => "server_links",
Kind::DialogList { .. } => "dialog_list",
};
let inputs: Vec<Value> = self
.inputs
.iter()
.zip(values)
.map(|(i, v)| {
let kind = match i.control {
Control::Text { .. } => "text",
Control::Boolean { .. } => "boolean",
Control::SingleOption { .. } => "single_option",
Control::NumberRange { .. } => "number_range",
};
json!({"key": i.key, "type": kind, "value": i.control.substitution(v)})
})
.collect();
json!({
"type": kind,
"title": self.title.plain(),
"body": self.body.iter().map(|b| match b {
Body::Message { contents, .. } => contents.plain(),
Body::Item { item, .. } => string(item.get("id")).unwrap_or_default(),
}).collect::<Vec<_>>(),
"inputs": inputs,
"buttons": self.buttons().iter().map(|b| b.label.plain()).collect::<Vec<_>>(),
"can_close_with_escape": self.can_close_with_escape,
"after_action": match self.after_action {
AfterAction::Close => "close",
AfterAction::None => "none",
AfterAction::WaitForResponse => "wait_for_response",
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn s(v: &str) -> Tag {
Tag::String(v.into())
}
/// The register dialog AuthMe 6 sends in configuration (the shape its
/// `show_dialog` packet has on the test bed).
fn register() -> Tag {
Tag::compound([
("type", s("minecraft:multi_action")),
("title", s("Register")),
("can_close_with_escape", Tag::Byte(0)),
("after_action", s("wait_for_response")),
(
"body",
Tag::compound([
("type", s("minecraft:plain_message")),
("contents", s("Choose a password")),
]),
),
(
"inputs",
Tag::List(vec![Tag::compound([
("type", s("minecraft:text")),
("key", s("password")),
("label", s("Password")),
])]),
),
(
"actions",
Tag::List(vec![
Tag::compound([
("label", s("Register")),
(
"action",
Tag::compound([
("type", s("minecraft:dynamic/custom")),
("id", s("authme:prejoin-register/submit")),
]),
),
]),
Tag::compound([
("label", s("Cancel")),
(
"action",
Tag::compound([
("type", s("minecraft:dynamic/run_command")),
("template", s("say $(password) $(missing)!")),
]),
),
]),
]),
),
])
}
#[test]
fn decodes_a_multi_action_dialog_with_defaults() {
let d = Dialog::parse(&register()).unwrap();
assert_eq!(d.title.plain(), "Register");
assert!(!d.can_close_with_escape);
assert!(d.pause);
assert_eq!(d.after_action, AfterAction::WaitForResponse);
assert!(matches!(&d.body[..], [Body::Message { width: 200, .. }]));
let Kind::MultiAction {
actions,
exit,
columns,
} = &d.kind
else {
panic!("{:?}", d.kind)
};
assert_eq!((actions.len(), exit.is_none(), *columns), (2, true, 2));
assert_eq!(actions[0].width, 150);
assert!(d.on_cancel().is_none());
let [
Input {
key,
control:
Control::Text {
max_length: 32,
width: 200,
..
},
},
] = &d.inputs[..]
else {
panic!("{:?}", d.inputs)
};
assert_eq!(key, "password");
}
#[test]
fn actions_carry_the_input_values() {
let d = Dialog::parse(&register()).unwrap();
let values = [InputValue::Text("p\"w".into())];
let Kind::MultiAction { actions, .. } = &d.kind else {
unreachable!()
};
let custom = actions[0]
.action
.as_ref()
.unwrap()
.click(&d.inputs, &values);
assert_eq!(
custom,
Some(Click::Custom {
id: "authme:prejoin-register/submit".into(),
payload: Some(Tag::compound([("password", s("p\"w"))])),
})
);
// The template escapes the text; a key no input has becomes nothing.
let command = actions[1]
.action
.as_ref()
.unwrap()
.click(&d.inputs, &values);
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
}
#[test]
fn a_notice_without_an_action_has_ok() {
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).unwrap();
let Kind::Notice(b) = &d.kind else {
unreachable!()
};
assert_eq!(b.label, Text::translate("gui.ok", &[]));
// Without a title, or with an unknown type, there is no dialog.
assert!(Dialog::parse(&Tag::compound([("type", s("notice"))])).is_none());
assert!(Dialog::parse(&Tag::compound([("type", s("x")), ("title", s("Hi"))])).is_none());
}
#[test]
fn number_ranges_snap_to_their_step() {
let mut range = Control::NumberRange {
width: 200,
label: Text::literal("n"),
label_format: "options.generic_value".into(),
start: 0.0,
end: 10.0,
initial: Some(4.0),
step: Some(3.0),
};
assert_eq!(range.initial(), InputValue::Slider(0.4));
assert_eq!(range.scaled(&InputValue::Slider(0.62)), Some(7.0));
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(10.0));
let Control::NumberRange { step, .. } = &mut range else {
unreachable!()
};
*step = Some(4.0);
// 12 is out of range: one step less.
assert_eq!(range.scaled(&InputValue::Slider(1.0)), Some(8.0));
assert_eq!(range.substitution(&InputValue::Slider(0.0)), "0");
assert_eq!(number_string(2.5), "2.5");
}
}
+382
View File
@@ -0,0 +1,382 @@
//! `DialogScreen` and its kinds (`SimpleDialogScreen`, `MultiButtonDialogScreen`):
//! where a server dialog's title, body, inputs and buttons go in a
//! `HeaderAndFooterLayout` (a 33 high header, the body in a `ScrollableLayout`,
//! the footer), and the `WaitingForResponseScreen` an answer can leave open.
use crate::{
dialog::{Body, Control, Dialog, Kind},
font::Font,
text::Text,
};
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
const BAR: f32 = 33.0;
/// `Checkbox.getBoxSize`.
pub const BOX: f32 = 17.0;
/// `DialogScreen.WARNING_BUTTON_SPRITES`: normal, disabled, highlighted.
pub const WARNING_SPRITES: [&str; 3] = [
"gui/sprites/dialog/warning_button",
"gui/sprites/dialog/warning_button_disabled",
"gui/sprites/dialog/warning_button_highlighted",
];
/// One drawn piece; `y` is from the top of the body (before it scrolls) for
/// the body's parts, from the top of the screen for the footer's.
#[derive(Clone, Debug, PartialEq)]
pub enum Part {
/// A line of text and where it starts.
Line(Text, f32, f32),
/// A text input's box.
Field { input: usize, rect: [f32; 4] },
/// A boolean input: the box, its label right of it.
Check { input: usize, rect: [f32; 4] },
/// A button of `Dialog::buttons`.
Button { index: usize, rect: [f32; 4] },
}
#[derive(Clone, Debug)]
pub struct Layout {
/// The title and where it is drawn.
pub title: (f32, f32),
pub warning: [f32; 4],
/// The body's visible area (`ScrollableLayout`'s container): x, y, w, h.
pub area: [f32; 4],
/// The body's full height: more than the area's scrolls.
pub content: f32,
pub body: Vec<Part>,
pub footer: Vec<Part>,
}
/// A laid-out element of a `LinearLayout` before it is placed.
struct Element {
w: f32,
h: f32,
parts: Vec<Part>,
}
impl Element {
/// Its parts moved to `(x, y)`.
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
self.parts.into_iter().map(move |p| match p {
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
Part::Field { input, rect } => Part::Field {
input,
rect: shift(rect, x, y),
},
Part::Check { input, rect } => Part::Check {
input,
rect: shift(rect, x, y),
},
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, x, y),
},
})
}
}
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
[x + dx, y + dy, w, h]
}
/// `FrameLayout.alignInDimension` at 0.5: the offset that centres `inner`.
fn centre(outer: f32, inner: f32) -> f32 {
((outer - inner) * 0.5).trunc()
}
/// `FocusableTextWidget` with the body's settings: `width` wide, the text
/// wrapped to `width - 8` and centred, 4 of padding all round.
fn message(font: &Font, contents: &Text, width: f32) -> Element {
let lines = font.split_lines(contents, width - 8.0);
let mid = (width / 2.0).trunc();
let parts = lines
.into_iter()
.enumerate()
.map(|(i, (line, _))| {
let x = mid - (font.width_text(&line) / 2.0).trunc();
Part::Line(line, x, 4.0 + 9.0 * i as f32)
})
.collect::<Vec<_>>();
Element {
w: width,
h: 9.0 * parts.len() as f32 + 8.0,
parts,
}
}
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
Some(match control {
Control::Text {
width,
label,
label_visible,
..
} => {
let w = *width as f32;
if !label_visible {
return Some(Element {
w,
h: 20.0,
parts: vec![Part::Field {
input: i,
rect: [0.0, 0.0, w, 20.0],
}],
});
}
// CommonLayouts.labeledElement: the label, 4 below it the box,
// both on the left.
Element {
w: w.max(font.width_text(label)),
h: 9.0 + 4.0 + 20.0,
parts: vec![
Part::Line(label.clone(), 0.0, 0.0),
Part::Field {
input: i,
rect: [0.0, 13.0, w, 20.0],
},
],
}
}
Control::Boolean { label, .. } => {
let w = BOX + 4.0 + font.width_text(label);
Element {
w,
h: BOX,
parts: vec![Part::Check {
input: i,
rect: [0.0, 0.0, w, BOX],
}],
}
}
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
})
}
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
/// is left in a centred row under them.
fn columns(widths: &[f32], columns: usize, first: usize) -> Element {
let columns = columns.max(1);
let full = widths.len() / columns * columns;
let row_width = |ws: &[f32]| ws.iter().sum::<f32>() + 2.0 * (ws.len().max(1) - 1) as f32;
let mut col = vec![0.0f32; columns];
for (i, w) in widths[..full].iter().enumerate() {
col[i % columns] = col[i % columns].max(*w);
}
let last = &widths[full..];
if !last.is_empty() {
// GridLayout spreads a cell over every column it spans (Divisor).
let spread = row_width(last) - 2.0 * (columns - 1) as f32;
let (each, extra) = ((spread / columns as f32).floor(), spread % columns as f32);
for (c, w) in col.iter_mut().enumerate() {
*w = w.max(each + f32::from(u8::from((c as f32) < extra)));
}
}
let x_of = |c: usize| col[..c].iter().sum::<f32>() + 2.0 * c as f32;
let mut parts = Vec::new();
for (i, w) in widths[..full].iter().enumerate() {
let (r, c) = (i / columns, i % columns);
parts.push(Part::Button {
index: first + i,
rect: [x_of(c) + centre(col[c], *w), 22.0 * r as f32, *w, 20.0],
});
}
let width = x_of(columns - 1) + col[columns - 1];
if !last.is_empty() {
let y = 22.0 * (full / columns) as f32;
let mut x = centre(width, row_width(last));
for (i, w) in last.iter().enumerate() {
parts.push(Part::Button {
index: first + full + i,
rect: [x, y, *w, 20.0],
});
x += w + 2.0;
}
}
let rows = full / columns + usize::from(!last.is_empty());
Element {
w: width,
h: (22.0 * rows as f32 - 2.0).max(0.0),
parts,
}
}
impl Layout {
/// `DialogScreen.init` and `repositionElements` on a `w` by `h` screen.
pub fn new(dialog: &Dialog, font: &Font, (w, h): (f32, f32)) -> Self {
// The header: the title, 10 right of it the warning button, both
// centred together.
let tw = font.width_text(&dialog.title);
let (hx, hy) = (centre(w, tw + 30.0), centre(BAR, 20.0));
let title = (hx, hy + centre(20.0, 9.0));
let warning = [hx + tw + 10.0, hy, 20.0, 20.0];
let mut elements: Vec<Element> = dialog
.body
.iter()
.filter_map(|b| match b {
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
Body::Item { .. } => None,
})
.collect();
elements.extend(
dialog
.inputs
.iter()
.enumerate()
.filter_map(|(i, input_)| input(font, i, &input_.control)),
);
let buttons = dialog.buttons();
let (listed, footer_buttons, footer_h) = match &dialog.kind {
Kind::MultiAction {
actions,
exit,
columns: n,
} => {
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
elements.push(columns(&widths, *n as usize, 0));
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
(
actions.len(),
footer,
if exit.is_some() { BAR } else { 5.0 },
)
}
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
(0, footer, if exit.is_some() { BAR } else { 5.0 })
}
// SimpleDialogScreen: the buttons side by side, 8 apart.
Kind::Notice(_) | Kind::Confirmation { .. } => {
(0, (0..buttons.len()).collect::<Vec<_>>(), BAR)
}
};
debug_assert!(listed + footer_buttons.len() <= buttons.len());
// The body: a column 10 apart, each element centred in it.
let body_w = elements.iter().map(|e| e.w).fold(0.0, f32::max);
let content = elements.iter().map(|e| e.h).sum::<f32>()
+ 10.0 * elements.len().saturating_sub(1) as f32;
// ScrollableLayout: 10 each side for the scrollbar (ReserveStrategy.BOTH),
// no higher than what the header and footer leave.
let area_w = body_w + 20.0;
let area_h = content.min(h - BAR - footer_h).max(0.0);
let area_y = (BAR + 30.0).min(h - footer_h - area_h);
let area_x = centre(w, area_w);
let mut body = Vec::new();
let mut y = 0.0;
for e in elements {
let (x, eh) = (area_x + 10.0 + centre(body_w, e.w), e.h);
body.extend(e.at(x, y));
y += eh + 10.0;
}
let widths: Vec<f32> = footer_buttons
.iter()
.map(|&i| buttons[i].width as f32)
.collect();
let row = widths.iter().sum::<f32>() + 8.0 * widths.len().saturating_sub(1) as f32;
let (mut x, fy) = (centre(w, row), h - footer_h + centre(footer_h, 20.0));
let mut footer = Vec::new();
for (&i, bw) in footer_buttons.iter().zip(&widths) {
footer.push(Part::Button {
index: i,
rect: [x, fy, *bw, 20.0],
});
x += bw + 8.0;
}
Self {
title,
warning: Self::in_bounds(warning, w, h),
area: [area_x, area_y, area_w, area_h],
content,
body,
footer,
}
}
/// `makeSureWarningButtonIsInBounds`.
fn in_bounds(r: [f32; 4], w: f32, h: f32) -> [f32; 4] {
if r[0] < 0.0 || r[1] < 0.0 || r[0] > w - 20.0 || r[1] > h - 20.0 {
[(w - 40.0).max(0.0), 5.0f32.min(h), 20.0, 20.0]
} else {
r
}
}
/// Every part where it is drawn with the body scrolled by `scroll`, and
/// whether it is in the body (clipped to its area).
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
let dy = self.area[1] - scroll;
let body = self.body.iter().map(move |p| {
let p = match p.clone() {
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
Part::Field { input, rect } => Part::Field {
input,
rect: shift(rect, 0.0, dy),
},
Part::Check { input, rect } => Part::Check {
input,
rect: shift(rect, 0.0, dy),
},
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, 0.0, dy),
},
};
(p, true)
});
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
}
}
/// `WaitingForResponseScreen`: its Back button's label after `ticks`, and
/// whether it shows and works (it shows after a second, works after five).
pub fn waiting_button(ticks: u32) -> (Text, bool, bool) {
let seconds = (ticks / 20).min(5);
let label = match seconds {
0 => Text::literal(""),
5 => Text::translate("gui.back", &[]),
s => Text::translate(
"gui.waitingForResponse.button.inactive",
&[Text::literal((5 - s).to_string())],
),
};
(label, seconds >= 1, seconds == 5)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grids_pack_full_rows_and_centre_the_rest() {
// AuthMe's two buttons: one row, 2 apart.
let two = columns(&[150.0, 150.0], 2, 0);
assert_eq!((two.w, two.h), (302.0, 20.0));
// Three in two columns: the third alone, centred under them.
let three = columns(&[150.0, 150.0, 150.0], 2, 0);
assert_eq!((three.w, three.h), (302.0, 42.0));
assert_eq!(
three.parts[2],
Part::Button {
index: 2,
rect: [76.0, 22.0, 150.0, 20.0]
}
);
// One in two columns spreads over both.
let one = columns(&[100.0], 2, 0);
assert_eq!(one.w, 100.0);
}
#[test]
fn the_waiting_screens_button_counts_down() {
assert!(!waiting_button(0).1);
assert_eq!(
waiting_button(20).0,
Text::translate(
"gui.waitingForResponse.button.inactive",
&[Text::literal("4")]
)
);
assert_eq!(
waiting_button(100),
(Text::translate("gui.back", &[]), true, true)
);
}
}
@@ -0,0 +1,623 @@
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
//! or with `HORIZON_NO_DISCORD=1`.
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::sync::{Condvar, Mutex, OnceLock};
use std::thread::Thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use crate::command::args::GameMode;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
pub const DISCORD_APP_ID: &str = "1563857609738690560";
/// Whether the application has the game-mode images (`survival`, `creative`, `adventure`,
/// `spectator`) beside the logo; it doesn't yet.
const MODE_IMAGES: bool = false;
/// Points the game at a test's fake Discord (a folder holding `discord-ipc-0`).
const IPC_DIR_ENV: &str = "HORIZON_DISCORD_IPC_DIR";
/// Discord drops activity updates beyond 5 in 20 seconds.
const BURST: usize = 5;
const WINDOW: Duration = Duration::from_secs(20);
/// Between two tries to reach Discord while it isn't running.
const RETRY: Duration = Duration::from_secs(15);
/// The activity is sent again this often, so a Discord restarted meanwhile gets it back.
const REFRESH: Duration = Duration::from_secs(60);
const HANDSHAKE: u32 = 0;
const FRAME: u32 = 1;
const CLOSE: u32 = 2;
const PING: u32 = 3;
const PONG: u32 = 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Play {
Singleplayer,
/// A server or someone else's LAN world. Its address and name are never shown.
Multiplayer,
/// This world is open to LAN or friends; `players` counts the host.
Hosting {
players: usize,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
/// The player turned Rich Presence off: the activity is cleared.
Off,
Menus,
World {
play: Play,
dimension: Dimension,
mode: GameMode,
},
}
/// What the game wants shown, and since when it has been in a world.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Wanted {
status: Status,
/// Unix seconds: when the player entered the world (dimension changes keep it).
since: Option<u64>,
}
impl Wanted {
/// A new status at `now`; false if it is the one already wanted.
fn update(&mut self, status: Status, now: u64) -> bool {
if self.status == status {
return false;
}
let in_world = |s: Status| matches!(s, Status::World { .. });
if !in_world(status) {
self.since = None;
} else if !in_world(self.status) {
self.since = Some(now);
}
self.status = status;
true
}
}
struct Shared {
wanted: Wanted,
stop: bool,
stopped: bool,
}
static SHARED: Mutex<Shared> = Mutex::new(Shared {
wanted: Wanted {
status: Status::Menus,
since: None,
},
stop: false,
stopped: false,
});
static STOPPED: Condvar = Condvar::new();
static THREAD: OnceLock<Thread> = OnceLock::new();
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
pub fn start(enabled: bool, automated: bool) {
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
if THREAD.get().is_some()
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
|| (automated && !fake_discord)
{
return;
}
if !enabled {
lock().wanted.status = Status::Off;
}
match std::thread::Builder::new()
.name("Discord presence".into())
.spawn(run)
{
Ok(handle) => {
let _ = THREAD.set(handle.thread().clone());
}
Err(e) => log::warn!("Horizon: no Discord presence: {e}"),
}
}
/// What the player does now; only a change wakes the thread. Free without [`start`].
pub fn set(status: Status) {
let Some(thread) = THREAD.get() else {
return;
};
if lock().wanted.update(status, unix_now()) {
thread.unpark();
}
}
/// On quit: clears the activity, waiting at most a moment for Discord.
pub fn stop() {
let Some(thread) = THREAD.get() else {
return;
};
let mut shared = lock();
shared.stop = true;
thread.unpark();
let _ = STOPPED.wait_timeout_while(shared, Duration::from_millis(300), |s| !s.stopped);
}
fn lock() -> std::sync::MutexGuard<'static, Shared> {
SHARED.lock().unwrap_or_else(|e| e.into_inner())
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn run() {
let mut client: Option<Client> = None;
let mut sent: Option<(Wanted, Instant)> = None;
let mut last_try: Option<Instant> = None;
let mut limit = RateLimit::default();
loop {
let (wanted, stop) = {
let s = lock();
(s.wanted, s.stop)
};
if stop || wanted.status == Status::Off {
if let Some(mut c) = client.take() {
let _ = c.set_activity(None);
c.close();
}
sent = None;
if stop {
lock().stopped = true;
STOPPED.notify_all();
return;
}
std::thread::park();
continue;
}
let now = Instant::now();
if client.is_none() {
if let Some(wait) = last_try.and_then(|t| RETRY.checked_sub(now - t)) {
std::thread::park_timeout(wait);
continue;
}
last_try = Some(now);
client = Client::connect();
if client.is_none() {
continue;
}
}
let due = sent.is_none_or(|(w, at)| w != wanted || now - at >= REFRESH);
if !due {
let (_, at) = sent.expect("sent");
std::thread::park_timeout(REFRESH - (now - at));
continue;
}
let wait = limit.wait(now);
if !wait.is_zero() {
std::thread::park_timeout(wait);
continue;
}
limit.record(now);
let c = client.as_mut().expect("connected");
match c.set_activity(activity(wanted.status, wanted.since)) {
Ok(()) => sent = Some((wanted, now)),
Err(e) => {
log::info!("Horizon: Discord presence disconnected ({e})");
client = None;
sent = None;
}
}
}
}
/// The activity Discord shows for `status`; `None` clears it.
fn activity(status: Status, since: Option<u64>) -> Option<Value> {
let mut assets = json!({"large_image": "horizon", "large_text": "Horizon"});
let (details, state, party) = match status {
Status::Off => return None,
Status::Menus => ("In the menus", None, None),
Status::World {
play,
dimension,
mode,
} => {
if MODE_IMAGES {
let (key, text) = match mode {
GameMode::Survival => ("survival", "Survival"),
GameMode::Creative => ("creative", "Creative"),
GameMode::Adventure => ("adventure", "Adventure"),
GameMode::Spectator => ("spectator", "Spectator"),
};
assets["small_image"] = key.into();
assets["small_text"] = text.into();
}
let place = match dimension {
Dimension::Overworld => "In the Overworld",
Dimension::Nether => "In the Nether",
Dimension::End => "In the End",
};
let (details, party) = match play {
Play::Singleplayer => ("Singleplayer", None),
Play::Multiplayer => ("Multiplayer", None),
Play::Hosting { players } => ("Hosting a world", Some(players)),
};
(details, Some(place), party)
}
};
let mut a = json!({"details": details, "assets": assets});
if let Some(state) = state {
a["state"] = state.into();
}
if let Some(start) = since {
a["timestamps"] = json!({"start": start});
}
if let Some(players) = party {
let max = crate::lan::MAX_PLAYERS;
a["party"] = json!({
"id": format!("horizon-{}", std::process::id()),
"size": [players.clamp(1, max), max],
});
}
Some(a)
}
/// At most [`BURST`] updates in any [`WINDOW`].
#[derive(Default)]
struct RateLimit {
sent: VecDeque<Instant>,
}
impl RateLimit {
/// How long until the next update may go.
fn wait(&self, now: Instant) -> Duration {
if self.sent.len() < BURST {
return Duration::ZERO;
}
(self.sent[0] + WINDOW).saturating_duration_since(now)
}
fn record(&mut self, now: Instant) {
if self.sent.len() == BURST {
self.sent.pop_front();
}
self.sent.push_back(now);
}
}
fn encode(op: u32, payload: &Value) -> Vec<u8> {
let body = payload.to_string();
let mut frame = Vec::with_capacity(8 + body.len());
frame.extend_from_slice(&op.to_le_bytes());
frame.extend_from_slice(&(body.len() as u32).to_le_bytes());
frame.extend_from_slice(body.as_bytes());
frame
}
fn read_frame(r: &mut impl Read) -> io::Result<(u32, Value)> {
let mut header = [0; 8];
r.read_exact(&mut header)?;
let op = u32::from_le_bytes(header[..4].try_into().expect("4 bytes"));
let len = u32::from_le_bytes(header[4..].try_into().expect("4 bytes")) as usize;
if len > 1 << 20 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"frame too large",
));
}
let mut body = vec![0; len];
r.read_exact(&mut body)?;
Ok((op, serde_json::from_slice(&body)?))
}
trait Pipe: Read + Write + Send {}
impl<T: Read + Write + Send> Pipe for T {}
struct Client {
pipe: Box<dyn Pipe>,
nonce: u64,
}
impl Client {
/// The first Discord socket that answers the handshake with READY.
fn connect() -> Option<Self> {
sockets().into_iter().find_map(|path| {
let mut client = Client {
pipe: open(&path).ok()?,
nonce: 0,
};
match client.handshake() {
Ok(()) => {
log::info!("Horizon: connected to Discord for Rich Presence");
Some(client)
}
Err(e) => {
log::debug!("Horizon: Discord at {} refused: {e}", path.display());
None
}
}
})
}
fn handshake(&mut self) -> io::Result<()> {
let hello = json!({"v": 1, "client_id": DISCORD_APP_ID});
self.pipe.write_all(&encode(HANDSHAKE, &hello))?;
self.reply(|frame| frame["evt"] == "READY")
}
fn set_activity(&mut self, activity: Option<Value>) -> io::Result<()> {
self.nonce += 1;
let nonce = self.nonce.to_string();
let command = json!({
"cmd": "SET_ACTIVITY",
"args": {"pid": std::process::id(), "activity": activity},
"nonce": nonce,
});
self.pipe.write_all(&encode(FRAME, &command))?;
self.reply(|frame| frame["nonce"] == nonce.as_str())
}
/// Reads until the frame `ours` picks, answering pings; an ERROR is logged, not fatal.
fn reply(&mut self, ours: impl Fn(&Value) -> bool) -> io::Result<()> {
loop {
match read_frame(&mut self.pipe)? {
(FRAME, frame) if ours(&frame) => {
if frame["evt"] == "ERROR" {
log::info!("Horizon: Discord presence: {}", frame["data"]["message"]);
}
return Ok(());
}
(PING, frame) => self.pipe.write_all(&encode(PONG, &frame))?,
(CLOSE, frame) => {
return Err(io::Error::new(
io::ErrorKind::ConnectionAborted,
frame["message"].as_str().unwrap_or("closed").to_owned(),
));
}
_ => {}
}
}
}
fn close(mut self) {
let _ = self.pipe.write_all(&encode(CLOSE, &json!({})));
}
}
/// Where Discord listens: `discord-ipc-0..9` in the temporary folders (and Flatpak's and
/// Snap's subfolders) on Linux and macOS, named pipes on Windows.
fn sockets() -> Vec<PathBuf> {
sockets_with(|key| std::env::var_os(key), cfg!(windows))
}
fn sockets_with(var: impl Fn(&str) -> Option<std::ffi::OsString>, windows: bool) -> Vec<PathBuf> {
let names = (0..10).map(|n| format!("discord-ipc-{n}"));
if let Some(dir) = var(IPC_DIR_ENV) {
return names.map(|n| PathBuf::from(&dir).join(n)).collect();
}
if windows {
return names.map(|n| PathBuf::from(r"\\?\pipe\").join(n)).collect();
}
let mut dirs: Vec<PathBuf> = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"]
.into_iter()
.filter_map(&var)
.map(PathBuf::from)
.collect();
dirs.push("/tmp".into());
dirs.dedup();
let mut paths = Vec::new();
for dir in dirs {
for sub in ["", "app/com.discordapp.Discord", "snap.discord"] {
paths.extend(names.clone().map(|n| dir.join(sub).join(n)));
}
}
paths
}
#[cfg(unix)]
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
let stream = std::os::unix::net::UnixStream::connect(path)?;
// A Discord that stops answering costs this thread a few seconds, never the game.
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
Ok(Box::new(stream))
}
#[cfg(windows)]
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
let pipe = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)?;
Ok(Box::new(pipe))
}
#[cfg(test)]
mod tests {
use super::*;
fn world(play: Play, dimension: Dimension) -> Status {
Status::World {
play,
dimension,
mode: GameMode::Survival,
}
}
/// The folders a real Discord listens in: `$TMPDIR` on macOS, `$XDG_RUNTIME_DIR` on Linux (and its
/// Flatpak and Snap subfolders), named pipes on Windows.
#[test]
fn sockets_cover_where_discord_listens() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |key: &str| {
pairs
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| std::ffi::OsString::from(v))
}
};
let macos = sockets_with(env(&[("TMPDIR", "/var/folders/xy/abc/T/")]), false);
assert_eq!(
macos[0],
PathBuf::from("/var/folders/xy/abc/T/discord-ipc-0")
);
assert!(macos.contains(&PathBuf::from("/var/folders/xy/abc/T/discord-ipc-9")));
assert!(macos.contains(&PathBuf::from("/tmp/discord-ipc-0")));
let linux = sockets_with(
env(&[("XDG_RUNTIME_DIR", "/run/user/1000"), ("TMPDIR", "/tmp")]),
false,
);
assert_eq!(linux[0], PathBuf::from("/run/user/1000/discord-ipc-0"));
for path in [
"/run/user/1000/app/com.discordapp.Discord/discord-ipc-0",
"/run/user/1000/snap.discord/discord-ipc-0",
"/tmp/discord-ipc-0",
] {
assert!(linux.contains(&PathBuf::from(path)), "{path}");
}
assert_eq!(linux.len(), 2 * 3 * 10, "/tmp is listed once");
let bare = sockets_with(env(&[]), false);
assert_eq!(bare[0], PathBuf::from("/tmp/discord-ipc-0"));
let windows = sockets_with(env(&[("TEMP", r"C:\Temp")]), true);
assert_eq!(windows.len(), 10);
// `Path::join` only uses `\` as the separator on Windows itself.
#[cfg(windows)]
assert_eq!(windows[0], PathBuf::from(r"\\?\pipe\discord-ipc-0"));
let fake = sockets_with(
env(&[("HORIZON_DISCORD_IPC_DIR", "/tmp/hz"), ("TMPDIR", "/x")]),
false,
);
assert_eq!(fake.len(), 10);
assert_eq!(fake[0], PathBuf::from("/tmp/hz/discord-ipc-0"));
}
#[test]
fn frames_round_trip() {
let frame = encode(FRAME, &json!({"cmd": "SET_ACTIVITY"}));
assert_eq!(&frame[..4], &[1, 0, 0, 0]);
assert_eq!(frame[4..8], (frame.len() as u32 - 8).to_le_bytes());
let (op, value) = read_frame(&mut frame.as_slice()).unwrap();
assert_eq!((op, value), (FRAME, json!({"cmd": "SET_ACTIVITY"})));
assert!(read_frame(&mut &frame[..6]).is_err());
let huge = [1, 0, 0, 0, 0, 0, 0, 2];
assert!(read_frame(&mut huge.as_slice()).is_err());
}
#[test]
fn activities_say_what_the_player_does_and_nothing_private() {
assert_eq!(activity(Status::Off, None), None);
let menus = activity(Status::Menus, None).unwrap();
assert_eq!(menus["details"], "In the menus");
assert!(menus.get("state").is_none() && menus.get("timestamps").is_none());
assert_eq!(menus["assets"]["large_image"], "horizon");
let single = activity(world(Play::Singleplayer, Dimension::Nether), Some(1000)).unwrap();
assert_eq!(single["details"], "Singleplayer");
assert_eq!(single["state"], "In the Nether");
assert_eq!(single["timestamps"]["start"], 1000);
assert!(single.get("party").is_none());
assert!(single["assets"].get("small_image").is_none());
let multi = activity(world(Play::Multiplayer, Dimension::End), Some(5)).unwrap();
assert_eq!(multi["details"], "Multiplayer");
assert_eq!(multi["state"], "In the End");
let host = world(Play::Hosting { players: 3 }, Dimension::Overworld);
let host = activity(host, Some(5)).unwrap();
assert_eq!(host["details"], "Hosting a world");
assert_eq!(host["state"], "In the Overworld");
assert_eq!(host["party"]["size"], json!([3, 8]));
}
#[test]
fn time_counts_from_entering_the_world() {
let mut w = Wanted {
status: Status::Menus,
since: None,
};
assert!(!w.update(Status::Menus, 10));
assert!(w.update(world(Play::Singleplayer, Dimension::Overworld), 20));
assert_eq!(w.since, Some(20));
// Another dimension, opening to LAN: the same visit.
assert!(w.update(world(Play::Singleplayer, Dimension::Nether), 30));
assert!(w.update(world(Play::Hosting { players: 1 }, Dimension::Nether), 40));
assert_eq!(w.since, Some(20));
assert!(w.update(Status::Menus, 50));
assert_eq!(w.since, None);
assert!(w.update(world(Play::Multiplayer, Dimension::Overworld), 60));
assert_eq!(w.since, Some(60));
assert!(w.update(Status::Off, 70));
assert_eq!(w.since, None);
}
#[test]
fn at_most_five_updates_in_twenty_seconds() {
let t = Instant::now();
let mut limit = RateLimit::default();
for i in 0..5 {
let now = t + Duration::from_secs(i);
assert_eq!(limit.wait(now), Duration::ZERO);
limit.record(now);
}
assert_eq!(
limit.wait(t + Duration::from_secs(5)),
Duration::from_secs(15)
);
assert_eq!(limit.wait(t + WINDOW), Duration::ZERO);
limit.record(t + WINDOW);
assert_eq!(limit.wait(t + WINDOW), Duration::from_secs(1));
}
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
#[cfg(unix)]
#[test]
fn talks_to_discord_over_its_socket() {
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("discord-ipc-0");
let _ = std::fs::remove_file(&path);
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
let server = std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let (op, hello) = read_frame(&mut s).unwrap();
assert_eq!((op, &hello["v"]), (HANDSHAKE, &json!(1)));
let ready = json!({"cmd": "DISPATCH", "evt": "READY", "data": {"v": 1}});
s.write_all(&encode(FRAME, &ready)).unwrap();
let mut got = Vec::new();
for _ in 0..2 {
let (op, cmd) = read_frame(&mut s).unwrap();
assert_eq!(op, FRAME);
s.write_all(&encode(PING, &json!({"n": 1}))).unwrap();
assert_eq!(read_frame(&mut s).unwrap(), (PONG, json!({"n": 1})));
let reply = json!({"cmd": "SET_ACTIVITY", "nonce": cmd["nonce"], "data": null});
s.write_all(&encode(FRAME, &reply)).unwrap();
got.push(cmd);
}
got
});
let mut client = Client {
pipe: open(&path).unwrap(),
nonce: 0,
};
client.handshake().unwrap();
let shown = activity(world(Play::Singleplayer, Dimension::End), Some(7));
client.set_activity(shown.clone()).unwrap();
client.set_activity(None).unwrap();
let got = server.join().unwrap();
assert_eq!(got[0]["cmd"], "SET_ACTIVITY");
assert_eq!(got[0]["args"]["pid"], std::process::id());
assert_eq!(got[0]["args"]["activity"], shown.unwrap());
assert_eq!(got[1]["args"]["activity"], Value::Null);
assert_ne!(got[0]["nonce"], got[1]["nonce"]);
let _ = std::fs::remove_dir_all(dir);
}
}
+50 -5
View File
@@ -33,7 +33,11 @@ impl Cost {
#[derive(Clone, Debug)]
pub struct Enchantment {
pub id: Arc<str>,
/// The name the pinned English map gave at load: the fallback for a
/// language that has no entry for the key below.
pub description: String,
/// `description`'s translation key (`enchantment.minecraft.<name>`).
pub description_key: String,
pub max_level: u32,
pub weight: u32,
pub anvil_cost: u32,
@@ -224,6 +228,7 @@ impl Enchantments {
.get(key)
.cloned()
.unwrap_or_else(|| id.trim_start_matches("minecraft:").replace('_', " ")),
description_key: key.to_owned(),
max_level: v["max_level"].as_u64().unwrap_or(1) as u32,
weight: v["weight"].as_u64().unwrap_or(1) as u32,
anvil_cost: v["anvil_cost"].as_u64().unwrap_or(0) as u32,
@@ -329,16 +334,26 @@ impl Enchantments {
}
/// `Enchantment.getFullname` as plain text and whether it is a curse (red).
pub fn full_name(&self, id: &str, level: u32) -> (String, bool) {
let language = crate::lang::current();
self.full_name_in(language.as_deref(), id, level)
}
/// `full_name` against `language`; `None` (no language loaded: a
/// dedicated server, the probes) keeps the English names loaded above.
pub fn full_name_in(
&self,
language: Option<&crate::lang::Language>,
id: &str,
level: u32,
) -> (String, bool) {
let tr = |key: &str| language.and_then(|l| l.get(key).map(str::to_owned));
let Some(e) = self.get(id) else {
return (format!("{id} {}", level_name(level)), false);
};
let mut name = e.description.clone();
let mut name = tr(&e.description_key).unwrap_or_else(|| e.description.clone());
if level != 1 || e.max_level != 1 {
name.push(' ');
name.push_str(
self.lang
.get(&format!("enchantment.level.{level}"))
.map_or(&level_name(level), |s| s),
&tr(&format!("enchantment.level.{level}")).unwrap_or_else(|| level_name(level)),
);
}
(name, self.is_curse(id))
@@ -771,7 +786,7 @@ pub(crate) mod tests {
let defs = [
(
"minecraft:sharpness",
json!({"anvil_cost":1,"exclusive_set":"#minecraft:exclusive_set/damage","max_cost":{"base":21,"per_level_above_first":11},"max_level":5,"min_cost":{"base":1,"per_level_above_first":11},"primary_items":"#minecraft:enchantable/melee_weapon","supported_items":"#minecraft:enchantable/sharp_weapon","weight":10,"slots":["mainhand"],"effects":{"minecraft:damage":[{"effect":{"type":"minecraft:add","value":{"type":"minecraft:linear","base":1.0,"per_level_above_first":0.5}}}]}}),
json!({"anvil_cost":1,"description":{"translate":"enchantment.minecraft.sharpness"},"exclusive_set":"#minecraft:exclusive_set/damage","max_cost":{"base":21,"per_level_above_first":11},"max_level":5,"min_cost":{"base":1,"per_level_above_first":11},"primary_items":"#minecraft:enchantable/melee_weapon","supported_items":"#minecraft:enchantable/sharp_weapon","weight":10,"slots":["mainhand"],"effects":{"minecraft:damage":[{"effect":{"type":"minecraft:add","value":{"type":"minecraft:linear","base":1.0,"per_level_above_first":0.5}}}]}}),
),
(
"minecraft:smite",
@@ -795,6 +810,7 @@ pub(crate) mod tests {
.map(|(id, v)| Enchantment {
id: Arc::from(*id),
description: id.trim_start_matches("minecraft:").into(),
description_key: v["description"]["translate"].as_str().unwrap_or("").into(),
max_level: v["max_level"].as_u64().unwrap() as u32,
weight: v["weight"].as_u64().unwrap() as u32,
anvil_cost: v["anvil_cost"].as_u64().unwrap() as u32,
@@ -1088,4 +1104,33 @@ pub(crate) mod tests {
assert!(!e.compatible("minecraft:sharpness", "minecraft:smite"));
assert!(e.compatible("minecraft:sharpness", "minecraft:unbreaking"));
}
/// The enchantment's name and its level come from the selected language,
/// with the English name loaded above as the fallback.
#[test]
fn names_follow_the_language() {
struct Mem(HashMap<String, Vec<u8>>);
impl crate::lang::ResourceSource for Mem {
fn read(&self, path: &str) -> Option<Vec<u8>> {
self.0.get(path).cloned()
}
}
let ru = Mem(HashMap::from([(
"assets/minecraft/lang/ru_ru.json".to_owned(),
r#"{"enchantment.minecraft.sharpness":"Острота","enchantment.level.5":"V"}"#
.as_bytes()
.to_vec(),
)]));
let ru = crate::lang::Language::load(&[&ru], "ru_ru");
let e = sample();
assert_eq!(
e.full_name_in(Some(&ru), "minecraft:sharpness", 5).0,
"Острота V"
);
// mending carries no key in the fixture: the English name stays.
assert_eq!(
e.full_name_in(Some(&ru), "minecraft:mending", 1).0,
"mending"
);
}
}
+60 -7
View File
@@ -10,6 +10,8 @@ use crate::{
use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::block_collision::BlockCollisionRegistry;
mod fluids;
#[derive(Clone, Debug)]
pub struct ItemEntity {
pub health: i32,
@@ -165,18 +167,16 @@ impl ItemEntity {
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
fluids: &crate::motion::FluidResources,
) -> bool {
self.ticks += 1;
self.previous = self.position;
if self.pickup_delay > 0 && self.pickup_delay != u32::MAX {
self.pickup_delay -= 1;
}
let eye = world.block(
self.position.x.floor() as i32,
(self.position.y + 0.1).floor() as i32,
self.position.z.floor() as i32,
);
let in_water = facts.get(eye).fluid == Fluid::Water;
// Entity.baseTick pushes before ItemEntity applies buoyancy and moves.
let (water_height, lava_height) = self.update_fluids(world, fluids);
let in_water = water_height > f64::from(0.1f32);
if in_water {
// ItemEntity.setUnderwaterMovement
self.velocity = Vec3::new(
@@ -184,7 +184,7 @@ impl ItemEntity {
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
self.velocity.z * 0.99,
);
} else if facts.get(eye).fluid == Fluid::Lava {
} else if lava_height > f64::from(0.1f32) {
self.velocity = Vec3::new(
self.velocity.x * 0.95,
self.velocity.y + if self.velocity.y < 0.06 { 5.0e-4 } else { 0.0 },
@@ -245,6 +245,8 @@ impl ItemEntity {
}
}
self.age += 1;
// ItemEntity.tick refreshes immersion and pushes again after moving.
self.update_fluids(world, fluids);
self.age < 6000 && self.position.y > -128.0
}
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
@@ -589,6 +591,57 @@ impl Painting {
)
}
}
/// The `DyeColor` ordinal of a colour name (`DyeColor.byName`), white when
/// the name is unknown.
pub fn dye_index(name: &str) -> u8 {
crate::mobs::render::DYE_NAMES
.iter()
.position(|n| *n == name)
.unwrap_or(0) as u8
}
/// A cushion (`Cushion`, a `BlockAttachedEntity`): a 1 x 0.25 block pillow
/// that seats a player. It sits on the top face of a block, at the clicked
/// height, and is coloured by its `DyeColor`.
#[derive(Clone, Debug)]
pub struct Cushion {
pub id: u32,
/// `Entity.position`: the bottom centre of the cushion.
pub position: Vec3,
/// `BlockAttachedEntity.pos` (the saved `block_pos`).
pub pos: [i32; 3],
pub yaw: f32,
/// `DyeColor` ordinal: white 0, orange 1, ..., black 15.
pub color: u8,
/// BlockAttachedEntity.ticksSinceLastCheck.
pub check_interval: u32,
}
impl Cushion {
/// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25.
pub fn bounds(&self) -> ([f64; 3], [f64; 3]) {
let p = self.position;
(
[p.x - 0.5, p.y, p.z - 0.5],
[p.x + 0.5, p.y + 0.25, p.z + 0.5],
)
}
/// The `DyeColor` name of the cushion.
pub fn color_name(&self) -> &'static str {
crate::mobs::render::DYE_NAMES[usize::from(self.color.min(15))]
}
/// `Cushion.getCushionItemStackWithData`: the matching coloured item.
pub fn item(&self) -> String {
format!("minecraft:{}_cushion", self.color_name())
}
/// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment
/// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`.
pub fn seat(&self) -> Vec3 {
Vec3::new(self.position.x, self.position.y - 0.35, self.position.z)
}
/// `Entity.getDismountLocationForPassenger`: on top of the cushion.
pub fn dismount(&self) -> Vec3 {
Vec3::new(self.position.x, self.position.y + 0.25, self.position.z)
}
}
/// An item frame or glow item frame (`ItemFrame`).
#[derive(Clone, Debug)]
pub struct ItemFrame {
@@ -0,0 +1,216 @@
//! Dropped-item immersion and currents through the shared vanilla fluid helpers.
use super::ItemEntity;
use crate::{
motion::{FluidResources, FluidTags, LAVA, WATER},
world::World,
};
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
position::BlockPos,
};
use horizon_world::{
entity_fluid::{
CurrentTarget, EntityFluidInteraction, FluidInteractionWorld, FluidMembership,
FluidSections, FluidTagId,
},
fluid::{FluidHeightWorld, FluidKind, FluidState},
fluid_flow::{FluidFlowWorld, flow_at},
registry::BlockStateId,
};
type Error = std::convert::Infallible;
struct ItemFluidWorld<'a> {
item: &'a mut ItemEntity,
world: &'a World,
fluids: &'a FluidResources,
}
impl ItemEntity {
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
let mut scene = ItemFluidWorld {
item: self,
world,
fluids,
};
if interaction.update(&mut scene, false, 4096).is_err() {
return (0.0, 0.0);
}
let water_height = interaction.fluid_height(&scene, WATER);
let lava_height = interaction.fluid_height(&scene, LAVA);
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
let _ = interaction.apply_current(
&mut scene,
LAVA,
if world.fast_lava {
0.007
} else {
0.0023333333333333335
},
);
(water_height, lava_height)
}
}
impl FluidMembership for ItemFluidWorld<'_> {
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
FluidTags.fluid_in_tag(kind, tag)
}
}
impl CurrentTarget for ItemFluidWorld<'_> {
type Error = Error;
fn is_player_class(&self) -> bool {
false
}
fn velocity(&mut self) -> Result<Vec3, Error> {
Ok(self.item.velocity)
}
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Error> {
let v = self.item.velocity;
let sum = Vec3::new(v.x + delta.x, v.y + delta.y, v.z + delta.z);
if delta.is_finite() && sum.is_finite() {
self.item.velocity = sum;
}
Ok(())
}
}
impl FluidInteractionWorld for ItemFluidWorld<'_> {
type Error = Error;
type Chunk = ();
fn interaction_box(&mut self) -> Result<Option<Aabb>, Error> {
Ok(Some(
self.item.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
))
}
fn loaded_chunk(&mut self, x: i32, z: i32) -> Result<Option<()>, Error> {
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
})
}
fn bounding_box_min_y(&self) -> f64 {
self.item.position.y
}
fn block_x(&self) -> i32 {
self.item.position.x.floor() as i32
}
fn eye_y(&mut self) -> Result<f64, Error> {
Ok(self.item.position.y + f64::from(0.2125f32))
}
fn block_z(&self) -> i32 {
self.item.position.z.floor() as i32
}
fn fluid_state(&mut self, p: BlockPos) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
}
fn fluid_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
f.height_at(self, [p.x, p.y, p.z])
}
fn camera_height(&mut self, f: FluidState, p: BlockPos) -> Result<f32, Error> {
f.camera_height_at(self, [p.x, p.y, p.z])
}
fn flow(&mut self, f: FluidState, p: BlockPos) -> Result<Vec3, Error> {
flow_at(self, f, p)
}
}
impl FluidHeightWorld for ItemFluidWorld<'_> {
type Error = Error;
fn fluid(&mut self, p: [i32; 3]) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p[0], p[1], p[2])))
}
fn sturdy_down(&mut self, p: [i32; 3]) -> Result<bool, Error> {
let state = self.world.block(p[0], p[1], p[2]);
Ok(self.fluids.sturdy(state, Direction::Down))
}
}
impl FluidFlowWorld for ItemFluidWorld<'_> {
type Error = Error;
type Block = BlockStateId;
fn fluid(&mut self, p: BlockPos) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
}
fn block(&mut self, p: BlockPos) -> Result<BlockStateId, Error> {
Ok(self.world.block(p.x, p.y, p.z))
}
fn blocks_fluid_flow(&self, s: BlockStateId) -> bool {
self.fluids.blocks_flow(s)
}
fn is_ice_class(&self, s: BlockStateId) -> bool {
self.fluids.ice(s)
}
fn is_face_sturdy(
&mut self,
s: BlockStateId,
_: BlockPos,
d: Direction,
) -> Result<bool, Error> {
Ok(self.fluids.sturdy(s, d))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let mut state = assets.registry.state(water).unwrap().clone();
state.properties.insert("level".into(), "1".into());
let flowing = assets.registry.resolve(&state).unwrap();
for x in 19..=21 {
for z in 19..=21 {
world.set(x, 3, z, stone).unwrap();
world.set(x, 4, z, water).unwrap();
}
}
let at = Vec3::new(20.5, 4.0, 20.5);
let item = ItemEntity {
id: 1,
health: 5,
fire_ticks: 0,
stack: crate::items::ItemStack {
item: "minecraft:sugar_cane".into(),
count: 1,
damage: 0,
extra: None,
},
thrower: None,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
};
let mut still = item.clone();
assert!(still.tick(&world, &facts, &collision, false, &fluids));
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
world.set(21, 4, 20, flowing).unwrap();
let mut carried = item;
assert!(carried.tick(&world, &facts, &collision, false, &fluids));
// First push is damped by buoyancy before motion, second follows drag.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
for _ in 0..20 {
carried.tick(&world, &facts, &collision, false, &fluids);
}
assert!(carried.position.x > at.x + 0.5);
}
}
+12 -1
View File
@@ -166,12 +166,23 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
if d >= end { return 1.0; }
return (d - start) / (end - start);
}
// CRUMBLING_UV_BEGIN
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
let size = vec2<f32>(dimensions);
// Undo stage_light's 16-bit quantization before adding local texels. Otherwise
// different stages can fall on opposite sides of a nearest-sampling boundary.
let origin = round(packed_origin * size);
let extent = max(round(sprite_size * size), vec2(1.0));
let local = min(floor(fract(uv) * extent), extent - vec2(1.0));
return (origin + local + vec2(0.5)) / size;
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.overlay == 11u || in.overlay == 12u {
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
uv = in.wrap + fract(in.uv) * globals.crumbling_sprite.zw;
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
}
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
if in.overlay == 9u || in.overlay == 10u {
+15 -5
View File
@@ -25,6 +25,20 @@ impl Culler {
vertices: &mut Vec<crate::mesher::Vertex>,
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
let hidden = self.hidden(vertices, start, opaque);
if hidden {
vertices.truncate(start);
}
hidden
}
/// Whether this entity's completed camera-relative mesh (`vertices[start..]`) is hidden.
pub fn hidden(
&self,
vertices: &[crate::mesher::Vertex],
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if start >= vertices.len() || self.remaining.get() == 0 {
return false;
@@ -42,11 +56,7 @@ impl Culler {
}
}
// Cover floating-point and rasterization edges as well as the vertices.
if self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque) {
vertices.truncate(start);
return true;
}
false
self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque)
}
/// Returns true only when a complete opaque block plane covers the bounds.
+196 -9
View File
@@ -6,7 +6,7 @@
//! survive a Horizon save.
use crate::{
arrows::Pickup,
entities::{ArmorStand, ExperienceOrb, ItemEntity, ItemFrame, Painting},
entities::{ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting},
mobs::{Mob, MobType, Who},
projectiles::{Projectile, ProjectileKind},
save::{
@@ -617,9 +617,23 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.head_pitch = pitch;
m.on_ground = flag(tag, "OnGround");
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
if let Some(h) = float(tag.get("Health")) {
m.health = h as f32;
// LivingEntity.readAdditionalSaveData: the saved attribute bases, then
// the health, the max health when there is none.
if let Some(Tag::List(attrs)) = tag.get("attributes") {
for a in attrs {
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
continue;
};
let v = f64::from_bits(*bits);
match id.as_str() {
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
_ => {}
}
}
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
@@ -735,12 +749,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else {
continue;
};
let v = f64::from_bits(*bits);
match id.as_str() {
"minecraft:max_health" => m.attrs.max_health = v as f32,
"minecraft:movement_speed" => m.attrs.speed = v,
"minecraft:jump_strength" => m.extra.vector[0] = v,
_ => {}
if id == "minecraft:jump_strength" {
m.extra.vector[0] = f64::from_bits(*bits);
}
}
}
@@ -1155,6 +1165,72 @@ pub fn arrow_from_nbt(
Some((p, tag.get("inBlockState").cloned()))
}
/// `LargeFireball` and `SmallFireball`: `AbstractHurtingProjectile`'s
/// `acceleration_power`, `Projectile`'s owner and flags, `ExplosionPower`.
pub fn fireball_to_nbt(
p: &Projectile,
uuid: [i32; 4],
owner: Option<[i32; 4]>,
raw: Option<&Tag>,
) -> Option<Tag> {
let power = match p.kind {
ProjectileKind::LargeFireball { power } => Some(power),
ProjectileKind::SmallFireball => None,
_ => return None,
};
let base = Base {
id: p.kind.id(),
uuid,
position: p.position,
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(
&mut tag,
"acceleration_power",
Tag::Double(p.acceleration.to_bits()),
);
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
// (a fireball saved before its first tick keeps what it was loaded with).
if let Tag::Compound(map) = &mut tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
if let Some(power) = power {
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
}
if let Some(owner) = owner {
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
}
Some(tag)
}
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = match kind(tag)?.as_str() {
"minecraft:fireball" => ProjectileKind::LargeFireball {
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
},
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
_ => return None,
};
let owner = uuid_from_tag(tag.get("Owner"));
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
Some(p)
}
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
#[allow(clippy::too_many_arguments)]
pub fn falling_block_to_nbt(
@@ -1237,6 +1313,79 @@ pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> {
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
Some((pos, facing, text(tag.get("variant"))?))
}
/// Cushion.addAdditionalSaveData: the block it sits on and its colour.
pub fn cushion_to_nbt(c: &Cushion, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let base = Base {
id: "minecraft:cushion",
uuid,
position: c.position,
motion: Vec3::ZERO,
rotation: [c.yaw, 0.0],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(&mut tag, "block_pos", Tag::IntArray(c.pos.to_vec()));
put(&mut tag, "color", string(c.color_name()));
tag
}
/// The position, block position, yaw and colour of a saved cushion.
pub fn cushion_from_nbt(tag: &Tag, id: u32) -> Option<Cushion> {
let position = position(tag)?;
let pos = match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
_ => [
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
],
};
let yaw = match tag.get("Rotation") {
Some(Tag::List(l)) => l
.first()
.and_then(|v| crate::save::float(Some(v)))
.unwrap_or(0.0) as f32,
_ => 0.0,
};
let color =
crate::entities::dye_index(&text(tag.get("color")).unwrap_or_else(|| "white".into()));
Some(Cushion {
id,
position,
pos,
yaw,
color,
check_interval: 0,
})
}
#[cfg(test)]
mod cushion_tests {
use super::*;
#[test]
fn cushion_save_preserves_color_anchor_rotation_and_unknown_data() {
let cushion = Cushion {
id: 7,
position: Vec3::new(-1.5, 64.5, 3.5),
pos: [-2, 64, 3],
yaw: 270.0,
color: 14,
check_interval: 55,
};
let raw = Tag::compound([("CustomName", string("Seat"))]);
let tag = cushion_to_nbt(&cushion, [1, 2, 3, 4], Some(&raw));
assert_eq!(text(tag.get("CustomName")).as_deref(), Some("Seat"));
let loaded = cushion_from_nbt(&tag, 8).unwrap();
assert_eq!(loaded.id, 8);
assert_eq!(loaded.position, cushion.position);
assert_eq!(loaded.pos, cushion.pos);
assert_eq!(loaded.yaw, cushion.yaw);
assert_eq!(loaded.color_name(), "red");
assert_eq!(loaded.check_interval, 0);
assert_eq!(loaded.bounds(), cushion.bounds());
}
}
/// ItemFrame.addAdditionalSaveData.
pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let (lo, hi) = f.bounds();
@@ -2139,3 +2288,41 @@ mod sheep_nbt_tests {
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
}
#[cfg(test)]
mod fireball_nbt_tests {
use super::*;
#[test]
fn a_fireball_round_trips_its_flight() {
let raw = Tag::compound([
("id", Tag::String("minecraft:fireball".into())),
("Pos", doubles(&[1.0, 70.0, -2.5])),
("Motion", doubles(&[0.0, -0.5, 1.0])),
("ExplosionPower", Tag::Byte(4)),
("acceleration_power", Tag::Double(0.0f64.to_bits())),
]);
let p = fireball_from_nbt(&raw, None).unwrap();
assert_eq!(p.kind, ProjectileKind::LargeFireball { power: 4 });
assert_eq!((p.velocity.z, p.acceleration), (1.0, 0.0));
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, None).unwrap();
assert_eq!(number(back.get("ExplosionPower")), Some(4));
assert_eq!(float(back.get("acceleration_power")), Some(0.0));
// Projectile.addAdditionalSaveData: no LeftOwner unless set, HasBeenShot always.
assert!(back.get("LeftOwner").is_none());
assert_eq!(number(back.get("HasBeenShot")), Some(0));
assert_eq!(
read_doubles::<3>(back.get("Motion")),
Some([0.0, -0.5, 1.0])
);
// A default fireball keeps vanilla's power and acceleration.
let plain = Tag::compound([
("id", Tag::String("minecraft:fireball".into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
let p = fireball_from_nbt(&plain, None).unwrap();
assert_eq!(
(p.kind, p.acceleration),
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
);
}
}
+53 -332
View File
@@ -1,21 +1,13 @@
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
//! Horizon's optional extras (minimap, lighting, inventory tools): their
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`.
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
/// The name the options screen uses while the zoom key waits for a key.
pub const ZOOM_KEY: &str = "horizon.zoom";
/// The same for the sort key.
/// The name the options screen uses while the sort key waits for a key.
pub const SORT_KEY: &str = "horizon.sort";
/// Zoom levels the default cycles through, and the wheel's limits.
pub const ZOOM_LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
pub const ZOOM_MIN: f32 = 1.25;
/// `Camera.tickFov` keeps the field-of-view modifier at 0.1 or more.
pub const ZOOM_MAX: f32 = 10.0;
/// Each wheel step multiplies the zoom by this.
const ZOOM_STEP: f32 = 1.25;
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
@@ -80,6 +72,9 @@ pub struct Extras {
pub zoom_smooth: bool,
/// Divide mouse turning by the zoom.
pub zoom_sensitivity: bool,
/// The FPS counter and keystrokes extras are official mods now
/// (`moved_extras`): their old settings stay here until the game has
/// installed the mod for a player who had one on.
pub fps: bool,
pub fps_corner: Corner,
/// Also show the minimum and 1% low.
@@ -88,6 +83,9 @@ pub struct Extras {
pub keystrokes_corner: Corner,
/// Clicks per second under the mouse buttons.
pub keystrokes_cps: bool,
/// The official mods installed for the extras they replace, by mod id
/// (`movedToMods`): each is done once.
pub moved: Vec<String>,
pub minimap: bool,
pub minimap_corner: Corner,
/// Side in GUI pixels (`MINIMAP_SIZES`).
@@ -112,6 +110,8 @@ pub struct Extras {
/// Agents may control the game the player starts: it opens its control socket for
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
pub agent_control: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -129,6 +129,7 @@ impl Default for Extras {
keystrokes: false,
keystrokes_corner: Corner::TopLeft,
keystrokes_cps: true,
moved: Vec::new(),
minimap: false,
minimap_corner: Corner::TopRight,
minimap_size: 96,
@@ -142,6 +143,7 @@ impl Default for Extras {
sort_key: "key.keyboard.unknown".into(),
no_ai: false,
agent_control: false,
discord_presence: true,
}
}
}
@@ -173,7 +175,7 @@ impl Extras {
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
"zoomLevel" => {
if let Ok(v) = value.parse::<u8>() {
e.zoom_level = v.clamp(2, ZOOM_MAX as u8);
e.zoom_level = v.clamp(2, 10);
}
}
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
@@ -186,6 +188,14 @@ impl Extras {
e.keystrokes_corner = Corner::parse(value, e.keystrokes_corner)
}
"keystrokesCps" => e.keystrokes_cps = flag(e.keystrokes_cps),
"movedToMods" => {
e.moved = value
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned)
.collect()
}
"minimap" => e.minimap = flag(e.minimap),
"minimapCorner" => e.minimap_corner = Corner::parse(value, e.minimap_corner),
"minimapSize" => {
@@ -207,6 +217,7 @@ impl Extras {
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
_ => {}
}
}
@@ -214,7 +225,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -228,6 +239,7 @@ impl Extras {
bool_text(self.keystrokes),
self.keystrokes_corner.name(),
bool_text(self.keystrokes_cps),
self.moved.join(","),
bool_text(self.minimap),
self.minimap_corner.name(),
self.minimap_size,
@@ -246,13 +258,13 @@ impl Extras {
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.discord_presence),
)
}
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
/// Binds the extra key `name` (`SORT_KEY`); false for
/// another name (a vanilla mapping).
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
ZOOM_KEY => self.zoom_key = value,
SORT_KEY => self.sort_key = value,
_ => return false,
}
@@ -269,87 +281,12 @@ impl Extras {
}
}
/// The zoom key's state: held or not, and the zoom the wheel has set.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Zoom {
held: bool,
factor: f32,
}
impl Default for Zoom {
fn default() -> Self {
Self {
held: false,
factor: 1.0,
}
}
}
impl Zoom {
/// The key went down: start at the default level.
pub fn press(&mut self, level: u8) {
self.held = true;
self.factor = f32::from(level).clamp(ZOOM_MIN, ZOOM_MAX);
}
pub fn release(&mut self) {
self.held = false;
}
/// A press or release of the zoom key. Only a press while `playing` (no
/// screen or text field open) zooms; anywhere else the key belongs to
/// the screen, and the zoom lets go.
pub fn key(&mut self, pressed: bool, repeat: bool, playing: bool, level: u8) {
if !pressed || !playing {
self.release();
} else if !repeat {
self.press(level);
}
}
pub fn held(&self) -> bool {
self.held
}
/// Wheel steps while held (positive zooms in); false when not zooming.
pub fn scroll(&mut self, steps: f32) -> bool {
if !self.held {
return false;
}
self.factor = (self.factor * ZOOM_STEP.powf(steps)).clamp(ZOOM_MIN, ZOOM_MAX);
true
}
/// The zoom in effect: 1 when the key is up or the spyglass is in use
/// (scoping keeps vanilla's spyglass view).
pub fn factor(&self, scoping: bool) -> f32 {
if self.held && !scoping {
self.factor
} else {
1.0
}
}
}
/// Vanilla key mappings bound to the zoom key. The key still reaches them
/// (a key bound twice triggers both, as vanilla's do), so the Extras page
/// says so.
pub fn zoom_key_conflicts(e: &Extras, keys: &crate::keybinds::KeyBinds) -> Vec<&'static str> {
if e.zoom_key == "key.keyboard.unknown" {
return Vec::new();
}
crate::keybinds::MAPPINGS
.iter()
.map(|m| m.0)
.filter(|name| keys.get(name) == e.zoom_key)
.collect()
}
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
/// (`getFieldOfViewModifier`) divided by the zoom. A zoom of 1 leaves it as is.
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
/// zoom of 1 leaves it as is.
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
if zoom == 1.0 { vanilla } else { vanilla / zoom }
}
/// The mouse turning scale while zoomed.
pub fn turn_scale(zoom: f32, lower_sensitivity: bool) -> f64 {
if lower_sensitivity && zoom > 1.0 {
1.0 / f64::from(zoom)
} else {
1.0
}
}
/// Frame times over the last few seconds: frames per second, the minimum
/// and the 1% low, recomputed twice a second.
pub struct FpsCounter {
@@ -441,23 +378,6 @@ impl Cps {
pub enum Item {
Fullbright,
DynamicLights,
Zoom,
ZoomKey,
ZoomLevel,
ZoomSmooth,
ZoomSensitivity,
Fps,
FpsCorner,
FpsLows,
Keystrokes,
KeystrokesCorner,
KeystrokesCps,
Minimap,
MinimapCorner,
MinimapSize,
MinimapShape,
MinimapZoom,
MinimapRotate,
AutoReconnect,
MouseTweaks,
MouseTweaksWheel,
@@ -466,6 +386,8 @@ pub enum Item {
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -474,27 +396,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("Lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Zoom"),
Ok(Zoom),
Ok(ZoomKey),
Ok(ZoomLevel),
Ok(ZoomSmooth),
Ok(ZoomSensitivity),
Err("Minimap"),
Ok(Minimap),
Ok(MinimapCorner),
Ok(MinimapSize),
Ok(MinimapShape),
Ok(MinimapZoom),
Ok(MinimapRotate),
Err("FPS counter"),
Ok(Fps),
Ok(FpsCorner),
Ok(FpsLows),
Err("Keystrokes"),
Ok(Keystrokes),
Ok(KeystrokesCorner),
Ok(KeystrokesCps),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
@@ -504,62 +405,29 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(AutoReconnect),
]
}
/// "1 block", "2 blocks", "½ block".
fn block_count(n: f32) -> String {
if n < 1.0 {
"½ block".into()
} else if n == 1.0 {
"1 block".into()
} else {
format!("{n} blocks")
}
}
fn next<T: Copy + PartialEq>(all: &[T], current: T, back: bool) -> T {
let i = all.iter().position(|v| *v == current).unwrap_or(0);
let n = all.len();
all[if back { (i + n - 1) % n } else { (i + 1) % n }]
}
impl Item {
pub fn caption(self) -> &'static str {
match self {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::Zoom => "Zoom",
Item::ZoomKey => "Zoom key",
Item::ZoomLevel => "Starting zoom",
Item::ZoomSmooth => "Smooth zoom",
Item::ZoomSensitivity => "Slower turning",
Item::Fps => "FPS counter",
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => "Corner",
Item::Minimap => "Minimap",
Item::MinimapSize => "Size",
Item::MinimapShape => "Shape",
Item::MinimapZoom => "Zoom",
Item::MinimapRotate => "Turn with the player",
Item::AutoReconnect => "Auto-reconnect",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
Item::SortKey => "Sort key",
Item::FpsLows => "Minimum and 1% low",
Item::Keystrokes => "Keystrokes",
Item::KeystrokesCps => "Clicks per second",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
pub fn parent(self) -> Option<Item> {
use Item::*;
Some(match self {
ZoomKey | ZoomLevel | ZoomSmooth | ZoomSensitivity => Zoom,
MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap,
FpsCorner | FpsLows => Fps,
KeystrokesCorner | KeystrokesCps => Keystrokes,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl => {
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
return None;
}
})
@@ -571,24 +439,9 @@ impl Item {
Item::DynamicLights => {
"Torches and other light sources held, dropped or on fire light the blocks around them."
}
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
Item::ZoomLevel => "The zoom the key starts at.",
Item::ZoomSmooth => "Eases in and out like the spyglass.",
Item::ZoomSensitivity => "Turns slower the more you zoom in.",
Item::Fps => "Frames per second in a corner of the screen.",
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => {
"Overlays in one corner stack."
}
Item::Minimap => "A map of the loaded terrain around you, in map colours.",
Item::MinimapSize => "How big the map is on screen.",
Item::MinimapShape => "Square or round.",
Item::MinimapZoom => "Blocks per pixel: smaller shows more detail, larger more land.",
Item::MinimapRotate => "Forward is up; off keeps north up.",
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
Item::FpsLows => "The slowest frame and the 1% low of the last 5 seconds.",
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -601,11 +454,12 @@ impl Item {
Item::SortKey => {
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
}
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
@@ -615,8 +469,8 @@ impl Item {
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
use crate::graphics_style::Cost;
match self {
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
Item::Fullbright => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
_ => None,
}
@@ -626,85 +480,45 @@ impl Item {
Some(match self {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::Zoom => e.zoom,
Item::ZoomSmooth => e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity,
Item::Fps => e.fps,
Item::FpsLows => e.fps_lows,
Item::Keystrokes => e.keystrokes,
Item::KeystrokesCps => e.keystrokes_cps,
Item::Minimap => e.minimap,
Item::MinimapRotate => e.minimap_rotate,
Item::AutoReconnect => e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
_ => return None,
})
}
/// The value of the other items, as their button shows it.
pub fn value(self, e: &Extras) -> String {
match self {
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::ZoomLevel => format!("{}x", e.zoom_level),
Item::FpsCorner => e.fps_corner.label().into(),
Item::KeystrokesCorner => e.keystrokes_corner.label().into(),
Item::MinimapCorner => e.minimap_corner.label().into(),
Item::MinimapSize => MINIMAP_SIZES
.iter()
.find(|s| s.0 == e.minimap_size)
.map_or("Medium", |s| s.1)
.into(),
Item::MinimapShape => if e.minimap_round { "Round" } else { "Square" }.into(),
Item::MinimapZoom => format!("{} per pixel", block_count(e.minimap_zoom)),
other => match other.switch(e) {
Some(true) => "On".into(),
_ => "Off".into(),
},
}
}
/// A click (`back` for the right button). The zoom key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
/// 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) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::Zoom => e.zoom = !e.zoom,
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
Item::Fps => e.fps = !e.fps,
Item::FpsLows => e.fps_lows = !e.fps_lows,
Item::Keystrokes => e.keystrokes = !e.keystrokes,
Item::KeystrokesCps => e.keystrokes_cps = !e.keystrokes_cps,
Item::ZoomLevel => e.zoom_level = next(&ZOOM_LEVELS, e.zoom_level, back),
Item::FpsCorner => e.fps_corner = next(&Corner::ALL, e.fps_corner, back),
Item::KeystrokesCorner => {
e.keystrokes_corner = next(&Corner::ALL, e.keystrokes_corner, back)
}
Item::Minimap => e.minimap = !e.minimap,
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::MinimapCorner => e.minimap_corner = next(&Corner::ALL, e.minimap_corner, back),
Item::MinimapSize => {
let sizes: Vec<u8> = MINIMAP_SIZES.iter().map(|s| s.0).collect();
e.minimap_size = next(&sizes, e.minimap_size, back);
}
Item::MinimapShape => e.minimap_round = !e.minimap_round,
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ZoomKey | Item::SortKey => {}
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::SortKey => {}
}
}
/// The key name the options screen waits on for a key item.
pub fn key_name(self) -> Option<&'static str> {
match self {
Item::ZoomKey => Some(ZOOM_KEY),
Item::SortKey => Some(SORT_KEY),
_ => None,
}
@@ -721,6 +535,7 @@ mod tests {
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -735,6 +550,7 @@ mod tests {
e.fps_corner = Corner::BottomRight;
e.keystrokes = true;
e.keystrokes_cps = false;
e.moved = vec!["fps_overlay".into(), "keystrokes".into()];
e.minimap = true;
e.minimap_size = 128;
e.minimap_round = true;
@@ -746,6 +562,7 @@ mod tests {
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.discord_presence = false;
assert_eq!(Extras::parse(&e.serialize()), e);
}
@@ -759,43 +576,7 @@ mod tests {
}
#[test]
fn default_zoom_key_is_free_in_vanilla() {
let key = Extras::default().zoom_key;
assert!(crate::keybinds::key_code(&key).is_some());
assert!(
crate::keybinds::MAPPINGS.iter().all(|m| m.1 != key),
"{key} is a vanilla binding"
);
}
#[test]
fn zoom_is_vanilla_unless_held_and_not_scoping() {
let mut z = Zoom::default();
assert_eq!(z.factor(false), 1.0);
assert_eq!(fov_target(1.1, z.factor(false)), 1.1);
assert!(!z.scroll(1.0), "the wheel is the hotbar's when not zooming");
z.press(4);
assert_eq!(z.factor(false), 4.0);
assert_eq!(z.factor(true), 1.0, "the spyglass wins");
assert!((fov_target(1.0, 4.0) - 0.25).abs() < 1e-6);
assert!(z.scroll(1.0));
assert!((z.factor(false) - 5.0).abs() < 1e-5);
for _ in 0..40 {
z.scroll(1.0);
}
assert_eq!(z.factor(false), ZOOM_MAX);
for _ in 0..80 {
z.scroll(-1.0);
}
assert_eq!(z.factor(false), ZOOM_MIN);
z.release();
assert_eq!(z.factor(false), 1.0);
z.press(2);
assert_eq!(z.factor(false), 2.0, "each press starts at the default");
}
#[test]
fn zoom_target_eases_like_the_spyglass() {
fn a_mods_zoom_eases_like_the_spyglass() {
// Camera.tickFov halves the distance to the target each tick.
let mut camera = crate::camera::Camera::default();
camera.tick(None, 1.0);
@@ -819,55 +600,6 @@ mod tests {
assert!((floor - 7.0).abs() < 1e-3, "{floor}");
}
#[test]
fn the_zoom_key_only_zooms_while_playing() {
let mut z = Zoom::default();
// In chat, a sign, a text field or any screen: no zoom.
z.key(true, false, false, 4);
assert!(!z.held());
z.key(true, false, true, 4);
assert!(z.held());
// A screen opening while held lets go (its key presses are the screen's).
z.key(true, true, false, 4);
assert!(!z.held());
// Key repeats do not restart a zoom the wheel changed.
z.key(true, false, true, 4);
z.scroll(1.0);
z.key(true, true, true, 4);
assert!((z.factor(false) - 5.0).abs() < 1e-5);
z.key(false, false, true, 4);
assert!(!z.held());
}
#[test]
fn zoom_key_conflicts_name_the_vanilla_mappings() {
let mut e = Extras::default();
let mut keys = crate::keybinds::KeyBinds::default();
assert!(zoom_key_conflicts(&e, &keys).is_empty());
e.zoom_key = "key.keyboard.c".into();
assert_eq!(
zoom_key_conflicts(&e, &keys),
[
"key.saveToolbarActivator",
"key.debug.crash",
"key.debug.copyLocation"
]
);
keys.set("key.drop", "key.keyboard.z");
e.zoom_key = "key.keyboard.z".into();
assert_eq!(zoom_key_conflicts(&e, &keys), ["key.drop"]);
e.zoom_key = "key.keyboard.unknown".into();
keys.set("key.smoothCamera", "key.keyboard.unknown");
assert!(zoom_key_conflicts(&e, &keys).is_empty());
}
#[test]
fn sensitivity_scales_with_zoom() {
assert_eq!(turn_scale(1.0, true), 1.0);
assert_eq!(turn_scale(4.0, false), 1.0);
assert_eq!(turn_scale(4.0, true), 0.25);
}
#[test]
fn fps_counter_reports_rate_minimum_and_low() {
let mut c = FpsCounter::default();
@@ -921,20 +653,9 @@ mod tests {
#[test]
fn items_cycle_through_their_values() {
let mut e = Extras::default();
Item::ZoomLevel.cycle(&mut e, false);
assert_eq!(e.zoom_level, 6);
Item::ZoomLevel.cycle(&mut e, true);
Item::ZoomLevel.cycle(&mut e, true);
assert_eq!(e.zoom_level, 3);
Item::FpsCorner.cycle(&mut e, true);
assert_eq!(e.fps_corner, Corner::BottomRight);
Item::Zoom.cycle(&mut e, false);
assert_eq!(Item::Zoom.switch(&e), Some(true));
assert_eq!(Item::ZoomKey.value(&e), "Z");
Item::MinimapZoom.cycle(&mut e, true);
assert_eq!(Item::MinimapZoom.value(&e), "½ block per pixel");
Item::MinimapSize.cycle(&mut e, false);
assert_eq!(Item::MinimapSize.value(&e), "Large");
Item::Sort.cycle(&mut e, true);
assert_eq!(Item::Sort.value(&e), "On");
// The minimap's old keys still parse, for the move to its mod.
assert_eq!(
Extras::parse("minimapSize:7\nminimapZoom:3").minimap_zoom,
1.0
+3 -133
View File
@@ -1,7 +1,7 @@
//! The extras' HUD overlays (FPS counter, keystrokes): small boxes in the
//! vanilla font, in GUI pixels, stacked per corner (`docs/extras.md`).
//! HUD boxes in the corners: the minimap's place and mods' panels, small
//! boxes in GUI pixels stacked per corner (`docs/extras.md`).
use crate::{
extras::{Corner, FpsReading},
extras::Corner,
hud::{HudAtlas, HudBatch, Rect, rgba},
};
@@ -99,36 +99,6 @@ pub fn draw_minimap(
}
}
/// The FPS counter's text.
pub fn fps_text(r: FpsReading, lows: bool) -> String {
if lows {
format!("{} fps min {} 1% {}", r.fps, r.min, r.low)
} else {
format!("{} fps", r.fps)
}
}
pub fn draw_fps(
batch: &mut HudBatch,
atlas: &HudAtlas,
corners: &mut Corners,
corner: Corner,
reading: FpsReading,
lows: bool,
) {
let text = fps_text(reading, lows);
let w = HudBatch::text_width(atlas, &text) + 4.0;
let (x, y) = corners.place(corner, w, 12.0);
batch.fill(atlas, x, y, w, 12.0, BACKGROUND);
batch.text(
atlas,
&text,
x + 2.0,
y + 2.0,
rgba(255, 255, 255, 255),
true,
);
}
/// A box of text lines (a mod's HUD panel): `(text, 0xRRGGBB)` each.
pub fn draw_lines(
batch: &mut HudBatch,
@@ -155,95 +125,6 @@ pub fn draw_lines(
}
}
/// What the keystroke overlay shows.
pub struct Keystrokes {
/// Forward, left, back, right: held, and the bound key's name.
pub moves: [(bool, String); 4],
pub jump: bool,
pub attack: bool,
pub use_: bool,
/// Clicks per second of the left and right buttons, when shown.
pub cps: Option<(usize, usize)>,
}
const KEY: f32 = 22.0;
const GAP: f32 = 2.0;
/// The overlay's size.
pub fn keystrokes_size() -> (f32, f32) {
(3.0 * KEY + 2.0 * GAP, 3.0 * KEY + 3.0 * GAP + 10.0)
}
fn key_box(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4], held: bool, lines: &[&str]) {
let [x, y, w, h] = rect;
let (fill, ink) = if held {
(rgba(255, 255, 255, 176), rgba(20, 20, 20, 255))
} else {
(BACKGROUND, rgba(255, 255, 255, 255))
};
batch.fill(atlas, x, y, w, h, fill);
let total = lines.len() as f32 * 9.0 - 1.0;
for (i, line) in lines.iter().enumerate() {
let tw = HudBatch::text_width(atlas, line);
let tx = (x + (w - tw) / 2.0).round();
let ty = (y + (h - total) / 2.0).round() + i as f32 * 9.0;
batch.text(atlas, line, tx, ty, ink, !held);
}
}
pub fn draw_keystrokes(
batch: &mut HudBatch,
atlas: &HudAtlas,
corners: &mut Corners,
corner: Corner,
k: &Keystrokes,
) {
let (w, h) = keystrokes_size();
let (x, y) = corners.place(corner, w, h);
let step = KEY + GAP;
let [forward, left, back, right] = &k.moves;
key_box(
batch,
atlas,
[x + step, y, KEY, KEY],
forward.0,
&[&forward.1],
);
for (i, key) in [left, back, right].into_iter().enumerate() {
key_box(
batch,
atlas,
[x + i as f32 * step, y + step, KEY, KEY],
key.0,
&[&key.1],
);
}
let mw = (w - GAP) / 2.0;
let my = y + 2.0 * step;
for (i, (held, label, cps)) in [
(k.attack, "LMB", k.cps.map(|c| c.0)),
(k.use_, "RMB", k.cps.map(|c| c.1)),
]
.into_iter()
.enumerate()
{
let rate = cps.map(|n| format!("{n} CPS"));
let lines: Vec<&str> = std::iter::once(label).chain(rate.as_deref()).collect();
key_box(
batch,
atlas,
[x + i as f32 * (mw + GAP), my, mw, KEY],
held,
&lines,
);
}
// The space bar: a line in a short key.
let sy = my + step;
key_box(batch, atlas, [x, sy, w, 10.0], k.jump, &[]);
let ink = if k.jump {
rgba(20, 20, 20, 255)
} else {
rgba(255, 255, 255, 255)
};
batch.fill(atlas, x + w / 2.0 - 12.0, sy + 4.0, 24.0, 2.0, ink);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -259,15 +140,4 @@ mod tests {
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
}
#[test]
fn fps_text_with_and_without_lows() {
let r = FpsReading {
fps: 144,
min: 61,
low: 98,
};
assert_eq!(fps_text(r, false), "144 fps");
assert_eq!(fps_text(r, true), "144 fps min 61 1% 98");
}
}
+29 -1
View File
@@ -891,8 +891,11 @@ impl Font {
}
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
}
/// Font.width of a component: `§` codes in the spans are formatting,
/// not glyphs.
pub fn width_text(&self, t: &Text) -> f32 {
t.chars()
t.resolved()
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
.ceil()
@@ -1198,6 +1201,31 @@ mod tests {
assert!(!font.take_patches().is_empty());
}
/// A renderer rebuilt over the same font (the loading screen's handoff)
/// makes its atlas texture from the textures alone, so the glyphs it had
/// rasterized into the previous texture must be placed into the new one.
#[test]
fn a_rebuilt_atlas_gets_its_glyphs_again() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
let default = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
let root = root(
"rebuild",
&[
("assets/minecraft/font/default.json", default),
("assets/minecraft/textures/font/a.png", &sheet),
],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
let first = font.glyph('a');
assert_eq!(font.take_patches().len(), 1);
font.reset_cache();
// Nothing is left over: drawing the character again places its pixels.
assert!(font.take_patches().is_empty());
assert_eq!(font.glyph('a').advance, first.advance);
assert_eq!(font.take_patches().len(), 1);
}
#[test]
fn provider_filters_follow_the_font_options() {
let sheet = png(8, 8, |x, _| x < 4);
File diff suppressed because it is too large. Load diff
+30 -3
View File
@@ -300,6 +300,8 @@ struct Glide {
pub(super) struct Agent {
task: Option<Task>,
glide: Option<Glide>,
/// Control-fixture barrier: hold simulation and packet handling, not transport threads.
pub hold_server: bool,
}
pub(super) enum Handled {
@@ -390,6 +392,9 @@ impl Game<'_> {
Ok(())
}
pub(super) fn screen_label(&self) -> &'static str {
if self.dialog_open() {
return "dialog";
}
if self.title.is_some() {
return "title";
}
@@ -416,6 +421,7 @@ impl Game<'_> {
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
@@ -469,11 +475,14 @@ impl Game<'_> {
"flying": p.flying,
"fall_flying": p.fall_flying,
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
"sprinting": p.sprinting,
"fov_modifier": self.view.fov_modifier,
"health": p.health.health(),
"absorption": p.health.absorption(),
// The hurt animation's ticks left (the camera's hurt tilt).
"hurt_time": p.health.hurt_animation_time,
// LivingEntity.deathTime while dead (the camera's death roll).
"death_time": self.death_time(),
"air": p.air,
"food": p.health.food.food,
"saturation": p.health.food.saturation,
@@ -483,6 +492,7 @@ impl Game<'_> {
})
});
json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": self.title.as_ref().map(|_| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
@@ -661,6 +671,7 @@ impl Game<'_> {
});
if let Some(chat) = &self.ui.chat_screen {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() {
v["menu"] = self.title_value();
@@ -680,6 +691,18 @@ impl Game<'_> {
v["craft_size"] = json!(self.craft_size);
v["station"] = json!(self.station.as_ref().map(|s| format!("{:?}", s.kind)));
v["container"] = json!(self.container);
// The title a server gave the menu, span by span as it is drawn.
if let Some(title) = self.remote_menu_title() {
let spans = title.spans.iter().map(|s| {
json!({
"text": s.text,
"color": s.style.color.map(|c| format!("#{c:06X}")),
"bold": s.style.is_bold(),
"italic": s.style.is_italic(),
})
});
v["title"] = json!(spans.collect::<Vec<_>>());
}
// The recipe book's buttons (GUI pixels): the book button, then
// the recipe buttons of the page, page arrows, filter and tabs.
let book = self.recipe_book_targets();
@@ -1598,8 +1621,12 @@ impl Game<'_> {
if self.screen != Screen::Dead {
return Err("respawn needs the death screen".into());
}
let label =
horizon_client::text::Text::translate("deathScreen.respawn", &[]).plain();
let key = if self.level.hardcore {
"deathScreen.spectate"
} else {
"deathScreen.respawn"
};
let label = horizon_client::text::Text::translate(key, &[]).plain();
let w = widgets::find(&widgets::last(), &label)?;
if !w.enabled {
// DeathScreen enables its buttons after 20 ticks.
+7 -3
View File
@@ -515,11 +515,15 @@ impl Game<'_> {
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = stack_tip {
let lines = self.stack_tooltip(&stack);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
} else if let Some(key) = tooltip {
let text = self.translate(&key, &key);
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
self.draw_tooltip_lines(
batch,
&[(horizon_client::text::Text::literal(text), u32::MAX)],
self.cursor.0,
self.cursor.1,
);
}
}
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
@@ -8,10 +8,15 @@ use horizon_client::{
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
block_entity_render::BlockAnim,
collisions::{SceneCollisions, no_collision_empty},
containers::BlockEntity,
particles::{ParticleOptions, ParticleType},
save::number,
skins,
};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::nbt::Tag;
/// The handle of the local player among a bell's gathered entities.
@@ -84,6 +89,16 @@ impl Game<'_> {
self.lid_openers_changed(other, open);
}
}
_ if self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]))
.name
.as_ref()
== "minecraft:barrel" =>
{
self.barrel_openers_changed(key, open);
}
Some(Ticker::Shulker { .. }) => {
let shulker = self.be_anims.shulkers.entry(pos).or_default();
let (count, _) = if open {
@@ -102,15 +117,48 @@ impl Game<'_> {
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
/// and for the guests.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
let count = self.lid_openers.entry(key).or_default();
*count = (*count + if open { 1 } else { -1 }).max(0);
let count = *count;
if count == 0 {
self.lid_openers.remove(&key);
}
let (before, count) = self.count_opener(key, open);
let pos = [key.0, key.1, key.2];
self.block_entity_event(pos, 1, count);
self.lan_block_event(pos, 1, count);
// TrappedChestBlockEntity.signalOpenCount: its signal is the count
// (`TrappedChestBlock.getSignal`), so the blocks around it and
// below it hear of a change.
let state = self.world.block(pos[0], pos[1], pos[2]);
if before != count && self.res.facts().get(state).name.as_ref() == "minecraft:trapped_chest"
{
let p = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| {
let block = b.block(state);
b.update_neighbors_at(level, ticks, p, block);
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
});
}
}
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
let (before, count) = self.count_opener(key, open);
if (before == 0) != (count == 0) {
self.set_bool_prop(
key,
"open",
count > 0,
horizon_world::level::flags::UPDATE_ALL,
);
}
}
/// One opener more (`open`) or less at `key`: the count before and after
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
fn count_opener(&mut self, key: (i32, i32, i32), open: bool) -> (i32, i32) {
let count = self.lid_openers.entry(key).or_default();
let before = *count;
*count = (before + if open { 1 } else { -1 }).max(0);
let after = *count;
if after == 0 {
self.lid_openers.remove(&key);
}
(before, after)
}
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
/// box meets the bell's block box inflated by 48.
@@ -171,6 +219,11 @@ impl Game<'_> {
/// client tick (`ClientLevel.tickBlockEntities`).
pub(super) fn tick_block_entity_anims(&mut self) {
let Some(r) = &self.renderer else {
// A server has no window, but its gateways still run their
// server ticker.
if self.remote.is_none() {
self.tick_gateway_servers();
}
return;
};
// Sorted by position: the order they tick in, and a quick lookup.
@@ -330,6 +383,31 @@ impl Game<'_> {
}
self.be_anims.present = positions;
}
/// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the
/// gateways a server keeps a copy of, in block-ticking chunks
/// (`Level.tickBlockEntities`): the cooldown an entry started runs out,
/// and every 2400 ticks `triggerCooldown` sends its block event.
fn tick_gateway_servers(&mut self) {
let levels = self.tickets.levels();
let mut positions: Vec<[i32; 3]> = self
.be_anims
.gateway_servers
.keys()
.copied()
.filter(|p| levels.block_ticking_at(p[0], p[2]))
.collect();
positions.sort_unstable();
for pos in positions {
let triggered = self
.be_anims
.gateway_servers
.get_mut(&pos)
.is_some_and(EndGateway::portal_tick);
if triggered {
self.block_entity_event(pos, 1, 0);
}
}
}
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
/// `pos` let the entity in (`triggerCooldown`, with its block event).
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
@@ -392,4 +470,25 @@ impl Game<'_> {
pub(super) fn chest_lid(&self, pos: [i32; 3], partial: f32) -> f32 {
self.be_anims.lid(pos, partial)
}
/// `SkullBlockRenderer.getPlayerSkin`: the skin sprite of the profile of
/// the player head at `pos`. The skin download is started here; while it
/// is in flight (or when it cannot be got) the profile's default skin
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
let Some(BlockEntity::Skull(skull)) =
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
else {
return None;
};
let skin = skull.skin.as_ref()?;
if let Some((url, location)) = &skin.download {
self.skin_downloads
.borrow_mut()
.state(skins::Kind::Skin, url);
if self.gui.atlas.sprites.contains_key(location) {
return Some(location);
}
}
Some(skin.fallback)
}
}
@@ -15,6 +15,8 @@ use horizon_world::registry::BlockStateId;
impl Game<'_> {
pub(super) fn tick_block_entities(&mut self) -> Result<()> {
self.tick_mount_menu();
self.tick_craft_table();
self.tick_container_reach();
self.load_entity_types();
if !self.world.block_entities.has_states() {
let states = self.ticking_states();
@@ -54,6 +56,7 @@ impl Game<'_> {
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
_ => {}
}
}
@@ -90,7 +93,10 @@ impl Game<'_> {
| "minecraft:vault"
| "minecraft:beehive"
| "minecraft:bee_nest"
| "minecraft:brewing_stand" => Ticking::Yes,
| "minecraft:brewing_stand"
// Lit, cookTick; unlit, cooldownTick.
| "minecraft:campfire"
| "minecraft:soul_campfire" => Ticking::Yes,
"minecraft:jukebox" => {
let record = registry
.state(state)
@@ -13,7 +13,7 @@ use horizon_client::{
use horizon_core::geometry::{Aabb, Vec3};
/// `Entity.boardingCooldown` once a mob leaves its boat.
const BOARDING_COOLDOWN: i32 = 60;
pub(super) const BOARDING_COOLDOWN: i32 = 60;
/// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's
/// feet are.
const PLAYER_RIDING_OFFSET: f64 = 0.6;
+4 -1
View File
@@ -55,7 +55,7 @@ pub(super) struct Boat {
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
pub(super) const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
@@ -587,6 +587,9 @@ impl Game<'_> {
}
/// The player's tick while riding: steer, tick the boat, sit in it.
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
if self.tick_cushion_riding(keys) {
return Ok(());
}
let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else {
if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) {
return Ok(());
+346 -151
View File
@@ -17,7 +17,6 @@ use horizon_client::{
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::level::flags as level_flags;
use std::time::Instant;
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -27,8 +26,24 @@ const BLACK: u32 = 0xFF00_0000;
const PAGE_CHARS: usize = 1024;
const MAX_PAGES: usize = 100;
const LINES: usize = 14;
/// `BookSignScreen`'s title box.
const BOOK_TITLE: horizon_client::clipboard::FieldId = ("book.title", 0);
fn book_title_box() -> ui::EditBox {
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
edit.bordered = false;
edit.max_length = 15;
edit.text_color = BLACK;
edit.focused = true;
edit
}
fn book_page_box() -> ui::EditBox {
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
edit.bordered = false;
edit.multiline = true;
edit.max_length = PAGE_CHARS;
edit.text_color = BLACK;
edit.focused = true;
edit
}
pub(super) enum BookScreen {
Edit {
@@ -38,7 +53,10 @@ pub(super) enum BookScreen {
page: usize,
signing: bool,
title: String,
opened: Instant,
/// BookSignScreen's title box (`EditBox` of 15).
title_edit: ui::EditBox,
/// BookEditScreen's page (`MultiLineEditBox`).
page_edit: ui::EditBox,
},
View {
pages: Vec<String>,
@@ -84,13 +102,16 @@ impl Game<'_> {
if pages.is_empty() {
pages.push(String::new());
}
let mut page_edit = book_page_box();
page_edit.set_value(&pages[0]);
BookScreen::Edit {
slot: self.held_slot(),
pages,
page: 0,
signing: false,
title: String::new(),
opened: Instant::now(),
title_edit: book_title_box(),
page_edit,
}
}
"minecraft:written_book" => BookScreen::View {
@@ -239,34 +260,24 @@ impl Game<'_> {
Some(KeyCode::PageDown) => self.turn_page(true),
_ => {}
},
Some(BookScreen::Edit {
signing: true,
title,
..
}) => match key {
Some(BookScreen::Edit { signing: true, .. }) => match key {
Some(KeyCode::Escape) => {
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = false;
}
}
Some(KeyCode::Backspace) => {
if !self.text_selection.take(BOOK_TITLE, title) {
title.pop();
}
}
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
if !title.trim().is_empty() {
let blank = self.book.as_ref().is_some_and(
|b| matches!(b, BookScreen::Edit { title, .. } if title.trim().is_empty()),
);
if !blank {
self.save_book(true);
}
}
Some(key) if self.book_title_key(key) => {}
_ => {
if let Some(typed) = text {
self.text_selection.take(BOOK_TITLE, title);
for c in typed.chars().filter(|c| !c.is_control()) {
if title.chars().count() < 15 {
title.push(c);
}
}
self.book_title_insert(typed);
}
}
},
@@ -274,24 +285,13 @@ impl Game<'_> {
Some(KeyCode::Escape) => self.save_book(false),
Some(KeyCode::PageUp) => self.turn_page(false),
Some(KeyCode::PageDown) => self.turn_page(true),
Some(KeyCode::Backspace) => {
if !self.take_page_selection() {
self.edit_page(|p| {
p.pop();
})
}
}
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
self.take_page_selection();
self.type_into_page("\n")
}
Some(KeyCode::ArrowUp) => self.book_move_line(-1),
Some(KeyCode::ArrowDown) => self.book_move_line(1),
Some(KeyCode::Enter | KeyCode::NumpadEnter) => self.page_insert("\n"),
Some(key) if self.book_page_key(key) => {}
_ => {
if let Some(typed) = text {
let typed: String = typed.chars().filter(|c| !c.is_control()).collect();
if !typed.is_empty() {
self.take_page_selection();
}
self.type_into_page(&typed);
self.page_insert(typed);
}
}
},
@@ -302,99 +302,258 @@ impl Game<'_> {
pub(super) fn book_type(&mut self, text: &str) {
let text = text.replace("\\n", "\n");
match self.book.as_mut() {
Some(BookScreen::Edit {
signing: true,
title,
..
}) => {
for c in text.chars().filter(|c| !c.is_control()) {
if title.chars().count() < 15 {
title.push(c);
}
}
}
Some(BookScreen::Edit { .. }) => self.type_into_page(&text),
Some(BookScreen::Edit { signing: true, .. }) => self.book_title_insert(&text),
Some(BookScreen::Edit { .. }) => self.page_insert(&text),
_ => {}
}
}
/// Before typing on a page: Select All's selection is cleared (true).
fn take_page_selection(&mut self) -> bool {
match self.book.as_mut() {
Some(BookScreen::Edit { pages, page, .. }) => self
.text_selection
.take(("book.page", *page), &mut pages[*page]),
_ => false,
/// `BookSignScreen.keyPressed` on the title box.
fn book_title_key(&mut self, key: KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return false;
};
if !title_edit.key(key, ctrl, shift) {
return false;
}
title.clone_from(&title_edit.value);
true
}
/// Ctrl+A/C/V/X: the title box (an `EditBox` of 15) while signing, else
/// the page (`MultilineTextField.insertText`: line breaks kept, cut to
/// the 1024 characters left, rejected when it overflows the 14 lines).
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
let font = &self.gui.atlas.font;
match self.book.as_mut() {
Some(BookScreen::Edit {
signing: true,
title,
..
}) => {
Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
BOOK_TITLE,
title,
shortcut,
15,
);
}
Some(BookScreen::Edit { pages, page, .. }) => {
// truncateInsertionText counts the whole value, selection included.
let room = PAGE_CHARS.saturating_sub(pages[*page].chars().count());
self.text_selection.apply(
("book.page", *page),
&mut pages[*page],
shortcut,
&mut self.clipboard,
|value, pasted| {
let text: String = horizon_client::clipboard::filter_text(pasted, true)
.chars()
.take(room)
.collect();
let next = format!("{value}{text}");
font.split(&Text::literal(&next), 114.0).len() <= LINES && {
*value = next;
true
}
},
);
}
_ => {}
}
}
fn edit_page(&mut self, f: impl FnOnce(&mut String)) {
if let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_mut() {
f(&mut pages[*page]);
}
}
/// MultiLineEditBox: characters that keep the page within 14 lines and 1024 characters.
fn type_into_page(&mut self, typed: &str) {
let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_ref() else {
fn book_title_insert(&mut self, text: &str) {
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return;
};
let mut text = pages[*page].clone();
for c in typed.chars() {
let mut next = text.clone();
next.push(c);
if next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES {
text = next;
title_edit.insert_text(text);
title.clone_from(&title_edit.value);
}
/// `BookEditScreen.keyPressed` on the page, apart from the line moves.
fn book_page_key(&mut self, key: KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => return false,
};
let handled = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.key(key, ctrl, shift)
} else {
false
};
if handled {
self.page_keep(before);
}
handled
}
/// One character at a time, so a page stops at the last character that fits.
fn page_insert(&mut self, text: &str) {
for c in text.chars() {
self.page_change(|edit| edit.insert_text(&c.to_string()));
}
}
/// Ctrl+A/C/V/X: the title box while signing, else the page.
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
let signing = matches!(self.book, Some(BookScreen::Edit { signing: true, .. }));
if signing {
if let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
{
title_edit.shortcut(shortcut, &mut self.clipboard);
title.clone_from(&title_edit.value);
}
return;
}
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => None,
};
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.shortcut(shortcut, &mut self.clipboard);
}
self.page_keep(before);
}
/// Edit the page box, then keep it only within 14 lines and 1024 characters.
fn page_change(&mut self, f: impl FnOnce(&mut ui::EditBox)) -> bool {
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => return false,
};
let ran = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
f(page_edit);
true
} else {
false
};
ran && self.page_keep(before)
}
/// Write the page, or put the previous text back when it no longer fits.
fn page_keep(&mut self, before: Option<(String, usize, usize)>) -> bool {
let next = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => page_edit.value.clone(),
_ => return false,
};
let ok = next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES;
let Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) = self.book.as_mut()
else {
return false;
};
if ok {
pages[*page].clone_from(&page_edit.value);
} else if let Some((value, cursor, highlight)) = before {
page_edit.value = value;
page_edit.cursor = cursor;
page_edit.highlight = highlight;
}
ok
}
/// ArrowUp and ArrowDown move a visual line, keeping the column.
fn book_move_line(&mut self, dir: isize) {
let shift = self.has_shift_down();
let (text, cursor) = match self.book.as_ref() {
Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) => (pages[*page].clone(), page_edit.cursor),
_ => return,
};
let rows = self.page_rows(&text);
let (x, line) = self.caret_on(&text, &rows, cursor);
let next = (line as isize + dir).clamp(0, rows.len() as isize - 1) as usize;
let (start, end) = rows[next];
let chars: Vec<char> = text.chars().collect();
let slice: String = chars[start..end].iter().collect();
let pos = start + self.gui.atlas.font.fit(&slice, x);
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.set_cursor(pos, shift);
}
}
/// Wrapped rows of a page as character ranges, the same breaks as `font.split`.
fn page_rows(&self, page: &str) -> Vec<(usize, usize)> {
let font = &self.gui.atlas.font;
let chars: Vec<char> = page.chars().collect();
let mut rows = Vec::new();
let mut start = 0;
while start < chars.len() {
let mut end = chars.len();
let mut next = chars.len();
let mut last_space = None;
for i in start..chars.len() {
if chars[i] == '\n' {
end = i;
next = i + 1;
break;
}
if chars[i] == ' ' {
last_space = Some(i);
}
let slice: String = chars[start..=i].iter().collect();
if font.width(&slice) > 114.0 {
match last_space {
Some(s) if s > start => {
end = s;
next = s + 1;
}
_ => {
end = i.max(start + 1);
next = end;
}
}
break;
}
}
rows.push((start, end));
start = next;
if start == chars.len() && chars.last() == Some(&'\n') {
rows.push((chars.len(), chars.len()));
}
}
self.edit_page(|p| *p = text);
if rows.is_empty() {
rows.push((0, 0));
}
rows
}
/// GUI x of the caret and the row it sits on.
fn caret_on(&self, page: &str, rows: &[(usize, usize)], cursor: usize) -> (f32, usize) {
let chars: Vec<char> = page.chars().collect();
for (i, &(start, end)) in rows.iter().enumerate() {
if cursor <= end || i + 1 == rows.len() {
let take = cursor.saturating_sub(start).min(end.saturating_sub(start));
let prefix: String = chars.iter().skip(start).take(take).collect();
return (self.gui.atlas.font.width(&prefix), i);
}
}
(0.0, 0)
}
fn page_index_at(&self, page: &str, local_x: f32, local_y: f32) -> usize {
let rows = self.page_rows(page);
let line = (local_y / 9.0).floor().max(0.0) as usize;
let line = line.min(rows.len().saturating_sub(1));
let (start, end) = rows[line];
let chars: Vec<char> = page.chars().collect();
let slice: String = chars[start..end].iter().collect();
start + self.gui.atlas.font.fit(&slice, local_x.max(0.0))
}
/// A click on the centred title (`EditBox.onClick`).
fn book_title_click(&mut self, field_x: f32, mx: f32) {
let shift = self.has_shift_down();
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return;
};
let tx = field_x + ((114.0 - self.gui.atlas.font.width(title)) / 2.0).floor();
let rel = (mx - tx).max(0.0);
let pos = self.gui.atlas.font.fit(title, rel);
title_edit.set_cursor(pos, shift);
}
/// A click on the open page.
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
let shift = self.has_shift_down();
let text = match self.book.as_ref() {
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
_ => return,
};
let pos = self.page_index_at(&text, mx - origin_x, my - origin_y);
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.set_cursor(pos, shift);
}
}
/// The page buttons (`pageForward` adds a page while editing).
fn turn_page(&mut self, forward: bool) {
let mut lectern_page = None;
match self.book.as_mut() {
Some(BookScreen::Edit { pages, page, .. }) => {
Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) => {
let before = *page;
if forward {
if *page + 1 >= pages.len() && pages.len() < MAX_PAGES {
pages.push(String::new());
@@ -405,6 +564,10 @@ impl Game<'_> {
} else if *page > 0 {
*page -= 1;
}
if *page != before {
let text = pages[*page].clone();
page_edit.set_value(&text);
}
}
Some(BookScreen::View {
lectern: Some(key), ..
@@ -764,6 +927,8 @@ impl Game<'_> {
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = false;
}
} else if inside(left + 36.0, 50.0, 114.0, 9.0) {
self.book_title_click(left + 36.0, mx);
}
}
Some(BookScreen::Edit { page, .. }) => {
@@ -780,6 +945,8 @@ impl Game<'_> {
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
self.save_book(false);
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
self.book_page_click(left + 36.0, 32.0, mx, my);
}
}
Some(BookScreen::View {
@@ -865,7 +1032,7 @@ impl Game<'_> {
BookScreen::Edit {
signing: true,
title,
opened,
title_edit,
..
} => {
let edit = tr("book.editTitle", "Enter Book Title:");
@@ -881,19 +1048,35 @@ impl Game<'_> {
// The title box: an unbordered, centred EditBox of 114 with
// black text and no shadow; the value alone is centred, the
// cursor follows it.
let value = &title_edit.value;
let x = left + 36.0;
let tx = x + ((114.0 - HudBatch::text_width(atlas, title)) / 2.0).floor();
horizon_client::ui::append_field(
batch,
atlas,
title,
(tx, 50.0, x + 114.0 - tx),
(BLACK, false),
15,
self.text_selection.covers(BOOK_TITLE, title),
true,
opened.elapsed().as_millis(),
);
let tx = x + ((114.0 - HudBatch::text_width(atlas, value)) / 2.0).floor();
batch.text(atlas, value, tx, 50.0, BLACK, false);
if title_edit.cursor != title_edit.highlight {
let (a, b) = (
title_edit.cursor.min(title_edit.highlight),
title_edit.cursor.max(title_edit.highlight),
);
let pre: String = value.chars().take(a).collect();
let sel: String = value.chars().skip(a).take(b - a).collect();
let sx = tx + HudBatch::text_width(atlas, &pre);
batch.text_highlight(
atlas,
(sx, 49.0, HudBatch::text_width(atlas, &sel), 11.0),
true,
);
}
if title_edit.cursor_visible() {
let pre: String = value.chars().take(title_edit.cursor).collect();
batch.fill(
atlas,
tx + HudBatch::text_width(atlas, &pre),
49.0,
1.0,
9.0,
BLACK,
);
}
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
let tw = HudBatch::text_width(atlas, &by);
batch.text(
@@ -941,12 +1124,12 @@ impl Game<'_> {
BookScreen::Edit {
pages,
page,
opened,
page_edit,
..
} => {
indicator(batch, *page, pages.len());
let blink = (opened.elapsed().as_millis() / 300) % 2 == 0;
let lines = self.book_lines(&pages[*page]);
let text = &pages[*page];
let lines = self.book_lines(text);
for (i, line) in lines.iter().take(LINES).enumerate() {
batch.text_styled(
atlas,
@@ -957,27 +1140,39 @@ impl Game<'_> {
false,
);
}
if blink {
// The cursor after the last character.
let last = lines
.last()
.map(|l| atlas.font.width_text(l))
.unwrap_or(0.0);
let row = lines.len().saturating_sub(1) as f32
+ if pages[*page].ends_with('\n') {
1.0
} else {
0.0
};
let x = if pages[*page].ends_with('\n') {
0.0
} else {
last
};
let rows = self.page_rows(text);
let (sel_a, sel_b) = (
page_edit.cursor.min(page_edit.highlight),
page_edit.cursor.max(page_edit.highlight),
);
if sel_a != sel_b {
let chars: Vec<char> = text.chars().collect();
for (i, &(start, end)) in rows.iter().enumerate().take(LINES) {
let from = sel_a.max(start);
let to = sel_b.min(end);
if from >= to {
continue;
}
let pre: String = chars[start..from].iter().collect();
let sel: String = chars[from..to].iter().collect();
batch.text_highlight(
atlas,
(
left + 36.0 + atlas.font.width(&pre),
top + 29.0 + i as f32 * 9.0,
atlas.font.width(&sel),
9.0,
),
true,
);
}
}
if page_edit.cursor_visible() {
let (x, row) = self.caret_on(text, &rows, page_edit.cursor);
batch.fill(
atlas,
left + 36.0 + x,
top + 29.0 + row * 9.0,
top + 29.0 + row as f32 * 9.0,
1.0,
9.0,
BLACK,
+190
View File
@@ -0,0 +1,190 @@
//! Campfires' cooking: `CampfireBlock.useItemOn` (`CampfireBlockEntity.placeFood`)
//! and the block entity's `cookTick` (lit) and `cooldownTick` (unlit). The
//! items drawn on the fire are `campfire_geometry`'s; a server's clients get
//! them through the block entity's update tag (`lan::chunks::entity_tag`).
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
items::ItemStack, mesher::Vertex, mobs::Who,
};
use horizon_core::position::BlockPos;
/// The `campfire_cooking` recipe kind (`RecipeType.CAMPFIRE_COOKING`).
const KIND: &str = "minecraft:campfire_cooking";
fn is_campfire(name: &str) -> bool {
name == "minecraft:campfire" || name == "minecraft:soul_campfire"
}
impl Game<'_> {
/// `CampfireBlock.useItemOn` with a held item that is a campfire recipe's
/// input (`RecipePropertySet.CAMPFIRE_INPUT`): on a server level the food
/// goes into the first free slot (`placeFood`), and the use is consumed
/// whether or not there was room. A client of a server leaves the placing
/// to the server. Whether the food went in; None when this isn't a
/// campfire use, so the item's own use runs.
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
let state = self.world.block(pos[0], pos[1], pos[2]);
if !is_campfire(&self.res.facts().get(state).name) {
return Ok(None);
}
let Some(held) = self.held().cloned() else {
return Ok(None);
};
let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
return Ok(None);
};
// A client of a server predicts the placing; the server decides.
if self.remote.is_some() {
return Ok(Some(true));
}
let key = (pos[0], pos[1], pos[2]);
let placed = match self.world.block_entities.get_mut(&key) {
Some(BlockEntity::Campfire(c)) => {
let mut one = held.clone();
c.place_food(&mut one, time)
}
_ => false,
};
if placed {
// ItemStack.consumeAndReturn: creative keeps the item.
self.consume_held();
self.stats.custom("interact_with_campfire", 1);
self.post_block_game_event(
"minecraft:block_change",
pos,
Some(Who::Player),
Some(state),
);
self.mark_block_entity_updated(key);
}
Ok(Some(placed))
}
/// `CampfireBlockEntity.cookTick` of a lit campfire, `cooldownTick` of an
/// unlit one. A finished item's result (the recipe's, or the item itself
/// when no recipe takes it any more) drops at the block's corner
/// (`Containers.dropItemStack` at its integer position), then the clients
/// see the slot empty and a `block_change` goes out.
pub(super) fn tick_campfire(&mut self, pos: Pos) -> Result<()> {
let state = self.world.block(pos.0, pos.1, pos.2);
let lit = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("lit"))
.is_some_and(|v| v == "true");
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
// BlockEntity.setChanged: a timer that moved is saved with the
// chunk (no update to the clients, who don't see the timers).
let changed = if lit {
c.items.iter().any(Option::is_some)
} else {
c.cooldown_tick()
};
if changed {
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
}
if !lit {
return Ok(());
}
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
let done = c.cook_tick();
for (_, item) in done {
let result = self
.res
.recipes()
.cook(KIND, &item)
.map_or_else(|| item.clone(), |r| r.result.clone());
self.campfire_drop(pos, result);
self.mark_block_entity_updated(pos);
self.post_block_game_event(
"minecraft:block_change",
[pos.0, pos.1, pos.2],
None,
Some(state),
);
}
Ok(())
}
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
/// (90° about X), at a corner and three eighths of its size, drawn as a
/// FIXED item seeded by the block's position plus the slot.
pub(super) fn campfire_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
for (key, entity) in &self.world.block_entities {
let BlockEntity::Campfire(c) = entity else {
continue;
};
if c.items.iter().all(Option::is_none) {
continue;
}
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
if at.distance_squared(camera) >= 64.0 * 64.0 {
continue;
}
// Direction.get2DDataValue: south 0, west 1, north 2, east 3.
let state = self.world.block(key.0, key.1, key.2);
let facing = match self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing"))
.map(String::as_str)
{
Some("west") => 1,
Some("north") => 2,
Some("east") => 3,
_ => 0,
};
let light = vertex_light(self.world.light(key.0, key.1, key.2));
let packed = BlockPos::new(key.0, key.1, key.2).pack();
for (i, stack) in c.items.iter().enumerate() {
let Some(stack) = stack else {
continue;
};
let seed = (packed as i32).wrapping_add(i as i32);
let item = self.resolve_item(stack, DisplayContext::Fixed, None, seed);
if item.is_empty() {
continue;
}
// Direction.toYRot: 90° per 2D step from south.
let yaw = -(((i + facing) % 4) as f32 * 90.0);
let pose = glam::Mat4::from_translation(
(at - camera).as_vec3() + glam::Vec3::new(0.5, 0.449_218_75, 0.5),
) * glam::Mat4::from_rotation_y(yaw.to_radians())
* glam::Mat4::from_rotation_x(90f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(-0.3125, -0.3125, 0.0))
* glam::Mat4::from_scale(glam::Vec3::splat(0.375));
self.submit_item_world(&item, pose, light, out);
}
}
}
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
/// integer position.
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
let at = BlockPos::new(pos.0, pos.1, pos.2);
for piece in
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
{
self.spawn_item(
ItemStack {
count: piece.count,
..stack.clone()
},
piece.pos,
piece.motion,
0,
);
}
}
}
@@ -0,0 +1,53 @@
//! `CampfireBlock.useItemOn`: an item in `#minecraft:douses_campfires` (the
//! shovels) put on a lit campfire puts it out.
use super::Game;
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
impl Game<'_> {
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
pub(super) fn use_campfire_douse(
&mut self,
pos: [i32; 3],
held: Option<&ItemStack>,
) -> Result<bool> {
let Some(tool) = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
}) else {
return Ok(false);
};
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
else {
return Ok(false);
};
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
self.level_event_at(1009, pos, 0);
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
// then the block change game event from the player.
if self.lan_acting.is_none() {
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
for _ in 0..20 {
make_campfire_smoke(&mut self.particles, pos, signal, true);
}
}
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), None);
state.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = registry.resolve(&state) {
self.level_set(pos, unlit)?;
}
let used = tool.clone();
self.damage_held(1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &used);
Ok(true)
}
}
+68 -12
View File
@@ -2,7 +2,10 @@
//! (game rules, the world clock, difficulty), bound to the game.
use super::{Game, Screen};
use horizon_client::{
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
chat::{
Chat, ChatAbilities, ChatAction, ChatScreen, ChatSource, ChatVisibility, Commands,
SYSTEM_INDICATOR,
},
command::Registries,
gamerules::GameRules,
hud::HudBatch,
@@ -18,7 +21,7 @@ fn chat_tree<'a>(
mods: &'a super::mods::ModsState,
) -> &'a horizon_client::command::Dispatcher {
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
Some(tree) => tree,
Some(tree) => mods.remote_dispatcher().unwrap_or(tree),
None => mods.dispatcher(),
}
}
@@ -99,6 +102,8 @@ pub struct UiState {
/// Death screen cause and when the player died (buttons wait 20 ticks).
pub death_text: Option<Text>,
pub died_at: Option<std::time::Instant>,
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
pub death_time: u32,
}
impl Game<'_> {
@@ -423,6 +428,7 @@ impl Game<'_> {
self.ui.chat.recent.len(),
b.width as i32,
b.height as i32,
self.chat_abilities(),
);
screen.auto_suggestions = self.settings.auto_suggestions;
self.ui.chat_screen = Some(screen);
@@ -441,6 +447,10 @@ impl Game<'_> {
self.capture(true);
}
}
/// `Minecraft.computeChatAbilities` from the Chat option.
pub(super) fn chat_abilities(&self) -> ChatAbilities {
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
}
/// A chat message from the game (ChatComponent.addServerSystemMessage).
pub(super) fn system_message(&mut self, text: Text) {
if let Some(capture) = self.chat_capture.as_mut() {
@@ -450,7 +460,32 @@ impl Game<'_> {
// Connected to a server (single player's own included), the chat on
// screen is the connection's: the game's own messages join it.
if self.remote.is_some() {
self.chat_line(text);
self.chat_line(text, ChatSource::Server);
return;
}
// The Chat option's Hidden level takes the server's messages too.
self.local_chat_line(text, Some(SYSTEM_INDICATOR), ChatSource::Server);
}
/// A player's chat line on the window's own chat: a LAN guest's message
/// while the host runs in-frame (`HostEvent::Chat`). `ChatListener`
/// checks `canReceivePlayerMessages` for these, so Commands Only and
/// Hidden drop them; command feedback (`/list`, `/tp`) is a system
/// message and takes the system-message permission instead.
pub(super) fn player_message(&mut self, text: Text) {
if let Some(capture) = self.chat_capture.as_mut() {
capture.push(text);
return;
}
// A server's client receives the line as a packet (PlayerChat or
// DisguisedChat) and filters it there.
if self.remote.is_none() {
self.local_chat_line(text, None, ChatSource::Player);
}
}
/// A line on the window's own chat when the Chat option lets `source`
/// through; running mods hear it.
fn local_chat_line(&mut self, text: Text, indicator: Option<u32>, source: ChatSource) {
if !self.ui.chat.shows(source) {
return;
}
let focused = self.screen == Screen::Chat;
@@ -458,7 +493,8 @@ impl Game<'_> {
self.ui.chat.add(
&self.gui.atlas,
text,
Some(SYSTEM_INDICATOR),
indicator,
source,
self.ticks,
focused,
);
@@ -568,7 +604,12 @@ impl Game<'_> {
}
}
/// ChatScreen.handleChatInput: history, then a command or a chat line.
/// The Chat option's restrictions stop the message here too, so a
/// control-socket `chat` cannot send what the player's setting hides.
pub(super) fn handle_chat_input(&mut self, message: &str) {
if !self.chat_abilities().allows(message) {
return;
}
self.ui.chat.add_recent(message);
// On a server (single player's own included) the server runs it,
// unless a client mod owns the command.
@@ -582,21 +623,30 @@ impl Game<'_> {
return;
}
if let Some(command) = message.strip_prefix('/') {
self.run_command(command);
// A client mod's command first, then a server mod's, as on a server.
if !self.mod_command(command) && !self.local_server_mod_command(command) {
self.run_command(command);
}
} else {
let line = Text::translate(
"chat.type.text",
&[Text::literal(PLAYER_NAME), Text::literal(message)],
&[self.player_display_name(), Text::literal(message)],
);
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| line.plain());
self.ui
.chat
.add(&self.gui.atlas, line, None, self.ticks, focused);
self.ui.chat.add(
&self.gui.atlas,
line,
None,
ChatSource::Player,
self.ticks,
focused,
);
if let Some(line) = heard {
self.mods_chat(&line);
}
self.lan_host_chat(message);
self.mod_player_chat(super::server_mods::Who::Local, message);
}
}
pub(super) fn chat_scroll(&mut self, amount: f32) {
@@ -732,6 +782,7 @@ impl Game<'_> {
max_snow_accumulation_height: r.int("max_snow_accumulation_height"),
spawn_mobs: r.bool("spawn_mobs"),
spawn_monsters: r.bool("spawn_monsters"),
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
}
}
/// Apply rules mirrored in other state (natural regeneration, difficulty).
@@ -896,9 +947,14 @@ impl Game<'_> {
Err(e) => Text::translate("screenshot.failure", &[Text::literal(e.clone())]),
};
let focused = self.screen == Screen::Chat;
self.ui
.chat
.add(&self.gui.atlas, message, None, self.ticks, focused);
self.ui.chat.add(
&self.gui.atlas,
message,
None,
ChatSource::Client,
self.ticks,
focused,
);
}
}
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
@@ -0,0 +1,37 @@
//! The window's own ambient effects of entities, which a client makes for
//! itself (`level().isClientSide()` branches): endermen's and endermites'
//! portal particles, blazes' smoke and burn, eyes of ender's trail. They run
//! on the game that has the window, in-frame or following a server, never on
//! a server: nothing of them goes over the network.
use super::Game;
use horizon_client::mobs::{MobEvent, ambient};
use horizon_client::particles::{ParticleOptions, ParticleType};
impl Game<'_> {
pub(super) fn tick_client_ambient(&mut self) {
if self.serving.is_some() || self.options.dedicated.is_some() {
return;
}
let mut events = Vec::new();
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
ambient::client_ai_step(m, &mut events);
}
for event in events {
match event {
MobEvent::ParticleAt { kind, at, velocity } => {
if let Some(ty) = ParticleType::from_name(kind) {
self.particles
.add(&ParticleOptions::simple(ty), at, velocity);
}
}
// Each guest of a LAN host makes its own.
MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
.play_unshared(&format!("minecraft:{name}"), Some(at), volume, pitch)
}
_ => {}
}
}
self.eye_particles();
}
}
+21 -22
View File
@@ -63,8 +63,8 @@ impl Game<'_> {
}
}
}
/// Typing or a backspace in a field of the title screens: in the server
/// screens' append-only fields Select All's selection is replaced first.
/// Typing or a backspace in a title-screen field. A field that still only
/// appends replaces a Select All selection before the edit.
pub(super) fn title_edit(&mut self, typed: Option<&str>, backspace: bool) {
let Some(title) = self.title.as_mut() else {
return;
@@ -86,32 +86,31 @@ impl Game<'_> {
}
/// Shortcuts in the creative inventory's search box (an `EditBox` of 50).
pub(super) fn creative_search_shortcut(&mut self, shortcut: EditShortcut) {
if Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
CREATIVE_SEARCH,
&mut self.creative_screen.search,
shortcut,
CREATIVE_SEARCH_CHARS,
) {
if self
.creative_screen
.search
.shortcut(shortcut, &mut self.clipboard)
{
self.creative_screen.scroll = 0.0;
}
}
/// Typing or a backspace in the creative search box.
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>, backspace: bool) {
let search = &mut self.creative_screen.search;
let cleared = self.text_selection.take(CREATIVE_SEARCH, search);
if backspace {
if !cleared {
search.pop();
}
} else if let Some(text) = typed {
clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS);
}
/// A backspace in the creative search box: with Ctrl, the whole word
/// before the caret (`EditBox.deleteWords(-1)`), otherwise one character.
/// A selection is what it deletes, as in `EditBox.keyPressed`.
pub(super) fn creative_search_backspace(&mut self, ctrl: bool) {
self.creative_screen
.search
.key(KeyCode::Backspace, ctrl, false);
self.creative_screen.scroll = 0.0;
}
/// Typing in the creative search box.
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) {
if let Some(text) = typed {
self.creative_screen.search.insert_text(text);
self.creative_screen.scroll = 0.0;
}
}
}
/// `CreativeModeInventoryScreen`'s search box: `setMaxLength(50)`.
pub(super) const CREATIVE_SEARCH_CHARS: usize = 50;
pub(super) const CREATIVE_SEARCH: horizon_client::clipboard::FieldId = ("creative.search", 0);
+13 -4
View File
@@ -52,11 +52,16 @@ impl Game<'_> {
/// Remember who deals the next `damage_type` damage to the player.
pub(super) fn note_attacker(&mut self, damage_type: &str, mob: u32) {
if let Some(name) = self.mob_display_name(mob) {
self.combat
.pending
.push((damage_type.to_owned(), (mob, name)));
self.note_named_attacker(damage_type, mob, name);
}
}
/// `note_attacker` for an entity that is not one of `self.mobs` (the
/// ender dragon).
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
self.combat
.pending
.push((damage_type.to_owned(), (id, name)));
}
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
@@ -191,8 +196,12 @@ impl Game<'_> {
}
/// `CombatTracker.getDeathMessage`.
pub(super) fn combat_death_message(&mut self) -> Text {
self.combat_death_message_for(Text::literal(PLAYER_NAME))
}
/// `CombatTracker.getDeathMessage` for a player whose display name is
/// `me`: a server's guest is not `PLAYER_NAME`.
pub(super) fn combat_death_message_for(&mut self, me: Text) -> Text {
self.recheck_combat();
let me = Text::literal(PLAYER_NAME);
let entries = std::mem::take(&mut self.combat.entries);
self.combat.in_combat = false;
let Some(killing) = entries.last() else {
+114 -27
View File
@@ -1,6 +1,7 @@
//! Headless command runner for `tools/test_command_exec_parity.py`: with
//! `HORIZON_COMMAND_SCRIPT` and `HORIZON_COMMAND_OUTPUT` set, the opened world
//! runs without a window. Each script line is a JSON operation: `cmd` runs a
//! runs on its server thread with a connected, headless client. Each script
//! line is a JSON operation: `cmd` runs a
//! command as the player (Commands.performPrefixedCommand) and records every
//! system message it produced, as styled spans, with the results reported to
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
@@ -41,30 +42,100 @@ pub(super) fn spans(text: &Text) -> Value {
}
impl Game<'_> {
/// Pump the real client's connection, without predicting movement: the
/// oracle's player stays where commands put it. Errors cross the server
/// boundary through Integrated::stop, including script and save failures.
pub(super) fn wait_command_harness(&mut self) -> Result<()> {
let server = self.integrated.take().ok_or("command harness: no server")?;
while !server.stopped() {
self.poll_stationary_client()?;
std::thread::sleep(Duration::from_millis(2));
}
server.stop()
}
/// The owner's authoritative body and level, using the same swaps as a
/// server command. Always put them back, also when a script operation fails.
fn harness_owner<T>(&mut self, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
let owner = self
.serving
.as_ref()
.ok_or("command harness: not a server")?
.owner;
let mut host = self.lan.take().ok_or("command harness: no hub")?;
let guest = host.hub.guests.iter().find(|g| g.uuid == owner && g.placed);
let Some(guest) = guest else {
self.lan = Some(host);
return Err("command harness: owner is not connected".into());
};
let (conn, dim, back) = (guest.conn, super::Dim::from_id(&guest.dimension), self.dim);
if let Err(error) = self.enter_level(Some(&mut host), dim) {
self.lan = Some(host);
return Err(error);
}
let swap = self
.enter_guest(&mut host, conn)
.expect("the owner is not already acting");
self.lan = Some(host);
let result = f(self);
let mut host = self.lan.take().expect("the operation puts the hub back");
self.leave_guest(&mut host, swap);
host.hub.sync_all_effects();
self.run_level_teleports(&mut host);
let restored = self.enter_level(Some(&mut host), back);
self.lan = Some(host);
restored?;
result
}
/// The player's portal process finished in the nether portal it stands
/// in (the oracle's player is not ticked, so neither is the wait).
fn harness_portal(&mut self) -> Result<()> {
let Some(p) = &self.player else {
return Err("command harness: portal without a player".into());
};
let entry = horizon_core::position::BlockPos::containing(p.position);
let block = self.world.block(entry.x, entry.y, entry.z);
if &*self.res.facts().get(block).name != "minecraft:nether_portal" {
return Err("command harness: the player is not in a nether portal".into());
}
self.player_through_nether_portal(entry)
let (conn, plan) = self.harness_owner(|g| {
let p = g
.player
.as_ref()
.ok_or("command harness: portal without a player")?;
let entry = horizon_core::position::BlockPos::containing(p.position);
let block = g.world.block(entry.x, entry.y, entry.z);
if &*g.res.facts().get(block).name != "minecraft:nether_portal" {
return Err("command harness: the player is not in a nether portal".into());
}
let plan = g.portal_plan(
entry,
p.position,
p.dimensions.width,
p.dimensions.height,
true,
);
Ok((
g.lan_acting.ok_or("command harness: no acting owner")?,
plan,
))
})?;
// Start travel after restoring the owner's body: change_level updates
// its hub record, which leave_guest would otherwise overwrite.
self.in_owner_level(|g| {
let mut host = g.lan.take().ok_or("command harness: no hub")?;
g.start_server_trip(&mut host, conn, plan.dim, Some(plan));
g.lan = Some(host);
Ok(())
})
}
/// Load terrain until the player stands in lit chunks.
fn harness_settle(&mut self, deadline: Instant) -> Result<()> {
loop {
self.update_world();
self.spawn_player()?;
if let Some(p) = &self.player {
let (cx, cz) = (
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
);
if (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))) {
// Finish requested terrain before inspecting it, so worker
// timing cannot change which command positions are loaded.
if !self.streaming.busy()
&& (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz)))
{
// The oracle's settle also waits for the entities saved
// in those chunks (areEntitiesActuallyLoadedAndTicking):
// after a dimension change they come back from the save.
@@ -95,7 +166,19 @@ impl Game<'_> {
.and_then(|v| v.parse().ok())
.unwrap_or(600),
);
self.harness_settle(deadline)?;
// Join/configuration uses the normal hub, but never advances the world.
while !self
.lan
.as_ref()
.is_some_and(|h| h.hub.guests.iter().any(|g| g.placed && g.loaded))
{
self.tick_server(true)?;
if Instant::now() > deadline {
return Err("command harness: owner did not connect".into());
}
std::thread::sleep(Duration::from_millis(2));
}
self.harness_owner(|g| g.harness_settle(deadline))?;
let mut out = String::new();
for line in std::fs::read_to_string(script)?.lines() {
if line.trim().is_empty() {
@@ -107,9 +190,12 @@ impl Game<'_> {
let command = op["cmd"].as_str().unwrap_or("");
self.chat_capture = Some(Vec::new());
self.cmds.results = Some(Vec::new());
self.as_console(|g| {
g.run_command(command.strip_prefix('/').unwrap_or(command))
});
self.harness_owner(|g| {
g.as_console(|g| {
g.run_command(command.strip_prefix('/').unwrap_or(command))
});
Ok(())
})?;
let messages = self.chat_capture.take().unwrap_or_default();
let results = self.cmds.results.take().unwrap_or_default();
if op["record"].as_bool() != Some(false) {
@@ -124,17 +210,8 @@ impl Game<'_> {
}
"tick" => {
self.chat_capture = Some(Vec::new());
// The oracle's player has no client moving it: hold the
// player where commands put it while the game ticks.
let held = self.player.as_ref().map(|p| p.position);
for _ in 0..op["n"].as_u64().unwrap_or(1) {
self.update_world();
self.tick()?;
if let (Some(p), Some(at)) = (self.player.as_mut(), held) {
p.position = at;
p.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0);
p.fall_distance = 0.0;
}
self.tick_server(false)?;
}
let messages = self.chat_capture.take().unwrap_or_default();
if op["record"].as_bool() == Some(true) {
@@ -147,7 +224,17 @@ impl Game<'_> {
out.push('\n');
}
}
"settle" => self.harness_settle(deadline)?,
"settle" => {
// Finish portal arrivals using connection-only passes too.
while !self.server_trips.is_empty() {
self.tick_server(true)?;
if Instant::now() > deadline {
return Err("command harness: portal did not arrive".into());
}
std::thread::sleep(Duration::from_millis(2));
}
self.harness_owner(|g| g.harness_settle(deadline))?;
}
"portal" => self.harness_portal()?,
other => return Err(format!("command harness: unknown op {other}").into()),
}
+152 -68
View File
@@ -10,6 +10,7 @@ mod items;
mod misc;
mod place;
mod players;
pub(super) mod pregen;
mod raid;
mod terrain_cmds;
mod text_commands;
@@ -42,7 +43,8 @@ pub(super) enum Target {
Mob(u32),
Item(u32),
Orb(usize),
Arrow(usize),
/// By index in `creatures.projectiles`.
Projectile(usize),
Stand(u32),
Frame(u32),
Painting(u32),
@@ -83,16 +85,16 @@ fn format_double(v: f64) -> String {
impl Game<'_> {
/// Commands.performCommand for a line without its slash.
pub(super) fn run_command(&mut self, command: &str) {
pub(super) fn run_command(&mut self, command: &str) -> bool {
if self.mod_command(command) {
return;
return true;
}
let d = vanilla::game_dispatcher();
let parse = d.parse(command, &self.ui.registries);
let result = match parse.executable() {
Err(e) => {
self.syntax_failure(&e, command);
return;
return false;
}
Ok((ctx, _)) => {
let ctx = ctx.clone();
@@ -101,6 +103,7 @@ impl Game<'_> {
self.run_chain(&root, &ctx)
}
};
let ok = result.is_ok();
if let Err(e) = result {
if e.input.is_some() {
self.syntax_failure(&e, command);
@@ -112,6 +115,20 @@ impl Game<'_> {
if self.player.is_some() {
self.check_inventory_trigger();
}
ok
}
/// `run_command` with its output suppressed
/// (`CommandSourceStack.withSuppressedOutput`): the player's own source,
/// rights and checks, no feedback and no failure in the chat.
pub(super) fn run_command_quietly(&mut self, command: &str) -> bool {
let quiet = execute::Source {
silent: true,
..self.player_source()
};
let outer = self.command_source.replace(quiet);
let ok = self.run_command(command);
self.command_source = outer;
ok
}
fn syntax_failure(&mut self, e: &CommandError, command: &str) {
self.command_failure(e.message.clone());
@@ -213,7 +230,7 @@ impl Game<'_> {
}
/// Player.getDisplayName: the name (team-formatted), suggesting a
/// `/tell`, showing the player on hover, inserting the name.
fn player_display_name(&self) -> Text {
pub(super) fn player_display_name(&self) -> Text {
let name = self.acting_player_name().to_owned();
let Some(uuid) = self.lan_acting_uuid else {
return self.target_name(Target::Player);
@@ -327,7 +344,7 @@ impl Game<'_> {
.orbs
.get(i)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Arrow(i) => self
Target::Projectile(i) => self
.creatures
.projectiles
.get(i)
@@ -382,7 +399,11 @@ impl Game<'_> {
}),
Target::Item(_) => "minecraft:item".into(),
Target::Orb(_) => "minecraft:experience_orb".into(),
Target::Arrow(_) => "minecraft:arrow".into(),
Target::Projectile(i) => self
.creatures
.projectiles
.get(i)
.map_or_else(String::new, |p| p.kind.id().into()),
Target::Stand(_) => "minecraft:armor_stand".into(),
Target::Frame(id) => {
if self.item_frames.iter().any(|f| f.id == id && f.glow) {
@@ -439,7 +460,7 @@ impl Game<'_> {
.find(|i| i.id == id)
.map(|i| i.stack.item.to_string())
.unwrap_or_default();
Text::literal(self.pack.names.item(&item))
Text::literal(self.pack.names.display_item(&item))
}
Target::Mob(id)
if self
@@ -503,6 +524,7 @@ impl Game<'_> {
_ => {}
}
if s.kind != 's' {
use horizon_client::projectiles::ProjectileKind as K;
candidates.extend(self.player_candidates(s));
candidates.extend(
self.mobs
@@ -517,8 +539,12 @@ impl Game<'_> {
.projectiles
.iter()
.enumerate()
.filter(|(_, p)| p.arrow.is_some())
.map(|(i, _)| Target::Arrow(i)),
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.arrow.is_some()
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
@@ -741,13 +767,15 @@ impl Game<'_> {
&& !id.ends_with("get")
&& let Ok(t) = self.entities(ctx, "target", false)
{
self.cmds.waypoint_dirty.push(t[0]);
let target = self.waypoint_target(t[0]);
self.cmds.waypoint_dirty.push(target);
}
result
}
"ride.mount" | "ride.dismount" => self.cmd_ride(id, ctx),
"spreadplayers" => self.cmd_spreadplayers(ctx),
id if id.starts_with("forceload.") => self.cmd_forceload(id, ctx),
id if id.starts_with("pregen.") => self.cmd_pregen(id, ctx),
"fillbiome" => self.cmd_fillbiome(ctx),
id if id.starts_with("worldborder.") => self.cmd_worldborder(id, ctx),
id if id.starts_with("loot.") => self.cmd_loot(id, ctx),
@@ -1084,6 +1112,12 @@ impl Game<'_> {
Target::Player => {
// ServerPlayer.teleport: removeVehicle before placing the player.
self.stop_riding();
// A server's player teleported within their level is placed
// there at once: the arrival they were waiting for elsewhere
// in it (whose chunks lose their tickets) is abandoned.
if let Some(conn) = self.lan_acting {
self.server_trips.retain(|trip| !trip.is(conn));
}
if let Some(p) = self.player.as_mut() {
p.position = v;
p.velocity = Vec3::new(0.0, 0.0, 0.0);
@@ -1100,9 +1134,15 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.position = v;
m.previous = v;
if let Some([yaw, _]) = rotation {
// Entity.teleportSetPosition: setYRot, setYHeadRot, setXRot
// (clamped to -90..90), then setOldRot.
if let Some([yaw, pitch]) = rotation {
m.yaw = yaw;
m.body_yaw = yaw;
m.head_yaw = yaw;
m.head_pitch = pitch.clamp(-90.0, 90.0);
m.previous_yaw = m.yaw;
m.previous_head_pitch = m.head_pitch;
}
}
}
@@ -1118,7 +1158,7 @@ impl Game<'_> {
o.previous = v;
}
}
Target::Arrow(i) => {
Target::Projectile(i) => {
if let Some(a) = self.creatures.projectiles.get_mut(i) {
a.position = v;
a.previous = v;
@@ -1542,14 +1582,10 @@ impl Game<'_> {
self.orbs[i].age = u32::MAX / 2;
}
}
Target::Arrow(i) => {
if let Some(a) = self
.creatures
.projectiles
.get_mut(i)
.and_then(|p| p.arrow.as_mut())
{
a.removed = true;
// cmd_kill goes from the highest index down.
Target::Projectile(i) => {
if i < self.creatures.projectiles.len() {
self.creatures.projectiles.remove(i);
}
}
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
@@ -1571,7 +1607,7 @@ impl Game<'_> {
let mut ordered = targets.clone();
ordered.sort_by_key(|t| {
std::cmp::Reverse(match t {
Target::Orb(i) | Target::Arrow(i) => *i,
Target::Orb(i) | Target::Projectile(i) => *i,
_ => 0,
})
});
@@ -1579,9 +1615,6 @@ impl Game<'_> {
self.kill_target(t);
}
self.orbs.retain(|o| o.age < u32::MAX / 2);
self.creatures
.projectiles
.retain(|p| !p.arrow.as_ref().is_some_and(|a| a.removed));
let message = if targets.len() == 1 {
Text::translate("commands.kill.success.single", &[names[0].clone()])
} else {
@@ -1852,11 +1885,12 @@ impl Game<'_> {
}
}
}
let Some((_, new)) = horizon_client::save::decode_block_entity(&tag) else {
let Some((_, mut new)) = horizon_client::save::decode_block_entity(&tag) else {
return false;
};
let rolled = self.take_loot_table(key, &tag, &mut new);
let after = horizon_client::save::encode_block_entity(key, &new, &name);
if after == before {
if after == before && !rolled {
return false;
}
self.world.block_entities.insert(key, new);
@@ -1864,6 +1898,79 @@ impl Game<'_> {
let _ = self.world.set(pos[0], pos[1], pos[2], state);
true
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
/// container (`setblock`, `data`): an empty container with a `LootTable`
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
/// the roll is kept with its chunk, so that the container saves, and
/// `data get` shows, its table and seed until its items change. A tag
/// without one drops what the container had. True when it had a table.
pub(super) fn take_loot_table(
&mut self,
key: (i32, i32, i32),
tag: &horizon_world::nbt::Tag,
entity: &mut horizon_client::containers::BlockEntity,
) -> bool {
use horizon_client::{containers::BlockEntity, save, structure_blocks};
let table = save::text(tag.get("LootTable"));
let rolled = match (table, entity) {
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
let loot = structure_blocks::loot_data(&self.options.resources);
let at = Vec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
let biome = self.biome_name_at(at).unwrap_or_default();
Some(structure_blocks::roll_container(
slots,
loot.as_deref(),
table,
structure_blocks::loot_seed(tag),
key,
&biome,
))
}
_ => None,
};
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
return false;
};
let held = |c: &horizon_client::chunk::Chunk| {
c.saved
.as_ref()
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
};
if rolled.is_none() && !held(chunk) {
return false;
}
let chunk = std::sync::Arc::make_mut(chunk);
let mut extras = chunk
.saved
.take()
.map(std::sync::Arc::unwrap_or_clone)
.unwrap_or_default();
extras.rolled_loot.retain(|r| r.pos != key);
let taken = rolled.is_some();
extras.rolled_loot.extend(rolled);
chunk.saved = Some(std::sync::Arc::new(extras));
taken
}
/// The loot table and seed a container still holding its roll saves
/// (`RolledLoot::restore`), in place of its items.
pub(super) fn restore_loot_table(
&self,
key: (i32, i32, i32),
tag: &mut horizon_world::nbt::Tag,
) {
let horizon_world::nbt::Tag::Compound(map) = tag else {
return;
};
let rolled = self
.world
.chunks
.get(&(key.0 >> 4, key.2 >> 4))
.and_then(|c| c.saved.as_ref())
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key));
if let Some(r) = rolled {
r.restore(map);
}
}
fn cmd_fill(&mut self, ctx: &Context) -> R<i32> {
let a = self.block_pos_arg(ctx, "from")?;
let b = self.block_pos_arg(ctx, "to")?;
@@ -2490,12 +2597,14 @@ impl Game<'_> {
};
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
let counting = max == 0;
// Inventory.clearOrCountMatchingItems: main, armor, crafting grid, carried.
// Inventory slots 0..40: main, armor feet to head, off hand;
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
let mut removed = 0i32;
let mut budget = if max < 0 { i32::MAX } else { max };
let all = |g: &Self| -> Vec<Option<ItemStack>> {
let mut v: Vec<Option<ItemStack>> = g.inventory.clone();
v.extend(g.armor.iter().cloned());
v.extend(g.armor.iter().rev().cloned());
v.push(g.offhand.clone());
v.extend(g.craft_grid.iter().cloned());
v.push(g.carried.clone());
v
@@ -2509,7 +2618,8 @@ impl Game<'_> {
.collect();
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
slots.extend(self.inventory.iter_mut());
slots.extend(self.armor.iter_mut());
slots.extend(self.armor.iter_mut().rev());
slots.push(&mut self.offhand);
slots.extend(self.craft_grid.iter_mut());
slots.push(&mut self.carried);
for (slot, matches) in slots.into_iter().zip(matching) {
@@ -2557,38 +2667,7 @@ impl Game<'_> {
/// `PoiTypes`: the point of interest type of a block state.
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
let s = self.pack.assets.registry.state(state)?;
let name = s.name.strip_prefix("minecraft:")?;
Some(match name {
"blast_furnace" => "minecraft:armorer",
"smoker" => "minecraft:butcher",
"cartography_table" => "minecraft:cartographer",
"brewing_stand" => "minecraft:cleric",
"composter" => "minecraft:farmer",
"barrel" => "minecraft:fisherman",
"fletching_table" => "minecraft:fletcher",
"cauldron" | "lava_cauldron" | "water_cauldron" | "powder_snow_cauldron" => {
"minecraft:leatherworker"
}
"lectern" => "minecraft:librarian",
"stonecutter" => "minecraft:mason",
"loom" => "minecraft:shepherd",
"smithing_table" => "minecraft:toolsmith",
"grindstone" => "minecraft:weaponsmith",
"bell" => "minecraft:meeting",
"beehive" => "minecraft:beehive",
"bee_nest" => "minecraft:bee_nest",
"nether_portal" => "minecraft:nether_portal",
"lodestone" => "minecraft:lodestone",
"test_instance_block" => "minecraft:test_instance",
n if n.ends_with("_bed") => {
if s.properties.get("part").map(String::as_str) != Some("head") {
return None;
}
"minecraft:home"
}
n if n.ends_with("lightning_rod") => "minecraft:lightning_rod",
_ => return None,
})
horizon_client::save::poi_type(s).map(|(kind, _)| kind)
}
/// `LocateCommand.locatePoi`: `PoiManager.findClosestWithType` within
/// 256 blocks of the source, over the loaded chunks.
@@ -2800,7 +2879,7 @@ impl Game<'_> {
let name = match self.command_source.map(|s| s.entity) {
Some(Some(t)) => self.target_name(t),
Some(None) => Text::literal("Server"),
None => Text::literal(self.acting_player_name()),
None => self.player_display_name(),
};
let line = match id {
"say" => Text::translate("chat.type.announcement", &[name, message]),
@@ -2810,14 +2889,14 @@ impl Game<'_> {
// Whispering to yourself: the incoming and outgoing lines.
let out = Text::translate(
"commands.message.display.outgoing",
&[Text::literal(self.acting_player_name()), message.clone()],
&[self.player_display_name(), message.clone()],
)
.with_color(text::GRAY)
.italic();
self.system_message(out);
Text::translate(
"commands.message.display.incoming",
&[Text::literal(self.acting_player_name()), message],
&[self.player_display_name(), message],
)
.with_color(text::GRAY)
.italic()
@@ -2827,9 +2906,14 @@ impl Game<'_> {
capture.push(line.clone());
}
let focused = self.screen == Screen::Chat;
self.ui
.chat
.add(&self.gui.atlas, line, None, self.ticks, focused);
self.ui.chat.add(
&self.gui.atlas,
line,
None,
horizon_client::chat::ChatSource::Player,
self.ticks,
focused,
);
Ok(1)
}
/// MessageArgument: `@p`-style selectors in a message become names.
@@ -114,7 +114,7 @@ impl Game<'_> {
self.other_to_nbt(&o, uuid)
}
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
Target::Arrow(i) => {
Target::Projectile(i) => {
let p = self.creatures.projectiles.get(i)?;
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
player_uuid
@@ -126,7 +126,8 @@ impl Game<'_> {
.as_ref()
.and_then(|a| a.last_state)
.map(|s| self.state_to_nbt(s));
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)?
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
}
})
}
@@ -285,6 +286,7 @@ impl Game<'_> {
if let Tag::Compound(c) = &mut tag {
c.remove(&"keepPacked".into());
}
self.restore_loot_table(key, &mut tag);
Ok(tag)
}
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
@@ -295,7 +297,8 @@ impl Game<'_> {
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
DataRef::Block(p) => {
let key = (p[0], p[1], p[2]);
if let Some((_, new)) = save::decode_block_entity(&tag) {
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
self.take_loot_table(key, &tag, &mut new);
self.world.block_entities.insert(key, new);
let state = self.world.block(p[0], p[1], p[2]);
// Redraw what shows the block entity (signs, banners).
@@ -606,10 +606,7 @@ impl Game<'_> {
.collect(),
_ => Vec::new(),
},
Some("vehicle") => match entity {
Target::Player => self.riding.map(Target::Mob).into_iter().collect(),
_ => Vec::new(),
},
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
Some("controller") | Some("passengers") => match entity {
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
vec![Target::Player]
Loaded 100 of 804 files, more files were not shown because too many files have changed in this diff. Show more