Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 5e48972bff README and docs: the recipe book's drawing, the loom's banner preview and End gateway beams match vanilla
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 02:26:40 +02:00
Ludovic MarieandClaude Opus 5.5 eec52f9055 The recipe book draws as vanilla's widgets do; test_recipe_book_render_parity
horizon_client::recipe_book_screen lays out RecipeBookComponent's drawing
(background, search EditBox, tab buttons, the filter CycleButton, the page
of RecipeButtons with its number and arrows, the alternatives overlay,
tooltips) and GhostSlots as vanilla's GUI calls with their poses, and the
game paints them. The new harness builds vanilla's widgets for 600 random
states (VanillaRecipeBookScreenOracle) and compares every call: 600/600.

Ingredients keep their HolderSet order (tags in TagLoader order), which is
how the overlay and ghost slots cycle; the book centres over the menu on
narrow screens as widthTooNarrow does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 02:22:55 +02:00
Ludovic MarieandClaude Opus 5.5 d9054baf44 The loom's result preview draws vanilla's banner flag picture in picture
GuiBannerResultRenderer's flag model and pattern layers, lit with
Lighting.Entry.ITEMS_FLAT and clipped to the 20x40 rectangle, through the
GUI item pipeline. The block entity render harness gains loom result cases
(the oracle submits vanilla's renderToTexture under the PiP pose): 407/407.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 02:03:08 +02:00
Ludovic MarieandClaude Opus 5.5 acdbb3d27a End gateways tick their beam in game: client beamAnimationTick beside the server copy's portalTick
The anim harness now runs vanilla's TheEndGatewayBlockEntity on a client and
a server stand-in (triggerCooldown every 2400 ticks and on entry, block event
1) and compares the beam render state; 40 cases match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:55:46 +02:00
Ludovic Marie 7011c5cddd Merge branch 'parity/dedicated-server' into parity-base 2026-10-13 01:44:30 +02:00
Ludovic MarieandClaude Opus 5.5 7b55319171 lan_bot: the status description is a bare string, as vanilla writes it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:16:23 +02:00
Ludovic MarieandClaude Opus 5.5 42cdea74be LAN discovery as vanilla's list keeps it; test_lan_parity against LanServerPinger and LanServerDetection
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:11:55 +02:00
Ludovic MarieandClaude Opus 5.5 de3a285cb3 Headless Horizon client joining a vanilla dedicated server, checked by the harness
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:07:19 +02:00
Ludovic MarieandClaude Opus 5.5 6f84b9ae94 Harness names differing chunks; spawner update tags without keepPacked
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 01:02:14 +02:00
Ludovic MarieandClaude Opus 5.5 5f5b6119d2 Spawner update tags in chunk packets; harness lists recipes and advancements as known gaps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:57:10 +02:00
Ludovic MarieandClaude Opus 5.5 4d62899a10 Harness: force-load the spawn area so both servers serve full chunks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:52:35 +02:00
Ludovic MarieandClaude Opus 5.5 89d5b27b4c Dedicated server: chunk centre in placeNewPlayer, first-tick entity data; harness normalises vanilla's run-to-run orders
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 85e43462ac Block entity order as vanilla's chunk map iterates; chunk comparison without light values
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:37:51 +02:00
Ludovic MarieandClaude Opus 5.5 7ede56b596 Dedicated server: vanilla console lines, player chat, /list, legacy ping, chunk tracking, heightmaps and light layers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:32:29 +02:00
Ludovic MarieandClaude Opus 5.5 8d16a937c5 Dedicated server: load the spawn area; vanilla-codec client harness
VanillaProtocolClient joins a server with vanilla's own codecs;
test_dedicated_server_parity.py compares a vanilla and a Horizon dedicated
server serving the same world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 00:18:01 +02:00
Ludovic MarieandClaude Opus 5.5 3319f92c0e Merge parity-base into parity/dedicated-server; track player idle time
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 23:55:53 +02:00
Ludovic MarieandClaude Opus 5.5 4b93509137 Note the render distance 2 loading hang, and run harnesses here by default
The Mac mini and ThinkPad no longer keep the shared vanilla data, so the
other machines are opt-in again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 19:50:58 +02:00
Ludovic MarieandClaude Opus 5.5 6909dbfc5b Mention the optional sounds step in PLAY.txt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 19:44:37 +02:00
Ludovic MarieandClaude Opus 5.5 002bd18170 Link the C runtime statically into the Windows build
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 19:31:00 +02:00
Ludovic Marie 6fab5eb279 Merge branch 'main' into parity-base
# Conflicts:
#	README.md
#	crates/horizon-client/src/game.rs
2026-10-12 19:02:09 +02:00
Ludovic MarieandClaude Opus 5.5 2f0ff87295 Describe what is new and the 88% parity audit in the README
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 19:00:32 +02:00
Ludovic MarieandClaude Opus 5.5 222345b5d1 Build Horizon on Windows and package playable folders
The control socket reports itself unavailable on Windows, which has no
Unix domain sockets, and horizon-ctl uses the same stream type. A release
workflow builds the Windows client by hand; packaging/package.sh assembles
the binary, the resource preparation tool and PLAY.txt, without any of
Minecraft's files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:58:25 +02:00
Ludovic MarieandClaude Opus 5.5 c51bfda80c Run the server files harness and platform-sensitive harnesses where they agree
Number the server files cases, whose folders eula-true and eula-TRUE collide
on case-insensitive macOS file systems; keep world_border_render and mob_ai
off the Mac, where platform float math changes their results; give the local
executor the dead flag the flake check reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:57:30 +02:00
Ludovic MarieandClaude Opus 5.5 f88b1da965 Refuse only worlds older than 1.17 in the save upgrade test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:58:34 +02:00
Ludovic MarieandClaude Opus 5.5 6402d44ce7 Pass clippy and rustfmt after merging the parity branches
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:57:27 +02:00
Ludovic Marie fc7420f74b Merge branch 'parity/old-saves' (before its WIP commit) into parity-base 2026-10-12 17:55:17 +02:00
Ludovic Marie 5731a9406f Merge branch 'parity/dedicated-server' (before its WIP commit) into parity-base 2026-10-12 17:55:17 +02:00
Ludovic Marie 404072d5e5 Merge branch 'parity/warden-sculk' (before its WIP commit) into parity-base
# Conflicts:
#	crates/horizon-client/examples/entity_anim_fixture.rs
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/creatures.rs
#	tools/test_entity_model_parity.py
2026-10-12 17:55:17 +02:00
Ludovic Marie 2e3063d821 Merge branch 'parity/mobs-b' into parity-base
# Conflicts:
#	crates/horizon-client/examples/entity_anim_fixture.rs
#	crates/horizon-client/examples/mob_ai_probe.rs
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/mobs.rs
#	crates/horizon-client/src/mobs/ai.rs
#	mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobAiOracle.java
#	tools/test_mob_ai_parity.py
2026-10-12 17:54:20 +02:00
Ludovic Marie f02c47d289 Merge branch 'parity/mobs-a' into parity-base 2026-10-12 17:50:53 +02:00
Ludovic Marie 14bff2518f Merge branch 'parity/raids' into parity-base
# Conflicts:
#	README.md
#	docs/rust-parity.md
2026-10-12 17:50:53 +02:00
Ludovic Marie c195cd3239 Merge branch 'parity/tablist-border' into parity-base
# Conflicts:
#	README.md
#	crates/horizon-client/src/lib.rs
#	docs/rust-parity.md
2026-10-12 17:50:31 +02:00
Ludovic Marie 69b191b1f4 Merge branch 'parity/loom-crafter' into parity-base
# Conflicts:
#	README.md
#	crates/horizon-client/src/game/dispense_fixture.rs
#	docs/rust-parity.md
#	mods/horizon/src/test/java/dev/horizonlab/parity/VanillaDispenserOracle.java
2026-10-12 17:48:44 +02:00
Ludovic Marie b6fa612bee Merge branches 'parity/tooling', 'parity/score-subtitles' and 'parity/block-followups' into parity-base 2026-10-12 17:47:58 +02:00
Ludovic MarieandClaude Opus 5.5 613a0dd3f1 WIP: unfinished dedicated-server parity work, stopped by Ludovic
Uncommitted changes the agent had in progress when it was stopped on
2026-10-12; may not build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:46:50 +02:00
Ludovic MarieandClaude Opus 5.5 c90cd5f072 Match vanilla llama caravans, trader llamas, breeding and wolves
A lead gives its mob a home at the holder (PathfinderMob.whenLeashedTo)
and every random position helper honours homes (generateRandomPos-
TowardDirection's lean back and GoalUtils.isRestricted), as the happy
ghast's float area now does too. LlamaFollowCaravanGoal keeps 2.1F, a
llama's baby and the breeding experience are drawn from the parent's
random, wolves run from llamas by strength (WolfAvoidEntityGoal), a
growing mob refits its box (fudgePositionAfterSizeChange) and a happy
ghast's new controls want nothing. Scenarios can lead mobs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:45:09 +02:00
Ludovic MarieandClaude Opus 5.5 ca1bc52a49 Stop the jukebox's song and the beacon as their block entities go
LevelChunk.removeBlockEntity calls setRemoved: a jukebox reports its song
stopping (the jukebox_stop_play game event and level event 1011) and a
beacon plays its deactivation, whatever the flags. A record popped from a
replaced jukebox stops a playing song first and updates the neighbours as
JukeboxBlockEntity.onSongChanged does (a redstone wire beside it can pop).
The block update oracle removes block entities the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:44:54 +02:00
Ludovic MarieandClaude Opus 5.5 b0ce83dd2d Port the 1.17 to 1.18 chunk height, biome and tick fixes (2825 to 2846)
ChunkHeightAndBiomeFix, ChunkProtoTickListFix, ChunkRenamesFix,
OverreachingTickFix, SpawnerDataFix, the world generation settings fixes
and the Caves & Cliffs biome and advancement renames, with the chunk's
Level tag and UNTAGGED_SPAWNER in the schema walker. A 1.17.1 sample
world upgrades as vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:42:59 +02:00
Ludovic MarieandClaude Opus 5.5 63f628762f Tick creaking hearts as vanilla and compare them with the oracle
mobs::creaking_heart is CreakingHeartBlockEntity: the ticker drawn from
the level random, the state following the night and the logs, the
protector spawned with SpawnUtil.trySpawnMob (five tries within 16
blocks, on top of a collider, then EntityType.create's yaw and
Mob.finalizeSpawn's follow range bonus), its sounds, letting it go by
day, beyond 34 blocks or with a player stuck in it, and when a player
hurts it the trails and the resin spread over the logs. The game ticks
its hearts through it, and its level random now carries on between
ticks.

The mob AI harness gains hearts: the oracle places them when a scenario
starts, ticks block entities after the mobs, seeds the creakings the
level creates and adds them to the scenario, prints each heart's state
and block entity, and lets the player hit a mob (hurt T I). Five heart
scenarios (night, day, uprooted, no player near, hit twice) match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:40:47 +02:00
Ludovic MarieandClaude Opus 5.5 e5dc127ab2 Document scoreboards, teams and subtitles and their harnesses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:39:55 +02:00
Ludovic MarieandClaude Opus 5.5 05228e0fe2 Document the loom, crafter and recipe book harnesses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:39:45 +02:00
Ludovic MarieandClaude Opus 5.5 b5c0bf7859 Draw the loom and crafter screens as the vanilla client does
LoomScreen.extractBackground (slot icons, scroller, pattern buttons
with their swatches and tooltip, the result banner or the six-layer
error) and CrafterScreen's background, disabled slots and redstone
arrow become screen_layout calls the game paints. A new harness runs
vanilla's screens on stand-in menus and compares 1,600 call lists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:38:25 +02:00
Ludovic MarieandClaude Opus 5.5 4b776a45d8 Tick blocks before entities, as ServerLevel.tick does
The scheduled and random block ticks now run after the player's digging
and before any entity, so items a dispenser shoots or a block pops get
their first tick on the tick they appear, as vanilla's do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:36:33 +02:00
Ludovic MarieandClaude Opus 5.5 225c12f4cc Move subtitles into the HUD overlays and save scoreboards end to end
The subtitle overlay now lays out GuiDraw calls in hud_overlays and the
game paints them like the other overlays. Hover events that show an entity
save its UUID as an int array, as UUIDUtil.CODEC does. The save round trip
builds a vanilla scoreboard with every saved field (objectives, number
formats, display slots, locked and displayed scores, teams with every
option) and requires Horizon to save scoreboard.dat as vanilla did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:35:05 +02:00
Ludovic MarieandClaude Opus 5.5 01db93a987 Port server.properties, eula.txt and the server user lists
DedicatedServerProperties with every key, default, parser and serializer,
java.util.Properties load/store, Eula and the four StoredUserList files,
checked against the pinned server JAR by test_server_files_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:34:57 +02:00
Ludovic MarieandClaude Opus 5.5 8f80f8decf Document block entity removal, immediate drops and copper golems
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:33:49 +02:00
Ludovic MarieandClaude Opus 5.5 13102892c4 Render, save and gift copper golems as vanilla does
The copper golem takes its weathering's texture and glowing eyes, saves
and loads next_weather_age and weather_state, starts with its weathering
unset instead of weathering on its first tick, and throws what it carries
to the player as BehaviorUtils.throwItem does. Iron golems offer their
poppy to copper golems too (#candidate_for_iron_golem_gift) and put it on
an empty antenna when the offer runs out beside one.

The entity model harness now poses the copper golem with its idle and
chest interaction animations (44 cases), and the entity death loot harness
includes it (1,595 cases over 88 types); both match vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:31:52 +02:00
Ludovic MarieandClaude Opus 5.5 82eda1d8bf Port the 1.18 structure fixes (2967, 2970) and add a 1.18.1 sample world
Old server jars run on Mojang's own Java runtimes, pinned in
research-lock.json and installed beside the shared vanilla data.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:30:48 +02:00
Ludovic MarieandClaude Opus 5.5 515d15d36f Merge the integration branch into parity/tablist-border
Keep the integration branch's StringSplitter port and HUD overlays: the tab
list now emits hud_layout::GuiDraw calls and wraps with its split_lines, and
the vignette's border warning comes from WorldBorder::warning_strength
through hud_overlays::border_warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:25:13 +02:00
Ludovic MarieandClaude Opus 5.5 a425de4c1c Merge the integration branch: villager brain, mob AI fixes and HUD overlays
The raid bars now draw through hud_overlays. Villagers' raid field is
what getRaidAt finds where they stand, with lost raids and the wave flag
kept apart, so the brain's SetRaidStatus, ResetRaidStatus (which resets on
a lost raid), ReactToBell and raid packages read vanilla's conditions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:23:59 +02:00
Ludovic MarieandClaude Opus 5.5 81e97617af WIP: share the creaking heart's block entity tick
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:22:02 +02:00
Ludovic MarieandClaude Opus 5.5 e664d2f7c4 Merge origin/t3/achieve-full-parity into parity/block-followups
Explosion drops keep their random sequences and the pot's dynamic drops;
/setblock destroy and /fill destroy run Level.destroyBlock through the
behaviour library, so the block entity spills as vanilla's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:19:39 +02:00
Ludovic MarieandClaude Opus 5.5 e723c8ad6c Merge origin/t3/achieve-full-parity into parity/score-subtitles
Mobs push through the new pushEntities with the teams' collision rules;
lang keeps the integration branch's deprecated.json handling; the
scoreboard keeps this branch's vanilla hash orders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:19:13 +02:00
Ludovic MarieandClaude Opus 5.5 2edae5746a Merge origin/t3/achieve-full-parity into parity/mobs-b
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:18:39 +02:00
Ludovic Marie 5dd2840cee Merge remote-tracking branch 'origin/t3/achieve-full-parity' into parity/warden-sculk
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/creatures.rs
2026-10-12 17:18:11 +02:00
Ludovic Marie a5eb241c32 Merge remote-tracking branch 'origin/t3/achieve-full-parity' into parity/loom-crafter
# Conflicts:
#	crates/horizon-client/src/game/commands/world_cmds.rs
2026-10-12 17:15:59 +02:00
Ludovic MarieandClaude Opus 5.5 8393b8de0f Match vanilla breeding sniffers and tempted happy ghasts
Mobs set each other's brain memories within the tick (the breed target
and lockGazeAndWalkToEachOther), the partner rests and forgets at the
birth, and an animal in love draws its heart particle randoms. The adult
happy ghast's TemptGoal.ForNonPathfinders ticks every other tick and
follows creative players. The mob AI scenarios can give the player an
item and a mob love, taming, an age or a llama strength.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:15:34 +02:00
Ludovic MarieandClaude Opus 5.5 233dadf29f Run harnesses on the ThinkPad too, two at a time at nice 19
A sample of 27 harnesses (the 20 quickest, options, datafix's six shards)
passed there; it builds against the same ALSA headers as the desktop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:15:18 +02:00
Ludovic MarieandClaude Opus 5.5 b56b4c3cea Bring the copper golem into the game and build it and shear it as vanilla does
The copper golem code (its goals, chest transport, weathering, statues and
interactions) was in the tree but not compiled; it is now a species with
its model, keyframe animations, sounds and game hooks. Its antenna flower
sits in the saddle slot as vanilla's EQUIPMENT_SLOT_ANTENNA, sheared by
players and dispensers with the unequip event and the 1.5-up drop, and
dropped with the carried stack as preserved equipment. A pumpkin on a
copper block builds it after the snow and iron golem patterns: both blocks
clear, the block becomes the matching copper chest (CopperChestBlock
getFromCopperBlock with the least oxidized of connected chests), and the
golem takes the block's weathering and plays its spawn sound. Weathering
draws from the level random, the statue pose from the golem's own.

The dispenser harness now builds copper golems and shears and equips them:
6000/6000 cases match.

Block.popResource of tall flowers and flower beds and the composter's
bone meal pop when the behaviour asks, as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:14:20 +02:00
Ludovic MarieandClaude Opus 5.5 8ba728287f Order the recipe book's buttons as the vanilla client does
ClientRecipeBook keeps the displays it was sent in a HashMap of display
ids, whose iteration order (buckets, then arrival) orders the
collections; the game now keeps such a client book (on the verified
Java HashMap port) synced with the server book, with display ids
numbered as RecipeManager does. A new harness runs the client's
ClientRecipeBook, RecipeCollection selection and the tab and page logic
of RecipeBookComponent over 1,200 cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:14:14 +02:00
Ludovic MarieandClaude Opus 5.5 59a966a9e3 Compare breezes and creakings with vanilla in the mob AI harness
Eight scenarios: a breeze alone and fighting a player near, inside and
beyond its jump circle; a creaking alone, watched, unwatched, and woken
then hunting once the player turns away (a new player turn= option).
The oracle now faces the player +z with its head too (it kept a random
head yaw from its creation, which the breeze's jump target reads).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:14:07 +02:00
Ludovic MarieandClaude Opus 5.5 245ceb5428 Port the breeze and creaking brains
monster_brain runs BreezeAi and CreakingAi as vanilla's Brain does:
the sensors with their random first delays, memories that expire, the
core, idle and fight activities, and each behaviour (Shoot, LongJump,
ShootWhenStuck, Slide, SlideToTargetSink, StartAttacking, MeleeAttack,
RunOne...) started and ticked in vanilla's order, with durations drawn
from the level random. It reuses the villager brain's memory slots.

Breeze.tick draws its particle count and whirl clock before the entity
tick; its whirl and idle sounds are local (client) sounds; it is immune
to fall damage and plays its landing sound. Entity.lookAt and the long
jump's atan2 (an fdlibm port) are bit-exact. A frozen creaking keeps
thinking while its navigation and controls wait, stopInPlace zeroes its
motion, and its attack sound follows the hit. Mobs update their ground
flag on every move, as a server's authoritative entities do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:14:02 +02:00
Ludovic MarieandClaude Opus 5.5 839ea6d04c Give the level random back to the level after the mobs' ticks
Brain behaviours draw from the level random (DoNothing, POI scans): the
game lends it to the mobs and keeps what they drew.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:13:57 +02:00
Ludovic Marie b98c67b04f Merge remote-tracking branch 'origin/t3/achieve-full-parity' into parity/mobs-a 2026-10-12 17:13:55 +02:00
Ludovic MarieandClaude Opus 5.5 d98576a1a4 Document remote hosts, shards, Rust scopes, flake checks and trial merges
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:11:09 +02:00
Ludovic MarieandClaude Opus 5.5 a4355c7c9b Catch flaky harnesses: --repeat, and three runs in the gate
parity.py --repeat N runs each harness N times; its runs must agree, else it
is reported FLAKY and recorded as failing. Later rounds run on the machine
that ran the first, reusing its oracle results, and never two at once there.
With --flake-check REF (passed by gate.py, merge base with parity-base; by
supervise.py integrate, the integration branch before the merge), harnesses
whose script, oracle or probe is new or changed must pass three runs in a
row; a pass records its rounds so it is not repeated again. gate.py matches
shards of a known failure by the harness's name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:10:08 +02:00
Ludovic MarieandClaude Opus 5.5 bbae62fc4b Render subtitles exactly as vanilla's SubtitleOverlay
The overlay listens to the sound engine from the frame that renders it with
Show Subtitles on until one renders with it off (keeping its captions),
forgets plays as it renders, picks the latest play in range (the first of
full ties), fades in float arithmetic, lays the boxes out in integers under
the pose translation and takes its background from the text background
opacity options. Translations now apply the game's deprecated.json renames,
as ClientLanguage does ("Chick hurts"). tools/test_subtitle_parity.py runs
vanilla's SubtitleOverlay on 1500 scripted cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 000e676b57 Compare the warden's animations and emissive layers with vanilla
The entity model harness now sets the warden's render state (tendril and
heart animation, the roar, sniff, emerge, dig, attack and sonic boom
animation states) and bakes its four emissive layers (tendrils, heart,
bioluminescent skin, pulsating spots) over WardenModel: 430 cases match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:06:36 +02:00
Ludovic MarieandClaude Opus 5.5 621fca874c Keep the player's recipe book as vanilla's ServerRecipeBook does
The known and highlighted recipes, the book settings and the saved
recipeBook compound now live in one place (recipe_book::Settings with
a RecipeIndex of learnability and display counts): advancement rewards,
/recipe give and take (counting displays, never special recipes) and
crafting a recipe award through it, and loading drops unknown recipes
instead of rebuilding the set from done advancements. A new harness
compares 1,500 operation sequences with vanilla's ServerRecipeBook and
its codec.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:02:53 +02:00
Ludovic MarieandClaude Opus 5.5 2f232d955c Charge sculk catalysts when the killing blow lands
Mobs die (LivingEntity.die: the death event, catalysts, loot and
experience) when they are killed, not when their death animation ends.
Catalysts take a death in the dispatcher's order (nearest by exact
distance, then visiting order); experience orbs draw their merge id from
the level random and their motion from their own. The sculk harness
gains catalyst scenarios: kills by the player near catalysts over
random terrain, with blooms, charge cursors and the sculk they spread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:01:59 +02:00
Ludovic MarieandClaude Opus 5.5 4a2d801d2b Apply teams in gameplay: targeting, explosions, pushing, names, deaths
Mobs on the player's team never target the player; a creeper's blast
spares its team mates when its team has no friendly fire; mobs push each
other only as their collision rules allow; name tags follow the team's
name tag visibility, show the team's prefix, suffix and colour and the
score of the objective displayed below names; the player's own death
message stays out of chat when the team hides death messages. The
criteria that follow the player compare health as a float, round armor
down and give auto-updated displays the player's team-formatted name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:52:15 +02:00
Ludovic MarieandClaude Opus 5.5 acf4df3a90 Place recipe book recipes as vanilla's ServerPlaceRecipe does
ServerPlaceRecipe and StackedContents' RecipePicker run on the menu
engine (horizon_client::menu::place) for the inventory, crafting table
and furnaces: testClearGrid, clearGrid with placeItemBackInInventory,
the biggest craftable stack, clamping to stack sizes and moveItemToGrid.
A new harness compares 6,000 placements with vanilla; vanilla's item
order follows identity hash codes, so the oracle hands its order to the
Rust side. The game's recipe book now places through the engine, and
its craftable filter uses the same counts and placement ingredients.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:51:38 +02:00
Ludovic MarieandClaude Opus 5.5 09a0a0e261 Match vanilla happy ghasts and ghastlings
The adult's careful GhastMoveControl (the blocks around the destination
and swept on the way, #happy_ghast_avoids, fluids), HappyGhastLookControl,
HappyGhastFloatGoal, the still timeout of a player standing on top, and
getSuitableFlyToPosition returning its first good point. The ghastling
flies by its brain with getWalkTargetValue's preference for air just
above the ground. Flying travel drags by 0.91F and 0.8F as floats, and a
happy ghast swims as fast as it flies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:51:36 +02:00
Ludovic MarieandClaude Opus 5.5 4490ff75fc Spill block entity contents when their block is replaced, as vanilla does
LevelChunk.setBlockState now runs BlockEntity.preRemoveSideEffects and
removes the block entity when a block with one is replaced by another
block (copper chests and copper golem statues keep theirs across
weathering; UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS skips the side effects).
Containers spill every slot through Containers.dropItemStack (spot and
pieces of 10 to 30 with triangular motions from the level random, empty
slots still drawing their spot), jukeboxes pop their record, lecterns
their book; shulker boxes keep their contents for the dropped box.
Campfires, decorated pots, chiseled bookshelves and shelves, which Horizon
keeps as loaded, spill too.

Block.dropResources now draws its loot and pop spots when the behaviour
calls it, not after it returns, and spawnAfterBreak awards ore, sculk and
spawner experience. Item entities and orbs the behaviour makes are added
before it returns to the game, in the same tick. Player mining follows
ServerPlayerGameMode.destroyBlock's order (removal and spill, tool wear,
then the drops); explosions let the removal spill the contents.
ExperienceOrb.award draws the merge id from the level random and moves
the orb with its own random. Decorated pots copy their decorations and
drop their sherds when cracked (loot tables' dynamic entries).

The block update oracle replays preRemoveSideEffects too, and the new
block removal harness (tools/test_block_removal_parity.py) compares
setBlock/destroyBlock/removeBlock on every block entity holder with
contents against vanilla: 2000/2000 cases match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:49:20 +02:00
Ludovic MarieandClaude Opus 5.5 cdd05f8e8b Trial-merge branches before integrating them
supervise.py check trial-merges every candidate branch into the integration
branch with git merge-tree, without touching a worktree: each alone, every
pair, then the largest set that merges cleanly together, in about a second.
integrate starts from that set (or every parity/* branch with new commits,
with --ready), gates it once, and if the gate fails tries each half, so the
branches that pass still land.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:43:58 +02:00
Ludovic Marie e6aa9f71b0 Merge branch 'parity/rendering-be' into parity-base 2026-10-12 16:43:52 +02:00
Ludovic Marie 93c3de5d61 Merge branch 'parity/hud' into parity-base 2026-10-12 16:43:50 +02:00
Ludovic Marie 095991b946 Merge branch 'parity/rendering-hud' into parity-base 2026-10-12 16:43:49 +02:00
Ludovic Marie cfb19e238f Merge branch 'parity/rendering-anim' into parity-base 2026-10-12 16:43:49 +02:00
Ludovic Marie ec6c2c596f Merge branch 'parity/entity-ai' into parity-base 2026-10-12 16:43:48 +02:00
Ludovic Marie 577e68fd12 Merge branch 'parity/tooling' into parity-base 2026-10-12 16:43:47 +02:00
Ludovic MarieandClaude Opus 5.5 70992eb815 Document the patrol and raid harness and its scope
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:43:32 +02:00
Ludovic MarieandClaude Opus 5.5 cb0244ce4a Fill the mob effect registry so /effect finds its effects
The command registries never received the effect ids, so every /effect
and mob_effect argument failed to resolve.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:43:32 +02:00
Ludovic MarieandClaude Opus 5.5 a05f9b7b8c Run harnesses on the Mac mini by default
Every harness gave the same result on the Mac mini as on Linux, except
options and chunk_generation, where Rust's float functions use the platform
libm and round differently on macOS: those stay off the Mac. gate.py and
supervise.py no longer force 8 local harnesses, so parity.py keeps the
desktop to 3. The ThinkPad is listed (2 harnesses, nice 19) but not used by
default. Oracle runs get a 15-minute hang guard instead of 5, since one oracle
on a fully loaded machine can take more than 5 minutes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:42:07 +02:00
Ludovic MarieandClaude Opus 5.5 cb19f5e7d6 Make the datafix harness take 40 seconds instead of 25 minutes
The oracle spent 58 of every world's 62 seconds waiting for the idle
threads of the game's IO pool to time out; it now exits once its outputs are
written (same outputs on all 22 worlds). The harness runs in 6 shards, keeps
the vanilla outputs so the oracle's results are reused, and declares its Rust
scope (datafix, NBT, region files, compression and horizon-core positions):
other horizon-world changes no longer rerun it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:41:51 +02:00
Ludovic MarieandClaude Opus 5.5 f5ab40d415 Shard harnesses, narrow their Rust sources, keep the desktop light
parity.py:
- A harness declaring PARITY_SHARDS = N runs as N entries, NAME#1..NAME#N,
  each told its share through PARITY_SHARD; NAME selects them all.
- PARITY_RUST_SCOPE lists the Rust sources a harness's probes can reach.
  It applies only if closed (nothing in it names a module, crate-root
  re-export or macro outside it, and nothing outside implements anything for
  its types); crate roots then count without their other module declarations.
- Bytecode caches no longer count as dependencies.
- With other hosts, this machine runs 3 harnesses at once and only those
  under 800 MB, admitted against the memory free at each start; hosts can
  exclude harnesses and run at a nice level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:41:51 +02:00
Ludovic MarieandClaude Opus 5.5 5352b59358 Tick the sniffer's and the ghastling's brains
The level random is passed down to the mob AI step as the integration
branch does, the brain runs in customServerAiStep's place, and the mob AI
probe builds the brain after seeding the mob and prints its activities and
memories. The oracle seeds every GateBehavior's shuffle with the mob's seed
(the same change the entity-ai branch makes) so RunOne picks are
reproducible.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:36:44 +02:00
Ludovic MarieandClaude Opus 5.5 1515c5e46c Add vanilla's team rules and compare them with vanilla
Friendly fire (doTeamsAllowDamage), alliances, collision rules
(EntitySelector.pushableBy), seeing invisible team mates (isInvisibleTo),
name tag visibility (LivingEntityRenderer.shouldShowName) and who sees a
player's own death message. tools/test_team_rules_parity.py runs vanilla's
methods on stand-in entities and a stand-in client over 6000 cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:36:04 +02:00
Ludovic MarieandClaude Opus 5.5 89c3a155c5 Play the sniffer's digging and searching sounds on the client side
The searching sound and the digging block hit sound are the client's
(playSearchingSound, emitDiggingParticles), the digging sound starts with
the dig (SnifferSoundInstance), and the egg plop and the breeding
experience come from the parent's random in its brain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:31:58 +02:00
Ludovic MarieandClaude Opus 5.5 eb9bee05bd Run the loom and the crafter menu on the verified menu engine
LoomMenu (selectable patterns, clickMenuButton, the six-layer limit,
quick moves, the result's onTake) and the server's CrafterMenu over the
block entity (the result following the grid, slot state packets, filled
disabled slots enabling) join the menu click harness, now 8,000 click
sequences over 19 menus with pattern button and slot state ops. Slot
clicks end with setChanged and container slots are limited to their
stack size as SimpleContainer does. The game's loom and crafter screens
now click through Game::menu_session on the same engine.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:31:00 +02:00
Ludovic MarieandClaude Opus 5.5 4e871bf22e Match vanilla llamas: the horse tail draw and mobs seeing earlier mobs' moves
AbstractHorse.aiStep draws a tail swish every tick before the goals run.
Mobs now see the mobs ticked before them as they are after their tick
(ServerLevel ticks entities one after another), in the game and in the
mob AI probe; a baby llama following its parent depended on it. The
llama's strength is read raw, as getStrength does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:29:00 +02:00
Ludovic MarieandClaude Opus 5.5 b8711a7c64 Add a sculk vibration harness against a vanilla server level
VanillaSculkOracle runs sensor, calibrated sensor and shrieker scenarios
(scripted game events and block changes, the player's warning level) in
a real headless level ticked in ServerLevel.tick's order; the game runs
the same scenarios through its own sculk code (HORIZON_SCULK_FIXTURE).

Fixes found on the way: sculk block entities tick in the order they were
added, the entity phase of sculk runs before the block entities, client
side effects of level events draw from the client random, scheduled
ticks run one at a time so a shrieker summons inline, wardens are
summoned as SpawnUtil does (obstruction by entities, the yaw and
finalizeSpawn draws, agitated sounds of discarded attempts), and an
empty level no longer draws the level random every tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:25:06 +02:00
Ludovic MarieandClaude Opus 5.5 68520308d0 Keep the scoreboard in vanilla's hash orders and save it as its codec does
The objectives, holders and teams now iterate as vanilla's fastutil maps and
each team's members as its HashSet; criteria resolve to their canonical
names as ObjectiveCriteria.byName does; scoreboard.dat leaves out the
fields equal to the codec defaults and writes components as
ComponentSerialization does. tools/test_scoreboard_parity.py runs vanilla's
own Scoreboard against the model on 500 scripted cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:23:34 +02:00
Ludovic MarieandClaude Opus 5.5 81783a5125 Port PatrolSpawner, Raid and Raids exactly, and run the game on them
The patrol spawner, the raid tick (countdown, spawn positions, wave
composition, bonus wave, leaders, riders, boss bar, horn seed, raiders
leaving, victory, loss, celebration) and the raids.dat codec with
DataFixerUpper's partial results and fastutil's map order now follow
vanilla line by line. tools/test_raid_parity.py runs vanilla's own
PatrolSpawner, Raids and Raids.CODEC on a stand-in level: 1,900 cases
match.

In game, raiders join, are loaded, hurt and killed through addWaveMob,
updateBossbar and removeFromRaid as vanilla calls them, the wave leader
map lives in the raid, recruits take the raid's current wave, raid and
patrol mobs keep their spawn rotation, and each dimension keeps its own
raids.dat. Bad Omen and Raid Omen act from inside the effect tick, with
the ConcurrentModificationException vanilla swallows when Raid Omen is
first added, and the omen position is saved with the player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:22:29 +02:00
Ludovic MarieandClaude Opus 5.5 0e076c7153 Port the sniffer's and the ghastling's brains
SnifferAi and HappyGhastAi as vanilla's Brain runs them: the sensors and
their scan rates, the activities and their requirements, and every
behaviour (AnimalPanic, MoveToTargetSink, FollowTemptation, AnimalMakeLove,
LookAtTargetSink, RunOne and the sniffer's scenting, sniffing, searching,
digging, rising and happy states) in availableBehaviorsByPriority order.
Adds AirAndWaterRandomPos and the mob AI scenarios for llamas, sniffers
and happy ghasts. The brain is not ticked yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:17:44 +02:00
Ludovic MarieandClaude Opus 5.5 95c7e7ad2a Document the player list and world border parity harnesses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 e4bb8463b9 Show the player list and the world border wall in the game
Holding the player-list key draws tab_list's layout: in single player the
local player with the list objective (vanilla shows nothing otherwise), on
a server the listed players from PlayerInfoUpdate (game mode, latency,
display name, list order, hat), the tab list header and footer, online
mode for heads and the server's objectives, scores, display slots and
teams, which now also feed the sidebar. The renderer draws the border wall
with RenderPipelines.WORLD_BORDER's overlay blend and the forcefield
texture, from border_render's buffer and transforms; the client border
follows the server's border packets and ticks between them. The vignette
reddens near the border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 16227901fc Check the crafter against vanilla with a crafting harness
CrafterBlock.dispenseFrom and CrafterBlockEntity.serverTick over 4,000
cases: recipes from the datapack with remainders and specials, disabled
slots, containers and other crafters in front. The crafter now reports
its level events (1049, 1050, white smoke 2010) through the client
random, hoppers merge stacks only with equal components, brewing stands
take items as a WorldlyContainer, and empty written book pages are not
written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:05:18 +02:00
Ludovic MarieandClaude Opus 5.5 1c9c9486f6 Run vanilla harnesses on other machines with parity.py --hosts
A worker started over ssh (parity.py --worker) runs harnesses on a mirror of
the worktree on another machine, here the Mac mini. One longest-first queue
feeds every machine, remote ones first; passes come back with the content
hash of each dependency, so they are keyed on exactly what ran. Harnesses
that need this machine are listed in tools/parity_hosts.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:54:35 +02:00
Ludovic MarieandClaude Opus 5.5 95015b7828 Compare sniffer animation states, happy ghast layers and the llama decor model
The entity model harness now drives the sniffer's five keyframe animation
states and its search walk, the happy ghast body item, riding and leash
holder on its body, harness and ropes layers, and LlamaDecorLayer's model.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:51:58 +02:00
Ludovic MarieandClaude Opus 5.5 cba3f5bb6a Compare the breeze's animations and its wind and eyes layers with vanilla
The entity model harness now poses the breeze's six animation states and
bakes BreezeWindLayer's and BreezeEyesLayer's models and the creaking's
emissive eyes model (141 and 64 cases, no differences).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:51:57 +02:00
Ludovic MarieandClaude Opus 5.5 8a32850f95 Lay out the player list exactly as vanilla PlayerTabOverlay does
tab_list ports PlayerTabOverlay: the comparator (list order, spectators,
team, name ignoring case), the 80-player limit and 20-row columns, heads
with the default skin by UUID and upside-down names, team and spectator
name styling, ping bars, the list objective as integer scores in their
number format or hearts with their change blink and the hp text fallback,
and the wrapped header and footer. Font::split_lines now follows
StringSplitter.splitLines and Text::resolve_legacy applies legacy codes
inside components. test_tab_list_parity runs vanilla's overlay against
examples/tab_list_fixture: 1,000 cases, 3,476 frames, 844,758 calls match.

The protocol gains the scoreboard, team and world border packets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:47:55 +02:00
Ludovic MarieandClaude Opus 5.5 84afe5a020 Merge origin/parity/rendering-hud into parity/rendering-be
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:40:54 +02:00
Ludovic MarieandClaude Opus 5.5 36b944ec3d Merge origin/parity/rendering-anim into parity/rendering-be
Client-ticked animation state now drives the renderer alongside the End
portal and gateway rendering; gateways, whose ages and cooldowns the client
does not track yet, keep rendering settled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:40:50 +02:00
Ludovic MarieandClaude Opus 5.5 a086bd83ba Merge parity/entity-ai, parity/rendering-anim and parity/rendering-hud into parity/hud
Keep the nbt contents hook and stack_display_name from parity/hud with
rendering-hud's object_style and first-component sibling style.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:40:44 +02:00
Ludovic Marie 94508b4528 Merge remote-tracking branch 'origin/parity/rendering-anim' into parity/hud 2026-10-12 15:39:07 +02:00
Ludovic Marie 136b74011c Merge remote-tracking branch 'origin/parity/entity-ai' into parity/hud 2026-10-12 15:39:06 +02:00
Ludovic MarieandClaude Opus 5.5 af463e976b Merge origin/parity/entity-ai into parity/rendering-be
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:39:05 +02:00
Ludovic MarieandClaude Opus 5.5 3467380074 Render the world border wall and its vignette exactly as vanilla does
The client border now wraps horizon-core's vanilla-checked BorderGeometry
(previous size for partial ticks, status, lerp speed, absolute limit) and
border_render ports WorldBorderRenderer: extract's fade and tint, the cached
wall buffer, the scrolling texture transform and which walls draw. The
vignette takes vanilla's int colour and the warning strength from the
border. test_world_border_render_parity runs vanilla's renderer against
examples/world_border_render_fixture: 1,500 cases, 16,859 frames match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:29:53 +02:00
Ludovic MarieandClaude Opus 5.5 f94aa2a3d6 Assemble /place jigsaw pieces from the generation context's random
JigsawPlacement.generateJigsaw builds the pieces with the GenerationContext's
WorldgenRandom (large feature seed from the level seed and the position's
chunk) and only places them with the level random. Horizon assembled them from
the level random, so the village layout, and the plants /fill later broke
around it, changed from run to run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 14:28:40 +02:00
Ludovic MarieandClaude Opus 5.5 fc7a95ee10 Merge branch 'parity-base' into parity/rendering-be
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 14:08:08 +02:00
Ludovic Marie 42c2e96082 Merge remote-tracking branch 'origin/t3/achieve-full-parity' into parity/hud 2026-10-12 14:07:23 +02:00
Ludovic Marie fd79addba4 Merge branch 'parity/saves' into parity-base 2026-10-12 14:06:53 +02:00
Ludovic Marie 7f8bec70bc Merge branch 'parity/blocks' into parity-base 2026-10-12 14:06:53 +02:00
Ludovic MarieandClaude Opus 5.5 22bc86739b Port the villager brain from VillagerGoalPackages
Villagers ran an approximation built from goals. They now run vanilla's
Brain: expiring memories, the nine sensors with their randomly delayed
scan clocks, activities from the villager_activity timeline, and every
package's behaviours (core, work, play, meet, rest, idle, panic, pre-raid,
raid, hide), started and ticked in Brain.availableBehaviorsByPriority's
order with RunOne's shuffles, the level random for behaviour timing and
POI scans, and Villager.refreshBrain when the profession changes.

The mob AI probe prints the activities and memories as the oracle does,
and the harness's three villager scenarios now match: 34/34.
Sleeping villagers take LivingEntity.SLEEPING_DIMENSIONS instead of being
frozen in travel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:37:39 +02:00
Ludovic MarieandClaude Opus 5.5 b0527b4818 Check a shulker box's lid room before opening and tick animations only while the world runs
ShulkerBoxBlock.canOpen: a closed box opens only when the half-open lid
box is free of collisions. Bells gather and test entities by their real
bounding boxes and liveness. Client block-entity tickers run with
ClientLevel.tickBlockEntities, which Minecraft.tick skips while paused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 d48017ab70 Build the player's attack strength model with its tests
Player.attackStrengthTicker and itemSwapTicker: both advance each tick, a
different main-hand item resets them, and getAttackStrengthScale divides
by the ATTACK_SPEED delay. The game does not drive it yet; the attack
indicator still shows full strength until combat uses the cooldown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:29:20 +02:00
Ludovic MarieandClaude Opus 5.5 5b201965af Compare the chat with vanilla's ChatComponent
The chat cases add messages (with the system and error tags) at given Hud
ticks and draw ChatComponent in background mode: wrapping, the 200-tick
fade, scale, width, height, spacing and opacity options. Horizon's chat now
wraps like StringSplitter.splitLines and ComponentRenderUtils.wrapComponents
(the indent on wrapped lines, breaks at the last space, a dropped newline
or space), keeps the options as doubles and draws through a layout with
vanilla's fills and text offsets. A component list whose first part has
empty text now still passes its style to the rest. The harness runs 200
cases per kind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:28:05 +02:00
Ludovic MarieandClaude Opus 5.5 e2b3f7b4e9 Keep unsimulated entities' UUIDs across saves and save player equipment as vanilla
save_entities forgot the UUID and compound of entities Horizon keeps but
does not simulate, so the save on quit gave them new UUIDs. The player's
equipment is written as LivingEntity does: omitted when empty, where
PlayerEquipment.isEmpty also counts the selected hotbar item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:26:18 +02:00
Ludovic MarieandClaude Opus 5.5 ef0c6604a5 Compare camera overlays and the sleep overlay with vanilla's Hud
The overlays cases run Hud.extractCameraOverlays and extractSleepOverlay:
the vignette with its brightness and world border warning, the spyglass
scope and its scopeScale easing (read back and compared), equipment
camera overlays, powder snow, the nether portal sprite and the nausea
swirl. The game feeds its full-screen camera overlays from the same
layout (the vignette option and first person now count), draws the
spyglass with vanilla's unclamped easing and the sleep fade at vanilla's
opacity under the sidebar, titles and chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:19:38 +02:00
Ludovic MarieandClaude Opus 5.5 51d4598632 Draw End portals and gateways with vanilla's end portal shader
The entity shader gains rendertype_end_portal as layers 9 (15 portal
layers) and 10 (16 for gateways): end_sky and the rotating, scrolling
layers of end_portal.png projected from the screen, repeated inside their
atlas sprites, with vanilla's GameTime uniform. The world-space
approximation and its portal block scan are gone; the block-entity
renderer draws both blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:16:32 +02:00
Ludovic MarieandClaude Opus 5.5 cd116711c2 Seed the mob AI oracle's gate shuffles so villager runs are reproducible
Every GateBehavior (RunOne, and the gates of TradeWithVillager and
VillagerMakeLove) shuffles its behaviours with a ShufflingList random that
RandomSource.create() seeds from the clock, so two oracle runs of the same
villager scenario diverged after the first RunOne. The oracle now gives
each gate a LegacyRandomSource seeded with the mob's seed, and seeds the
gates of a brain rebuilt by Villager.refreshBrain before its next tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:16:13 +02:00
Ludovic MarieandClaude Opus 5.5 5b53d37e30 Compare item decorations with GuiGraphicsExtractor.itemDecorations
The decor cases run vanilla's itemDecorations on stacks with counts,
durability (Item.getBarWidth/getBarColor), unbreakable stacks and item
cooldowns ticked through ItemCooldowns. Horizon's item drawing now uses
the same layout: the bar rounds and colours like vanilla's and the count
sits at vanilla's offsets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:13:12 +02:00
Ludovic MarieandClaude Opus 5.5 6bc85d48c6 Tick chest lids and enchanting books as vanilla and drive the renderer with it
block_entity_anim now also ports ChestLidController (chests, trapped and
copper chests, ender chests: triggerEvent(1, openCount), tickLid,
getOpenness), ChestBlock.opennessCombiner and ChestRenderer's lid curve,
and EnchantingTableBlockEntity.bookAnimationTick with its render state
(nearest non-spectator player by feet position within 3 blocks, Mth.atan2,
the class's shared RANDOM). The renderer pairs a double chest half with its
neighbour only as DoubleBlockCombiner does (same block, other half, same
facing) and reads the book from BlockAnim instead of ticking its own copy.

The game keeps a BlockEntityAnims and runs the client tickers of every
loaded animated block entity each tick: chest lids, books, shulker boxes,
bells (resonance sound, entity-effect particles, raider glow), conduits
(nautilus particles) and dragon/piglin heads. Opening and closing chests,
ender chests and shulker boxes sends the opener-count block event (both
halves of a double chest); a ring's block event reaches the bell through a
new LevelEvent::BlockEntityEvent. The renderer now gets this state instead
of BlockAnim::default(), so shulker lids, bells, conduits and heads move.

block_entity_anim harness: 256/256 cases (new: 48 chest, 36 book).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:12:30 +02:00
Ludovic MarieandClaude Opus 5.5 f55b4281e5 Compare boss bars, the jump bar and the scoreboard sidebar with vanilla
The oracle fills a BossHealthOverlay with LerpingBossEvents, runs
JumpableVehicleBar on a jumpable mount and displayScoreboardSidebar on a
real Scoreboard with teams, display names and number formats. The game
collects the dragon, wither, raid and custom bars into one
BossHealthOverlay layout (the backgrounds and notches now draw in vanilla's
order) and draws the sidebar with the text background options and
String.CASE_INSENSITIVE_ORDER.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:10:33 +02:00
Ludovic MarieandClaude Opus 5.5 613702c0a7 Render End portals and End gateways as vanilla's block-entity renderers
TheEndPortalRenderer and TheEndGatewayRenderer: the position-only portal
cube in FaceInfo order (only the vertical faces for portals, the faces
Block.shouldRenderFace leaves for gateways) and the gateway beam through a
shared submitBeaconBeam. The beacon beam now goes through the same
function. The client does not track gateway ages and cooldowns yet, so
gateways in game render settled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:05:36 +02:00
Ludovic MarieandClaude Opus 5.5 fda4d65a02 Give the block-entity render oracle an item resolver and an End gateway level
Campfires and brushable blocks resolve their items (empty here) through the
context's ItemModelResolver, which was null. End gateways read the level for
their beam (max y, game time) and visible faces, so they get a stub level
with the case's neighbours, light, build height and game time. New cases
cover gateways spawning, cooling down and settled against neighbours, and
End portals beside the same neighbours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:05:36 +02:00
Ludovic MarieandClaude Opus 5.5 b7cfe5578c Compare effect icons, titles, the action bar and the selected item name with vanilla's Hud
The HUD harness now draws cases by kind. The oracle runs Hud.extractEffects,
extractTitle, extractOverlayMessage and extractSelectedItemName after
replaying setTitle/setTimes/setOverlayMessage and Hud.tick, and
hud_overlays lays out the same state: MobEffectInstance ordering through
Java's small-array TimSort, the title and action bar fades, the backdrop
from the text background options and the tool highlight timer. The game
draws its effect icons, titles and action bar through these layouts and
now shows the selected item name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:05:05 +02:00
Ludovic Marie 06abd528c5 Merge remote-tracking branch 'origin/t3/achieve-full-parity' into parity/rendering-anim 2026-10-12 12:43:27 +02:00
Ludovic Marie c36190dc4d Merge remote-tracking branch 'origin/t3/achieve-full-parity' into parity/rendering-hud 2026-10-12 12:43:16 +02:00
Ludovic Marie 0b86f5f4c2 Merge remote-tracking branch 'origin/t3/achieve-full-parity' into parity/entity-ai 2026-10-12 12:43:08 +02:00
Ludovic MarieandClaude Opus 5.5 b3b6194a91 Add the plans for after the rewrite to the README roadmap
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:49:42 +02:00
Ludovic MarieandClaude Opus 5.5 6fdd7f3bd7 Add the Horizon logo, banner and in-game screenshots to the README
An original logo (a sun setting behind a blocky horizon), a banner and a social
preview card, and six screenshots captured in the Rust client. The README opens
with badges, a short pitch and a gallery; a test keeps the parity badge in step
with the subsystem table. Packaged builds use the logo as their menu icon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:47:52 +02:00
Ludovic MarieandClaude Opus 5.5 312d5fc7d2 Hide the whole HUD and the hand with F1, as vanilla does
F1 only hid the chat. Hud.extractRenderState hides the camera overlays,
crosshair, hotbar, effects, bars, sidebar, action bar, titles and chat, and
GameRenderer.renderItemInHand skips the hand; the sleep overlay, debug screen
and open screens still draw.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:47:37 +02:00
Ludovic Marie fae208deb6 Merge pull request #39 from ludovic111/docs/professional
Docs: professional README and no personal details
2026-10-12 02:18:57 +02:00
Ludovic MarieandClaude Opus 5.5 83bcfb15af Docs: professional README and no personal details
Restructure the README around what Horizon is, its status, how to build it
from your own copy of the game, and its parity table. The Java performance
pack material and the research lab notes move to docs/java-packs.md and
docs/research-lab.md. The target platform is stated generically instead of
naming a machine, the stale list of missing mobs is dropped in favour of the
audited table, and the legal notice follows Mojang's usage wording.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:14:10 +02:00
Ludovic MarieandClaude Opus 5.5 6c90e42675 Install ALSA headers for the CI lint and test job
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:02:37 +02:00
Ludovic MarieandClaude Opus 5.5 37898e856b Build from the user's own copy of the game instead of downloading it
Horizon no longer fetches anything of Minecraft from Mojang's servers. The
client jar, version JSON, libraries and assets come from the user's official
launcher install, and the server jar is one they downloaded themselves
(--server-jar or HORIZON_SERVER_JAR). Every file is checked against checksums
pinned in research-lock.json, and the prepared .local/ layout is byte-identical
to the old downloads, so the harnesses and the native client are unchanged.

CI keeps the offline checks on GitHub's runners; the vanilla comparisons and
the mod build need the game, so they move to a manual job on a self-hosted
runner that holds the maintainer's own copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 02:00:18 +02:00
Ludovic MarieandClaude Opus 5.5 b036c0c90d Document the mob AI harness --trace option
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 20:32:11 +02:00
Ludovic MarieandClaude Opus 5.5 d572cd7262 Link only the vanilla data into new worktrees
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:38:16 +02:00
Ludovic MarieandClaude Opus 5.5 82dceec5e7 Keep the datafix sample worlds with the shared vanilla data, and generate them when missing
The old server jars and the 22 worlds made from them are inputs, but they
lived in each worktree's .local and were lost when its outputs were
cleared. They now sit beside the shared vanilla data, written atomically,
and the harness generates any that are missing (under a lock, since
several worktrees may run it at once).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:36:09 +02:00
Ludovic MarieandClaude Opus 5.5 c7a2fbb182 Match vanilla wither tick order, underwater checks and gravity
The wither's customServerAiStep (charging, side heads, block breaking,
healing) now runs inside serverAiStep, and aiStep draws the heads' smoke
particle offsets after travel as vanilla does; its steering uses the 0.6F
and Mth.atan2 float maths, and its level-event sounds (shoot, spawn, block
break) no longer draw from its random: MobEvent::LevelEventSound lets the
client pick their pitch. Entity.getGravity is zero while isNoGravity, which
the flying move control sets. Drowned swim with isUnderWater (the previous
tick's eye state, Entity.wasEyeInWater), and the eyes count as in water per
26.3's EntityFluidInteraction (between a water block's bottom and its camera
height) rather than the older 0.11 offset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:18:23 +02:00
Ludovic MarieandClaude Opus 5.5 a466af6adc Match vanilla projectile randoms, witch target goal and enderman goal names
Projectiles shot by mobs (arrows, splash potions, snowballs, llama spit,
wind charges, crossbow bolts, tridents) take their spread, and arrows their
base damage, from the new projectile's own random as vanilla does, leaving
the shooter's random alone. The witch's player target goal reports as
NearestAttackableWitchTargetGoal (NearestTarget gains a subclass label), and
the probe names the enderman goals as vanilla's classes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:02:46 +02:00
Ludovic MarieandClaude Opus 5.5 feb043fda6 Merge parity/blocks into parity/hud
Block loot keeps one random-sequence path: the blocks side's block_loot
with the generic loot evaluator, and /loot uses the shared loot_sequence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:02:13 +02:00
Ludovic MarieandClaude Opus 5.5 5ede186d40 Integrate several branches behind one gate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:56:43 +02:00
Ludovic MarieandClaude Opus 5.5 d2c91b513b Match vanilla drowned AI, skeleton weapon goals, trader look goals and monster idle time
Drowned register Zombie's goals and then Drowned.addBehaviourGoals (go to
water, trident and melee attacks with okTarget, go to beach, swim up,
RandomStrollGoal) with DrownedMoveControl, Drowned.travelInWater and
AmphibiousPathNavigation. Skeletons get their bow from finalizeSpawn and
pick the bow goal or the AbstractSkeleton$1 melee goal from the held item,
as reassessWeaponGoal does. Monster.aiStep adds 2 to noActionTime in bright
light (raiders always), which stops RandomStrollGoal; LookAtPlayerGoal
supports Mob.class targets and InteractGoal, used by the wandering trader
(with vanilla's AvoidEntityGoal order), illagers, ravagers and vexes.
Entities below the world take 4 damage a tick instead of vanishing, water
travel uses the 0.8F/0.6F/0.3F float constants, and MobWorld carries the
level's sea level.

The oracle refreshes the player's water state after placing it (it is never
ticked, so isInWater kept the previous scenario's value) and keeps its world
under the harness build directory, deleting it before halting instead of
leaving /tmp/horizon-mob-ai* behind. The probe reports KILLED removals.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:56:36 +02:00
Ludovic MarieandClaude Opus 5.5 e8f2ccd403 Fail the gate when the harness runner dies, and clear large harness outputs
tools/gate.py took a crashed tools/parity.py for a pass with no new
failures. The runner now records each completed run, and the gate fails
unless its own run finished. A missing or empty trace (the disk was full)
no longer crashes the runner.

After a harness passes, the runner deletes its large regenerated outputs
(64 MB or more), keeping the vanilla oracle outputs that reusing an oracle
run needs. Full suites left 2-4 GB files in every worktree and filled the
disk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:56:28 +02:00
Ludovic MarieandClaude Opus 5.5 d4144d33b4 Match vanilla zombie turtle-egg goal, entity pushing and raider goal order
Zombies (and husks, drowned, zombie villagers, zombified piglins) now
register ZombieAttackTurtleEggGoal first, as vanilla does, so their goal
selector draws the same random numbers. LivingEntity.pushEntities runs in
aiStep against the player and other mobs, with pushes to other mobs applied
at once as Entity.push does (replacing the post-tick push_apart). Spider
target goals check the light before their random interval, LookAtPlayerGoal
uses the non-combat targeting conditions, raiders register the
PatrollingMonster/Raider goals before their own, and the witch runs its
aiStep (heal cooldown, drinking, particle roll) before super.aiStep with
NearestHealableRaiderTargetGoal's nextBoolean.

The mob AI oracle's player is now a fully loaded, hurtable client: its
connection starts with a 60-tick load timer and 26.3 moved the hurt cooldown
to damageCooldownTime, both of which only ServerPlayer.tick counts down, so
whether mobs could hurt the player depended on the scenario order. The probe
reports the player's hurt sound, and an rng-trace feature records the call
sites of Horizon's mob random draws next to the oracle's trace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:24:38 +02:00
Ludovic MarieandClaude Opus 5.5 7d35a25906 Format and lint the command parity changes
Clippy and rustfmt clean, and the scoreboard test now expects vanilla's
removeObjective, which keeps holders tracked without scores.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:00:32 +02:00
Ludovic Marie 4eb2f53ffb Merge branch 'parity-base' into parity/saves
# Conflicts:
#	README.md
2026-10-11 17:51:57 +02:00
Ludovic MarieandClaude Opus 5.5 99057953e0 Mark dispensers as matching vanilla in the parity table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:51:49 +02:00
Ludovic Marie 2e775aff2f Merge parity-base into parity/hud 2026-10-11 17:51:39 +02:00
Ludovic Marie d0815052cd Merge branch 'parity-base' into parity/blocks 2026-10-11 17:51:37 +02:00
Ludovic Marie 168bc1d0a3 Merge branch 'parity/loot-spawn' into parity-base 2026-10-11 17:36:20 +02:00
Ludovic MarieandClaude Opus 5.5 2eb688cc6a Decrypt bytes already read when a connection enables encryption
Encrypted bytes that arrived in the same read as the last plain packet
stayed in the buffer undecrypted, and the connection then waited forever
for a frame it could not parse. The loopback test hit this under load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:36:20 +02:00
Ludovic MarieandClaude Opus 5.5 a919e44050 Run /locate biome and poi and /place feature, jigsaw and structure as vanilla
/locate biome samples the biome source like BiomeSource.findClosestBiome3d,
/locate poi scans loaded chunks for PoiTypes block states within 256 blocks,
/place feature places the configured feature with the level random,
/place jigsaw assembles pieces with JigsawPlacement.addPieces and places
them into the level, and unknown structures or pools report vanilla's
errors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:35:55 +02:00
Ludovic Marie 6ed726e935 Merge branch 'parity-base' into parity/loot-spawn 2026-10-11 17:26:12 +02:00
Ludovic MarieandClaude Opus 5.5 d46fa60400 Run /compute as vanilla's ComputeCommand
Context int and float providers (inline or registered) evaluate with
Java's arithmetic, exceptions and rounding, in the default, block or
entity loot context; their codecs report missing fields as
DataFixerUpper does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:24:18 +02:00
Ludovic Marie 0a1ce1314c Merge branch 'parity-base' into parity/saves 2026-10-11 17:12:11 +02:00
Ludovic MarieandClaude Opus 5.5 17b1bcf2ce Destroy blocks for /setblock and /fill destroy as Level.destroyBlock does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:11:05 +02:00
Ludovic MarieandClaude Opus 5.5 debc6e124c Award tick, inventory and recipe advancements and draw block drops as vanilla
PlayerTrigger.TICK fires every tick and InventoryChangeTrigger as the
inventory changes; /recipe counts recipe book displays and fires
RecipeUnlockedTrigger; block drops draw from their loot table's random
sequence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:02:09 +02:00
Ludovic MarieandClaude Opus 5.5 ae3f129c8e Format the dispenser parity work
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:54:06 +02:00
Ludovic MarieandClaude Opus 5.5 54a92f23f0 Give every block update oracle level a chunk generator
Bone meal and saplings reach for getChunkSource().getGenerator(); the
shared oracle level now answers it, as the dispenser oracle did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:52:18 +02:00
Ludovic MarieandClaude Opus 5.5 753b5654b1 Keep errors inside forked execute chains silent, as vanilla
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:51:07 +02:00
Ludovic MarieandClaude Opus 5.5 a47f1ec58b Dispense mobs with their jockeys as vanilla's spawn eggs do
A dispensed spawn egg's mob gets the riders finalizeSpawn adds (chicken
jockeys, spider jockeys, strider jockeys). The harness leaves which mob
comes out a baby and rides to the spawning port's finalization draws, as
it already does for the level random of spawning cases. Cases no longer
grow plants out of a dispenser or build a door from mismatched halves,
and the oracle runs every case three times so an unseeded value is
marked even when two runs happen to agree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:50:20 +02:00
Ludovic MarieandClaude Opus 5.5 00371a7f35 Run /test as vanilla does in a world without test instances
Model TestCommand's release tree (ResourceSelectorArgument patterns
included) and its finder, locate, pos, stop and clear outcomes when no
test instance block exists. Compare /help's gamerule alternatives as a
set: GameRuleCommand registers them in identity-hash order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:47:42 +02:00
Ludovic Marie 3ba0204bc0 Merge branch 'parity-base' into parity/blocks 2026-10-11 16:44:43 +02:00
Ludovic Marie 38059ab9f9 Merge branch 'parity-base' into parity/hud 2026-10-11 16:43:21 +02:00
Ludovic MarieandClaude Opus 5.5 d061bdf2fb Share harness passes between worktrees and cap harnesses machine-wide
A pass is keyed by the content of the harness's dependencies, so it is
also valid in any worktree with the same files. Passes are published to
parity-shared/ beside the shared vanilla data, and a worktree adopts one
whose key matches its own files instead of running the harness: an
agent's gate only runs what its own changes affect, instead of the whole
suite from scratch. Every runner on the machine also shares
PARITY_MACHINE_JOBS slots, so several agents' gates can't overload it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:42:52 +02:00
Ludovic MarieandClaude Opus 5.5 d7e4a5a275 List command usages in vanilla's registration order and run the release tree
Brigadier keeps children in registration order and /help prints them
that way; tools/generate_command_order.py records vanilla's order where
Horizon's tree differs. The game now runs the release integrated
server's tree; HORIZON_DEV_COMMANDS restores the development commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:41:43 +02:00
Ludovic MarieandClaude Opus 5.5 877165a6c9 Read region headers as vanilla's RegionFile does in the region tests
Invalid allocations are dropped rather than refused, overlaps are not
checked, and an empty or truncated file is a region without chunks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:41:30 +02:00
Ludovic MarieandClaude Opus 5.5 3a875c709e Refuse only worlds older than 1.18.2 when opening them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:39:26 +02:00
Ludovic MarieandClaude Opus 5.5 8490e19ec0 Resolve nbt text components, other dimensions and vanilla's own data as vanilla
/tellraw resolves nbt components; execute in another dimension sees its
chunks unloaded; the built-in pack's predicates load; holders stay
tracked after their objective is removed; new worlds use the offline
profile id of the player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:34:17 +02:00
Ludovic MarieandClaude Opus 5.5 6605e6cdf1 Run the dispenser fixture in the oracle's plains light and level
The fixture's chunks are plains (empty chunks read as badlands, which has
no bone meal flowers) and carry the case's sky and block light, as the
oracle level does, so grass bone meal places flowers and crops check
their light like vanilla's. The oracle's client sounds include the lava
fizz (1501). Cases build two-block plants whole: a lone lower half is not
a world vanilla makes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:32:28 +02:00
Ludovic MarieandClaude Opus 5.5 93b785a2ef Describe save upgrades from 1.18.2 in the status table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:28:53 +02:00
Ludovic MarieandClaude Opus 5.5 2faf5520b3 Match vanilla /spreadplayers, /datapack, /reload, function tags and dialog codecs
/spreadplayers follows SpreadPlayersCommand (safe positions, failure
distance, float centre); /datapack models the built-in, feature and
world packs with their chat links; unknown function tags are empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:22:06 +02:00
Ludovic MarieandClaude Opus 5.5 7994c1e8c5 Count natural spawning among the entity checks that match vanilla
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:19:49 +02:00
Ludovic MarieandClaude Opus 5.5 6949ca4455 Target rarer natural spawns in the parity harness
Desert, snowy, swamp and jungle nights, lush, sulfur and dripstone caves,
warm and frozen oceans, crimson forests and slime chunks. A cube mob grown
by finalizeSpawn takes its constructor's head rotation from the entity's
unseeded random (AbstractCubeMob.onSyncedDataUpdated), so that yaw alone is
not compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:14:22 +02:00
Ludovic MarieandClaude Opus 5.5 36209e7827 Place dispensed shulker boxes only where DirectionalPlaceContext can
A dispenser places a shulker box in the block in front or nowhere, and
only when that block's canBeReplaced accepts it (a snow layer only when
it is a single layer).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:10:30 +02:00
Ludovic MarieandClaude Opus 5.5 b2812c55be Choose natural spawns exactly as vanilla's NaturalSpawner does
tools/test_natural_spawn_parity.py compares whole chunk spawn passes,
spawnCategoryForPosition from chosen sites and chunk generation packs
against vanilla's NaturalSpawner on generated box worlds.

Divergences fixed on the way: the reduced water ambient tag is
reduce_water_ambient_spawns, and the room check uses each type's spawn
dimensions scale (4 for slimes and magma cubes, 2 for sulfur cubes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:10:12 +02:00
Ludovic MarieandClaude Opus 5.5 e1eabdc781 Pass the save upgrade harness from 1.18.2: block entity data stays empty
A block entity read inside an item stack remembers the map it was read from;
that memory is not part of the value, so an item's block entity tag with
only its id still counts as empty and is dropped, as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:07:10 +02:00
Ludovic MarieandClaude Opus 5.5 8eca39aff1 Equip, saddle and light from dispensers as vanilla does
Villagers, piglins, piglin brutes, allays, dolphins, foxes and pandas can
pick up loot from construction (their constructors call
setCanPickUpLoot), so they take dispensed equipment without spawn
finalization. Nautilus saddles play item.nautilus_saddle_equip. The
oracle level is the overworld, so fire in an obsidian frame lights a
portal there too, and it answers getEntity(id) and level-wide entity
queries, which leash cutting needs. Unseeded item motion is compared
within its full spread (item motion plus a shearing sheep's push).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:07:06 +02:00
Ludovic MarieandClaude Opus 5.5 b5d84b138d Match vanilla /clear predicates, /particle results, block entity NBT and /fillbiome checks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:06:17 +02:00
Ludovic MarieandClaude Opus 5.5 c80edcdbf4 Draw /loot from each table's random sequence, as vanilla
LootTable.getRandomItems uses the table's random_sequence, so /loot
results are deterministic per world seed; inline loot tables and tool
components now work too. The oracle ticks its player like the network
listener does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:59:56 +02:00
Ludovic MarieandClaude Opus 5.5 bcfa2f6974 Retry fresh only when a harness that passed fails while reusing oracle results
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:56:45 +02:00
Ludovic MarieandClaude Opus 5.5 0de0379cc7 Expect an int page in the click event fix test, as createInt writes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:53:55 +02:00
Ludovic MarieandClaude Opus 5.5 894113d7f4 Build golems and withers from dispensers as vanilla does
Golems and the wither come from EntityType.create (no spawn finalization,
no level random), after clearPatternBlocks sets every pattern cell to air
with flags 2 and a destroy event, and updatePatternBlocks follows. A
dispensed pumpkin that completes a golem skips setBlock's neighbour
updates as vanilla's does, and pumpkins and skulls post block_place.
Dispensed skulls face RotationSegment.convertToSegment(facing). Destroy
events for air play no break sound. The dispenser fixture plays on normal
difficulty like the oracle level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:53:37 +02:00
Ludovic MarieandClaude Opus 5.5 2ea1c273dd Run /item modify, /summon stacks and generic attributes as vanilla
Inline and registered item modifiers now apply (26.3's type-dispatched
functions, set_lore, set_custom_data, set_components, toggle_tooltips,
filtered), counting every set slot like CountingModifier. Item stacks
keep components Horizon does not model, display names follow
ItemStack.getDisplayName, /data prints compounds in CompoundTag's
HashMap order, summoned passengers snap to the position, and living
entities other than the player get vanilla attribute maps (waypoint
transmit range included, tracked as ServerWaypointManager does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:52:59 +02:00
Ludovic MarieandClaude Opus 5.5 a598255898 Add the natural spawn oracle and fixture
VanillaNaturalSpawnOracle runs NaturalSpawner (createState, the filtered
categories, spawnForChunk's loop, spawnCategoryForPosition and
spawnMobsForChunkGeneration) on a stand-in level built from boxes of blocks,
light and biomes with structure pieces, entities, players, the world spawn
and unloaded chunks; natural_spawn_fixture runs mobs::spawn_selection on the
same world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:52:12 +02:00
Ludovic MarieandClaude Opus 5.5 139c5abd1c Port the DataFixers of 1.19 to 1.20 for worlds from 1.18.2 on
The fixes from 3076 to 3459 (game event, cat, frog, painting, goat, goat
horn, filtered text, POI, blending, spawn egg, effect duration, archaeology,
double-sided signs, the 1.20 experiment and chunk statuses), the block
entities their schemas add, and block entities' remainders (a decorated
pot's shards stay next to its sherds). The sample world generator logs in
to 1.18.2 to 1.19.4 servers and gates the scene by version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:50:56 +02:00
Ludovic Marie 1b4c3dec6b Merge branch 'parity-base' into parity/blocks 2026-10-11 15:44:08 +02:00
Ludovic MarieandClaude Opus 5.5 3ab461cffc Upgrade older worlds when Horizon opens them, as 26.3 does
Opening a world saved by an older version runs the file fixes, level.dat and
the DataFixers upgrades of every file instead of refusing it. The save
round-trip harness treats a lit campfire's cooking progress as a timer in the
vanilla views: Horizon saves it as loaded (stage 3 checks it exactly), and it
moves on by however many ticks each vanilla server ran before data get.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:44:02 +02:00
Ludovic Marie 484bbb5ee8 Merge branch 'parity-base' into parity/loot-spawn 2026-10-11 15:43:57 +02:00
Ludovic MarieandClaude Opus 5.5 e019d361d9 WIP: natural spawn oracle and collision lookup (harness in progress)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:43:57 +02:00
Ludovic Marie ef6499fb0e Merge branch 'parity-base' into parity/saves 2026-10-11 15:43:49 +02:00
Ludovic Marie c2b7dad0e4 Merge branch 'parity-base' into parity/hud 2026-10-11 15:43:47 +02:00
Ludovic MarieandClaude Opus 5.5 13759ef16b Reuse unchanged vanilla oracle runs, and keep test games silent
tools/parity_shim.py stands in for java and javac in harness runs: it
records each oracle run's outputs and the content of every file under the
paths its arguments name, and replays the run while those are unchanged.
After a Rust-only change a harness only pays for its Rust side
(block_update 53 s -> 5 s). A harness that fails while reusing oracle
results is rerun fresh, and stops reusing them if that passes; --all and
--fresh never reuse them.

Harness probes built in the dev profile are optimised (overflow checks
and debug assertions kept), and the chunk generation harness runs its
vanilla and Horizon sides concurrently.

HORIZON_NO_AUDIO keeps the game off the speakers; tools/parity.py sets it,
since the save round-trip harness launches the game on a virtual screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:43:26 +02:00
Ludovic MarieandClaude Opus 5.5 c626f696f5 Set dispensed mobs down as EntityType.getYOffset does
Only the collision shapes overlapping the spawn block stop the fall, so a
wide mob beside the dispenser no longer lands a block high. Spawn egg
cases compare that the new mob speaks and where, not which of its voices
its own unseeded random picked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:41:00 +02:00
Ludovic MarieandClaude Opus 5.5 49ecc8a7c3 Keep the fields DataFixerUpper's remainders bring back when upgrading saves
DataFixerUpper reads typed fields without taking them out of a record's
remainder, so a typed field a fix drops comes back on write with its value
from the record's last read: entities keep their old ArmorItems, HandItems,
body_armor_item, saddle, Trident and Particle next to the new fields, as
vanilla 26.3 writes them. Fixes that write and read entities refresh that
map. The area effect cloud particle moves to custom_particle (4420), boats
keep Type (BoatSplitFix's remainder update misses), and region files with a
short header read as empty, as vanilla's RegionFile does. The sample worlds
now use the item formats each version accepts and enable its experiments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:40:30 +02:00
Ludovic MarieandClaude Opus 5.5 a21d268a41 Spawn naturally through a NaturalSpawner port shared by the game
mobs::spawn_selection ports NaturalSpawner over a NaturalLevel trait: the
spawn state (category counts, spawn potential charges, local caps), the
filtered categories, spawnForChunk, spawnCategoryForPosition with mobsAt,
isValidSpawnPostitionForType and isValidPositionForMob, and
spawnMobsForChunkGeneration. The game's spawning goes through it. Biomes
now load their spawn costs and creature_world_gen_spawn_probability.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:40:15 +02:00
Ludovic MarieandClaude Opus 5.5 f9fcb004a5 Dispense chest boats and command block minecarts as vanilla does
BoatDispenseItemBehavior spawns the chest boat or chest raft itself: boats
now carry ChestBoat's 27-slot chest, drawn with its model, saved with its
Items, opened by sneaking, spilt when broken. MinecartDispenseItemBehavior
spawns command block minecarts (an empty command). The dispenser harness
now draws fronts per item (fire, portal frames, anchors, crops, copper,
hives, golem and wither builds), every boat wood, more equipment, spawn
eggs, sulfur cube swallowables and leashed targets, over 6000 cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:38:13 +02:00
Ludovic MarieandClaude Opus 5.5 a5aaa53ab7 Light fires, portals, candles and anchors from dispensers as vanilla does
Flint and steel uses BaseFireBlock.canBePlacedAt and getState (a portal
frame draws the level random for vertical dispensers), posts block_place
and block_change, and lights only what CampfireBlock, CandleBlock and
CandleCakeBlock.canLight accept. Fire placed inside an empty obsidian
frame now lights the portal (BaseFireBlock.onPlace). Glowstone posts the
anchor's block_change. Shears cut leads (shearOffAllLeashConnections) and
empty a sulfur cube. Dropped leads draw motion from the item's own
random, not the level's. The oracle gives bone meal a chunk generator so
sapling, grass and moss cases are compared instead of erroring.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:31:35 +02:00
Ludovic MarieandClaude Opus 5.5 01f492c939 Select, tag and summon entities Horizon does not simulate, as vanilla
Markers, displays and other unsimulated entity types are now kept as
their saved data at a position, so selectors, /tag, /kill, /tp and
/data reach them. Widen the command execution corpus to every command
the integrated server registers, and keep the oracle's terrain tickets
out of /forceload's view.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:31:12 +02:00
Ludovic Marie 888ce5a3ff Merge branch 'parity-base' into parity/loot-spawn 2026-10-11 15:27:03 +02:00
Ludovic Marie c9a1e959ee Merge branch 'parity-base' into parity/saves 2026-10-11 15:26:57 +02:00
Ludovic Marie 89b712b5a8 Merge branch 'parity-base' into parity/hud 2026-10-11 15:26:52 +02:00
Ludovic Marie 51b6b0b65e Merge branch 'parity-base' into parity/blocks 2026-10-11 15:26:48 +02:00
Ludovic MarieandClaude Opus 5.5 abee1f165b Let the gate pass with harnesses that already fail on the integration branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:05:57 +02:00
Ludovic MarieandClaude Opus 5.5 6396e8c161 Run the vanilla harnesses in parallel, only when their inputs change
tools/parity.py traces each harness (its Python, Java oracle sources and
the Rust sources of the probes it runs, from Cargo's dep-info) and reruns
it only when one of those, the vanilla artifacts or the toolchains change.
Harnesses run in parallel within the available memory, and their probes
build without LTO and with 16 codegen units into .local/parity-target: a
one-line change rebuilds every horizon-world probe in 3 seconds instead of
122. The full suite takes about 20 minutes instead of an hour.

tools/gate.py is the check a branch passes before integration, and
tools/supervise.py manages the per-area worktrees: status, new worktrees,
saving unfinished work, and integrating a branch only when the gate passes.

Harnesses find probes through lab.cargo_target() instead of a hard-coded
target/, the save round-trip harness builds the binaries it launches, and
the mob AI harness now fails on divergences like every other harness
(--report-only keeps the old behaviour). Debug builds keep line tables
only, so worktrees stay small.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:05:36 +02:00
Ludovic MarieandClaude Opus 5.5 bf3de4ef05 WIP: saves parity work, paused while the harness tooling is sped up
Uncommitted changes the agent had in progress when it was stopped; may
not build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:26:49 +02:00
Ludovic MarieandClaude Opus 5.5 e3a1cdce51 WIP: hud parity work, paused while the harness tooling is sped up
Uncommitted changes the agent had in progress when it was stopped; may
not build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:26:47 +02:00
Ludovic MarieandClaude Opus 5.5 45cb5850a9 WIP: blocks parity work, paused while the harness tooling is sped up
Uncommitted changes the agent had in progress when it was stopped; may
not build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:26:46 +02:00
Ludovic Marie 504c4df027 Merge branch 'parity-base' into parity/loot-spawn
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-10-11 14:17:15 +02:00
Ludovic MarieandClaude Opus 5.5 45b8861a85 Accept vanilla's clockless time sync in the LAN bot test
MinecraftServer.forceGameTimeSynchronization broadcasts SetTime with no
clocks every 20 ticks, and it can reach a joining bot before
PlayerList.sendLevelInfo's full sync. Wait for a sync with clocks instead
of failing on the first packet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:16:34 +02:00
Ludovic Marie 6391ef0444 Merge branch 'parity-base' into parity/blocks
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-10-11 14:16:18 +02:00
Ludovic Marie e6e7de7ebe Merge branch 'parity-base' into parity/saves 2026-10-11 14:12:55 +02:00
Ludovic Marie 4becbd860b Merge branch 'parity-base' into parity/hud 2026-10-11 14:12:44 +02:00
Ludovic MarieandClaude Opus 5.5 6734879ff7 Pass rustfmt and clippy after merging the parity branches
Formats the overnight parity work, fixes the lints it introduced, and
updates the LAN bot test and mob AI probe for fields added on the other
side of the merges (the bot test also set a field that never existed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:11:51 +02:00
Ludovic Marie 6421e48c8f Merge commit 'a903cb4' into parity-base 2026-10-11 14:09:10 +02:00
Ludovic Marie 46e54c642f Merge commit 'e6ef8c5' into parity-base 2026-10-11 14:09:10 +02:00
Ludovic Marie 05bcc61446 Merge branch 'parity/multiplayer' into parity-base 2026-10-11 14:09:10 +02:00
Ludovic Marie c47a853e49 Merge branch 'parity/entity-ai' into parity-base 2026-10-11 14:09:10 +02:00
Ludovic Marie 3950c0ef7b Merge parity/assets into parity-base
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-11 14:09:10 +02:00
Ludovic MarieandClaude Opus 5.5 24a58c40d8 Query the shipped vanilla bytecode through REA
tools/rea_vanilla.py runs REA's JADX provider over the pinned client and
server JARs: class search, class members, method bodies and callers
(including lambdas). REA and the JADX engine are pinned in
research-lock.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:08:47 +02:00
Ludovic MarieandClaude Opus 5.5 37ae66c8c7 Track shulker, bell, conduit, skull and pot animation state as vanilla ticks it
block_entity_anim ports ShulkerBoxBlockEntity.updateAnimation and its
open-count events, BellBlockEntity triggerEvent/tick with resonation,
ConduitBlockEntity.clientTick (shape check, hunting, rotation, nautilus
particles), SkullBlockEntity.animation and DecoratedPotBlockEntity wobbles,
and maps the state to the renderer's BlockAnim.

tools/test_block_entity_anim_parity.py runs the real vanilla block entities
on a stand-in client level (VanillaBlockEntityAnimOracle) against
examples/block_entity_anim_fixture: 172/172 cases, 12720 ticked steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:25:32 +02:00
Ludovic MarieandClaude Opus 5.5 6e9f218a14 WIP: unfinished rendering-hud parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 0a8fd9a78f WIP: unfinished loot-spawn parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 f78de6d9ae WIP: unfinished blocks parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 68092c4c7f WIP: unfinished saves parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 0410a222ea WIP: unfinished hud parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 2012f8baad WIP: unfinished rendering-anim parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 fd23a8d682 Match vanilla atan2 tables, rotation constants, despawn checks and target search
Mth.atan2 builds its tables with StrictMath.asin (fdlibm, ported) and
HotSpot's cos; the controls multiply by the folded RAD_TO_DEG float
constant. Mob.checkDespawn runs before the tick and, in 26.3, persistent
mobs only reset their idle time near a player. NearestAttackableTargetGoal
always requires a line of sight and ignores mustReach when searching.
MeleeAttackGoal starts its canUse clock at game time 0. The harness now
records every random draw of the vanilla mobs with its caller, the random
state per tick, and starts each scenario at game time 1000.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:23:16 +02:00
Ludovic Marie 925e4d0f5f Merge remote-tracking branch 'desktop/parity-audio' into t3/achieve-full-parity 2026-10-10 22:22:41 +02:00
Ludovic Marie 1e0ad488bb Merge remote-tracking branch 'desktop/parity-input' into t3/achieve-full-parity 2026-10-10 22:21:34 +02:00
Ludovic MarieandClaude Opus 5.5 e6ef8c502b Check spawn placements and spawn predicates exactly as SpawnPlacements does
mobs::spawn_rules ports SpawnPlacements over a SpawnLevel trait the game's
MobWorld implements: the placement types with per-type isValidSpawn
(leaves, ice, magma for fire-immune types) and isValidEmptySpawnBlock with
each type's dangerous and immune blocks, and every registered predicate in
vanilla's draw order: the dimension's monster light test and block light
limit, thunder, peaceful, spawn reasons, guardians' sky from below water,
drowned, bats, ghasts, ocelots, slimes (surface chance and slime chunks),
strays under powder snow, striders, turtles, polar bears, tropical fish,
nautiluses, silverfish and endermites near players, patrols and the
spawnable-on tags.

Divergences fixed on the way: nether monsters ignored light, hoglins and
piglins had extra checks, slimes spawned outside slime chunks, ocelots and
ghasts skipped their odds, bats ignored the surface and their draw order,
and several predicates read the wrong blocks.

tools/test_spawn_rules_parity.py: 18,669 cases over 87 entity types match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:17:01 +02:00
Ludovic MarieandClaude Opus 5.5 635acffda9 Draw every item through the resolved item model pipeline
The client now resolves stacks with horizon-assets' ItemModelResolver into
an ItemStackRenderState equivalent (item_render): baked cuboid layers with
their display and local transforms, tints and foil, and the special model
renderers (chests, shulker boxes, banners, heads, shields, tridents,
decorated pots, conduits, bells, books, copper golem statues) posed from
the entity model layers. GUI icons are submitted under the GuiItemAtlas
pose with ITEMS_3D/ITEMS_FLAT lighting, back faces culled and clipped to
the slot; dropped items follow ItemEntityRenderer (bounding box hover,
spin and seeded cluster copies); item frames, first-person and mob,
player and armour stand hands, worn skulls and head items submit the
same state in the world.

The client atlas also runs the atlas sprite sources (directory, single,
filter, unstitch, paletted_permutations), so armour trim sprites exist.
The ad-hoc icon, held-banner, shield, potion-layer and glint paths are
gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:16:52 +02:00
Ludovic MarieandClaude Opus 5.5 f77a22177a Click recipe book buttons at forUI volume and test the mixer
Recipe book widgets play SimpleSoundInstance.forUI(UI_BUTTON_CLICK, 1.0F),
whose volume is 0.25. Unit tests cover the mixer distance gain and the
stopped/looping source states.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:13:40 +02:00
Ludovic MarieandClaude Opus 5.5 17ac24f4db Drive toggle sprint/sneak as vanilla's key handlers do and compare
ToggleKeys holds the sneak, sprint, attack and use ToggleKeyMappings with
the dispatch of KeyboardHandler.keyPress (releases always reach them;
presses and SDL key repeats only without a screen), MouseHandler.onButton,
Gui.setScreen (releaseAll / restoreToggleStatesOnScreenClosed) and
LocalPlayer.respawn (resetToggleKeys, which Horizon lacked). Presses behind
a screen no longer flip a toggled key. tools/test_toggle_keys_parity.py
drives the real Options mappings through the real static KeyMapping
functions (8,000 sequences).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:12:23 +02:00
Ludovic MarieandClaude Opus 5.5 1357e2f01b Choose situational music exactly as Minecraft.getSituationalMusic does
Situation captures the screen music, the menu without a player, End boss
music, creative abilities and underwater selection and the music volume;
the harness calls the real getSituationalMusic/getMusicVolume with a real
camera attribute probe for every dimension and biome.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:10:06 +02:00
Ludovic MarieandClaude Opus 5.5 538347007a Add auto-jump and compare it with LocalPlayer.updateAutoJump
Horizon had the Auto-Jump option but never jumped. MotionWorld::move_self
now runs LocalPlayer.updateAutoJump after the player's own move (behind
canAutoJump: option, autoJumpTime, ground, shift, input, block jump
factor), the next aiStep presses jump for one tick (input.makeJump) without
toggling creative flight, and the game feeds the option each tick.
tools/test_auto_jump_parity.py runs the real updateAutoJump on a LocalPlayer
shell in an independently built copy of the resident test scene with real
block states (10,568 cases, 1,200 jumps).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:04:04 +02:00
Ludovic MarieandClaude Opus 5.5 b93f72c0da Play bubble column inside sounds as BubbleColumnAmbientSoundHandler does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:03:51 +02:00
Ludovic Marie 8ef3195457 Merge remote-tracking branch 'desktop/parity-rendering' into t3/achieve-full-parity 2026-10-10 22:03:24 +02:00
Ludovic MarieandClaude Opus 5.5 d088140c0d Compare beacon beams with BeaconRenderer
Move the beam geometry into block_entity_render::beacon_beam so the
block entity render harness can replay it against vanilla submit calls
(positions, UVs and colours of the solid beam and glow per section).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:02:40 +02:00
Ludovic MarieandClaude Opus 5.5 9e4811dd6e Compare the ambient sound handlers tick by tick with vanilla LocalPlayer
The oracle runs the real updateIsUnderwater, UnderwaterAmbientSoundHandler
and BiomeAmbientSoundsHandler over the real SoundEngine, comparing every
level random draw, loop fade, mood sample and playing instance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:57:02 +02:00
Ludovic MarieandClaude Opus 5.5 723ec3bb8a Drop entity death loot exactly as vanilla's LivingEntity.die does
Entity loot tables now run on the faithful loot engine with an entity
context: the victim, the killer and direct attacker, the damage type and
its tags, the last player to hurt it and their luck, entity predicates
(flags, equipment, components, sheep, cube size, raider captains,
vehicles, entity type tags), damage source predicates, killed_by_player,
random_chance_with_enchanted_bonus, enchanted_count_increase, furnace_smelt
and tag entries. Draws come from the world's random sequence for the table.

mobs::drops ports dropAllDeathLoot: Monster loot for babies, equipment
drops in EquipmentSlot order with Looting's equipment_drops effect and
durability, the fox's mouth item, the wither's star, the charged creeper
head hook, horse inventories and chests, and getBaseExperienceReward with
the equipment bonus and baby zombie reward, split into orbs.

Fixes found on the way: sheep dropped their wool twice, baby monsters
dropped nothing, armour bonus experience and the equipment slot order were
wrong, hoglins were fire immune, and fishing treasure was unreachable (the
hook predicate format changed).

tools/test_entity_death_loot_parity.py: 1,578 cases over 87 entity types
match vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:56:39 +02:00
Ludovic MarieandClaude Opus 5.5 268babc257 Lay out the HUD decorations exactly as vanilla Hud does
Add horizon_client::hud_layout (crosshair and attack indicator, hotbar
with selection, off hand by main arm, item slots with the pop squeeze,
armour, hearts including frozen and absorbing kinds, food, air bubbles,
mount hearts capped at 30, experience bar and level) and the
test_hud_parity harness whose oracle runs vanilla Hud methods on a
stand-in client. The game now draws these through hud_layout; the
experience level is centred with integer division like vanilla,
spectators get no crosshair and mount hearts show in creative too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:56:23 +02:00
Ludovic MarieandClaude Opus 5.5 6eee2e3262 Compare audio environment attributes and Musics with the vanilla data
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:52:28 +02:00
Ludovic MarieandClaude Opus 5.5 ebb66e451f Pace frames with vanilla's FramerateLimiter and compare it
The game now ends each frame with FramerateLimiter.limitDisplayFPS below
260 fps (sleep, overshoot averaging, spin-wait) instead of waking the event
loop at a computed deadline, and the limiter honours Thread.interrupted.
tools/test_frame_limiter_parity.py runs the real limiter bytecode with its
clock calls rewritten to a scripted virtual clock (5,288 cases).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:51:04 +02:00
Ludovic MarieandClaude Opus 5.5 a903cb4dd5 Pass the save round-trip harness
Horizon spawned mobs whatever the spawn_mobs and spawn_monsters game rules
said; natural spawning now follows ServerChunkCache.tickChunks (the chunks
are still shuffled every tick) and the custom spawners tick only with
spawn_mobs. The slimes this put in the test world were what woke its sculk
sensor.

Falling blocks keep their UUID and loaded compound while they move, read
NoGravity, DropItem and a signed Time, and drop themselves as an item
after 600 ticks off the ground instead of landing. A crafter's saved
triggered flag only follows the block state when the state changes
(CrafterBlock.neighborChanged), and a horse saves the attribute instances
it was loaded with, as AttributeMap.pack does.

The harness compares vanilla log lines without their time stamps, accepts
extra entities only in chunks vanilla never generated, treats a falling
block's Time as a timer, and keeps the lit furnace's slots out of the
vanilla view. Its scene starts the falling block far below the 600-tick
limit and keeps the campfire from finishing during the run. The known-gap
list is empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:49:38 +02:00
Ludovic MarieandClaude Opus 5.5 13e434d49d Compare the music manager tick by tick with the real vanilla MusicManager
The oracle drives MusicManager over the real SoundEngine with the situation
as input; the script covers delays, replacements, frequencies, fades,
loading screens, pauses, silent starts, toasts and unplayable events.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:49:11 +02:00
Ludovic MarieandClaude Opus 5.5 b29f51f8e1 Add a differential mob AI harness and match vanilla mob movement math
VanillaMobAiOracle runs vanilla mobs in a real headless flat server level,
ticking them as ServerLevel does while the rest of the level stands
still; mob_ai_probe replays the same scenarios on Horizon. Both print each
mob's AI-visible state every tick (position, motion, rotations, goals,
targets, navigation, controls, input, brain activities and memories,
sounds) and tools/test_mob_ai_parity.py reports the first divergence.

Fixes found: float entity dimensions, Mth.sin/cos in moveRelative and the
strafe/swim controls, Mth.atan2 and the float thresholds of the controls,
RandomPos float constants, the ambient sound clock starting at zero, hurt
and death sounds drawn from the mob's own random (also when burning),
knockback for every damage type not tagged no_knockback, Entity.move's
collision flags, restitution and footstep/swim sounds, and mob sound
volumes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:46:57 +02:00
Ludovic MarieandClaude Opus 5.5 f85411f2fb Port the camera, field of view and view/hurt bobbing exactly
Camera.tick, tickFov, alignWithEntity with getMaxZoom over visual-shape
clipping, setRotation and move with JOML quaternion arithmetic,
calculateFov/calculateHudFov, getFieldOfViewModifier (now including
flight), ClientAvatarState walk/bob history (the backwards-extrapolated
walk distance and velocity-based bob) and GameRenderer.bobHurt/bobView as
the JOML pose matrix with property flags. The game renders through them,
adding hurt tilt, the camera distance and scale attributes, the mirrored
front camera and the sleeping camera. tools/test_camera_parity.py compares
18,502 cases against the real classes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:45:35 +02:00
Ludovic MarieandClaude Opus 5.5 ccdf49d3e5 Play every sound through the vanilla SoundEngine model
Audio now keeps vanilla SoundEngine bookkeeping over a mixer that stands in
for OpenAL sources (gain, pitch, linear distance attenuation, looping,
relative, source states, the 247/8 channel pools), ticks once per client
tick with pauseAllExcept/resume and stops everything when the world closes.
MusicManager holds a forMusic instance and checks isActive as vanilla does;
the ambient handlers play LoopSoundInstance, UnderLiquid* and mood instances,
the enter/exit sounds seeded from the level random; jukebox songs use
forJukeboxSong instead of a hand-made volume ramp.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:44:58 +02:00
Ludovic MarieandClaude Opus 5.5 3429de0020 Compare block entity rendering with vanilla renderers
Add a differential harness (test_block_entity_render_parity) whose Java
oracle runs each vanilla BlockEntityRenderer on a real block entity and
captures the submitted model vertices, textures, light and text calls.
Horizon now animates shulker lids, bell shakes, active conduits (cage,
wind and the camera-facing eye), skull jaws and ears, pot wobble and
sherd sides; signs wrap lines like StringSplitter and measure spaces
with the font; books use the Mth sine table and player heads the slim
default skin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:35:45 +02:00
Ludovic MarieandClaude Opus 5.5 d68efbabfc Compare the sound engine bookkeeping with the real vanilla SoundEngine
The oracle runs vanilla SoundManager/SoundEngine on OpenAL Soft null device
(inline executor, silent buffers) and replays scripts of plays, stops,
category gains, option volumes, channel ends, pauses, queued, delayed and
looping sounds and tickable ambient instances, printing every AL source
parameter, isActive, subtitle notification and pool count per step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:33:17 +02:00
Ludovic MarieandClaude Opus 5.5 f2232c3ed7 Rescore the Java parity table at 84%
World generation and interaction now have every covering harness passing and
score 1. The other rows keep 0.75, with their gaps rewritten to name what is
still missing now that the block, command, entity model and item model
harnesses pass; the next steps follow the open gaps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:22:38 +02:00
Ludovic MarieandClaude Opus 5.5 fc468a021d Resolve item models exactly as the vanilla client does
The item model harness ran out of memory, so it now streams both outputs and
looks Horizon's rows up by offset. With the vanilla side no longer crashing on
swap-animation lookups (properties now come from the baked set), on armour
(trim sprites are generated by the items atlas's paletted_permutations source
once SpriteSources is bootstrapped) or on compasses (item tags are bound from
the data pack), 197,000 more stacks were compared and 121,841 differed.

Horizon now composes transformations as JOML 1.10.9 does: rotationXYZ and
axis-angle quaternions with cosFromSin, and Matrix4f operations that take the
identity, translation, affine or generic path by matrix properties, with
unfused a * b + c. Float RGB tint constants are scaled by 255, the shulker box
renderer always encodes its texture, and a new palette module ports
PalettedPermutations and PaletteMapping so trim sprites exist on the Horizon
side too.

196,290 resolved stacks match; 2,582 cases are still rejected by vanilla's
stand-in client and not compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:21:50 +02:00
Ludovic MarieandClaude Opus 5.5 1d70ca47a8 Generate whole chunks block for block as vanilla does
The chunk generation harness found 70 differing chunks across three seeds in
the overworld, Nether and End. Proto chunks, biomes and decoration now span
the dimension type's build range rather than the noise height, so features
above y=128 in the Nether and End city upper floors survive; the decoration
level reports no sky light, as unlit vanilla chunks do, so mushrooms and crops
pass or fail their light checks the same way; beehive decorators store their
bees; iceberg ellipses skip NaN distances; double slabs never waterlog;
mineshaft spawners save an empty SpawnPotentials list; and the dump example
forgets structure starts between rectangles, as the oracle starts each with
fresh chunks.

The oracle prints absent block entity keys as vanilla loads them from an empty
tag, and the harness treats vanilla's save-format equivalences (plain sign
text, absent empty banner patterns, unfiltered sign messages) as equal. All 2,352
chunks over 225 structure sites match with zero block, biome or block entity
differences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:20:51 +02:00
Ludovic MarieandClaude Opus 5.5 ad4d836f6f Resolve sound events exactly as vanilla SoundManager does
Add SoundRegistry (sounds.json with replace, subtitles, file validation and
event references weighing as much as their event) and a SoundEngine model,
with a differential harness resolving every sounds.json event against the
real SoundManager.prepare and SimpleSoundInstance.getOrResolve.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:16:28 +02:00
453 changed files with 95237 additions and 12899 deletions

No files matched your search

+22 -15
View File
@@ -8,12 +8,7 @@ permissions:
contents: read
jobs:
verify:
strategy:
fail-fast: false
matrix:
# Linux only. macOS only when started by hand: gh workflow run checks --ref <branch>.
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["ubuntu-latest", "macos-26"]' || '["ubuntu-latest"]') }}
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
@@ -27,14 +22,30 @@ jobs:
python tools/check_public_tree.py --history
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- name: Install Rust toolchain and the ALSA headers the client's audio links against
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- name: Check formatting and lint
run: |
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets -- -D warnings
- name: Test Rust core and voxel kernel correctness
run: cargo +stable test --workspace
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
# HORIZON_SERVER_JAR. Start them by hand: gh workflow run checks --ref <branch>.
vanilla-parity:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- uses: actions/setup-java@v4
with:
distribution: temurin
@@ -374,12 +385,8 @@ jobs:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_float_log_parity.py
portable-mod:
strategy:
fail-fast: false
matrix:
# Linux only. macOS only when started by hand: gh workflow run checks --ref <branch>.
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["ubuntu-latest", "macos-26"]' || '["ubuntu-latest"]') }}
runs-on: ${{ matrix.os }}
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -466,7 +473,7 @@ jobs:
- name: Upload original mod and pack manifests only
uses: actions/upload-artifact@v4
with:
name: horizon-${{ matrix.os }}
name: horizon-linux
path: |
dist/*.mrpack
dist/horizon-*.jar
+27
View File
@@ -0,0 +1,27 @@
name: release builds
# Started by hand: gh workflow run release.yml --ref <branch>
# Builds the Windows x64 client and uploads the playable folder as an artifact.
# Linux and macOS builds are made on the development machines.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- name: Build the client
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
env:
RUSTFLAGS: -C target-feature=+crt-static
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
- name: Package
shell: bash
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
- uses: actions/upload-artifact@v4
with:
name: horizon-windows-x64
path: dist/horizon-windows-x64
+5
View File
@@ -15,6 +15,11 @@ type_complexity = "allow"
needless_range_loop = "allow"
large_enum_variant = "allow"
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
[profile.dev]
debug = "line-tables-only"
[profile.release]
lto = "thin"
codegen-units = 1
+164 -301
View File
@@ -1,336 +1,199 @@
<p align="center">
<img src="docs/media/banner.jpg" alt="Horizon: Minecraft Java Edition 26.3, rewritten from scratch in Rust" width="100%">
</p>
<p align="center">
<a href="https://github.com/ludovic111/horizon/actions/workflows/ci.yml"><img src="https://github.com/ludovic111/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-89%25-F0795A" alt="Vanilla parity 89%">
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
</p>
<p align="center">
<a href="#getting-it"><b>Get started</b></a> ·
<a href="#screenshots"><b>Screenshots</b></a> ·
<a href="#project-status"><b>Parity status</b></a> ·
<a href="docs/native-client.md"><b>Native client guide</b></a>
</p>
# Horizon
Horizon is a Rust rewrite of Minecraft Java **26.3** targeting one-to-one vanilla gameplay parity, alongside client performance research. Not affiliated with Mojang or Microsoft.
A Rust rewrite of Minecraft: Java Edition 26.3 that aims for one-to-one vanilla parity, checked against the game itself with differential tests.
**Status:** in development. The native client is playable on Linux, macOS (Apple Silicon) and Windows; Java parity is 89% (see [Project status](#project-status)).
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
## Screenshots
Captured in the Rust client itself, with vanilla assets and no mods or shaders.
<table>
<tr>
<td colspan="2"><img src="docs/media/sunset.jpg" alt="Sunset over a savanna river valley and a plains village"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/village.jpg" alt="A plains village with farms, an iron golem and animals"></td>
<td width="50%"><img src="docs/media/survival.jpg" alt="Survival at dusk: a zombie attacks near the village"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/nether.jpg" alt="A crimson forest in the Nether with a zombified piglin"></td>
<td width="50%"><img src="docs/media/night.jpg" alt="Moonrise over a torch-lit village and river"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/underwater.jpg" alt="Salmon swimming in a river"></td>
<td width="50%" valign="middle">
Vanilla world generation ported to Rust (terrain, aquifers, carvers, features, villages and the Nether), every block model with smooth lighting and biome tints, the vanilla sky and fog, mobs, the HUD, chat and advancements: everything in these frames is drawn by Horizon.
</td>
</tr>
</table>
## What it does
Horizon reimplements the Minecraft Java client and its game logic in Rust, one subsystem at a time, and compares each piece with the vanilla game. Each `tools/test_*_parity.py` harness drives the same inputs through Horizon and through the vanilla client and server bytecode from your own copy of the game, then checks that the outputs are identical. It is for players and developers interested in game reimplementation, and for anyone who wants to see how far a behaviour-exact native rewrite can go.
The [native client](docs/native-client.md) opens on a vanilla-style title screen with world creation and selection, and generates worlds with the Rust port of the vanilla generators: noise terrain, aquifers, surface rules, carvers and placed features, all checked against vanilla. It streams and lights chunks on worker threads and renders every vanilla block model with smooth lighting, biome tints, the vanilla sky, sun, moon, stars and clouds. Gameplay includes:
**The Rust game is now playable.** The [native client](docs/native-client.md) opens on a vanilla-style title screen with world creation and selection, and generates worlds with the Rust port of the vanilla overworld generator: noise terrain, aquifers, surface rules, carvers, and the placed features ported so far, all checked against vanilla. It streams and lights chunks on worker threads and renders every vanilla block model with smooth lighting, biome tints, the vanilla sky, sun, moon, stars and clouds. Gameplay so far:
- validated player physics, health and hunger;
- mining with vanilla tool rules and loot tables;
- placing, crafting, chests and furnaces;
- dropped items and particles;
- sounds;
- passive and hostile mobs with combat, including guardians, shulkers, illagers, ravagers, the wither and the ender dragon fight;
- villagers that take jobs, trade, restock, meet at the bell and sleep in beds;
- dropped items, particles and sounds;
- passive and hostile mobs with combat and vanilla AI, including guardians, shulkers, illagers, ravagers, the wither, the ender dragon fight, the breeze, the creaking, the sniffer, the happy ghast, llamas and the copper golem;
- villagers that take jobs, trade, restock, meet at the bell and sleep in beds, on a port of vanilla's brain;
- raids and pillager patrols;
- dispensers, crafters, the loom and the recipe book;
- every command, scoreboards and teams, the player list, subtitles and the HUD overlays;
- boats, minecarts, item frames, paintings and armor stands;
- shields, maps, books, fireworks, beacons, advancements and statistics;
- Anvil saves.
- Anvil saves, joining vanilla servers, and opening a world to LAN.
Parity is **not** complete. Still missing: the warden and the sculk family, the breeze, sniffer, creaking, happy ghast, llamas and a few other mobs, raids and pillager patrols, villager breeding and gossip, the loom, crafter and recipe book, scoreboards, subtitles, and spawned mob gear enchantments. Multiplayer joins vanilla servers but Horizon cannot host one. See [Rust rewrite status](docs/rust-parity.md).
## Getting it
### Java parity: 80%
Builds for **Linux x86-64**, **macOS Apple Silicon** and **Windows x64** are attached to the [releases](https://github.com/ludovic111/horizon/releases). Each folder holds the game, the resource preparation tool and a `PLAY.txt`; the first start prepares the resources from your own copy of the game, as below. You can also build from source.
**Platform.** A GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Development and the vanilla harnesses run on Linux.
**Requirements.** Python 3.10 or newer and JDK 25 (for resource preparation). To build from source: Rust 1.90 or newer, and on Linux the ALSA development headers (`libasound2-dev` on Debian and Ubuntu).
### Bring your own copy
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
2. **Server jar:** download the 26.3 server jar yourself, from [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release, or otherwise from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json`. Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
### Build and run
```sh
git clone https://github.com/ludovic111/horizon.git
cd horizon
python3 tools/prepare_native_client.py --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
python3 tools/fetch_sounds.py # optional: sounds from your launcher install
cargo run --release -p horizon-client
```
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
## Usage
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`), controls, scripting through `horizon-ctl`, and packaging for another of your own Linux machines are described in [the native client guide](docs/native-client.md).
## Documentation
- [Native client](docs/native-client.md): running, options, controls, scripting and architecture.
- [Rust rewrite status](docs/rust-parity.md): completion criteria and per-area scope.
- Parity notes: [block behaviours and ticking](docs/parity/blocks.md), [section meshing](docs/parity/meshing.md).
- [Decompilation and Rust research lab](docs/research-lab.md): the local game inspection workspace and the first research crates.
- [Decompilation status](docs/decompilation-status.md).
- [Java performance packs](docs/java-packs.md): the earlier, no longer developed Java track.
## Project status
Parity is **not** complete. The table below is the project's own audit of each subsystem. Still missing: the warden's brain and the sculk shrieker summoning it, villager breeding and gossip, spawned mob gear enchantments, the resource pack screen, a dedicated server mode, and worlds older than 1.17.
### Java parity: 89%
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
and verified against vanilla by differential tests). The percentage is the
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
2026-10-10 against the code, not the docs: of the 232 `tools/test_*_parity.py`
differential harnesses, 223 pass, 8 fail and the item model harness runs out of
memory; the failures are new harnesses that still find divergences.
2026-10-12 against the harness results: 270 harnesses pass. Two still differ
from vanilla in places: `mob_ai` (71 of 72 scenarios; a tempted ghastling) and
`sculk` (427 of 435; shrieker warnings and warden summoning).
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
| Window, input and game loop | 0.75 | Game loop and options match vanilla; camera, auto-jump, toggle sprint/sneak and the frame limiter not compared |
| Assets and resources | 0.75 | Item model pipeline not yet used for all GUI icons; item model harness does not finish; no pack screen |
| Network protocol and multiplayer | 0.75 | Opens worlds to LAN, but hosting is not tested against a vanilla client; no dedicated server mode |
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
| Assets and resources | 0.75 | Item models match vanilla over 196,290 stacks and the client draws every item through them; no resource pack screen |
| Network protocol and multiplayer | 0.75 | Joins vanilla servers and opens worlds to LAN; server.properties, eula.txt and the user lists match vanilla, but there is no dedicated server mode and LAN hosting is not tested against a vanilla client |
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
| Rendering | 0.75 | Entity model harness fails; block entity renderers and HUD overlays not compared |
| Rendering | 1 | None: entity models, block entity renderers (407 cases, End portals, gateways and the loom's banner preview included) and their animations (296 cases, End gateway beams included), the HUD overlays (2,200 states), the recipe book (600 states), the player list, subtitles and the world border match vanilla |
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
| World generation | 0.75 | Whole-chunk harness still finds single-block differences near camps, shipwrecks and villages |
| Region files and saves | 0.75 | Save round-trip harness fails; older DataVersions are not upgraded |
| Entities and physics | 0.75 | Attributes and dimensions match; AI, loot and spawn rules not compared; drowned, villagers, traders and the wither unverified |
| Interaction and inventory | 0.75 | Menu click harness fails |
| Menus, HUD and chat | 0.75 | Command parse harness fails; no /item modify, /spectate, /waypoint or /dialog; no tab list or world border rendering |
| Audio | 0.75 | Sound engine, music and ambience not compared with vanilla |
| Block behaviours and ticking | 0.75 | Block update, random tick and redstone harnesses fail; a few dispenser behaviours missing |
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
| Region files and saves | 0.75 | Saves round-trip with vanilla, and worlds from 1.17 to 26.2 upgrade as vanilla's DataFixers do; worlds older than 1.17 are not upgraded |
| Entities and physics | 0.75 | Attributes, dimensions, death loot, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900 cases) and mob AI (71 of 72 scenarios, villager brains included) match; the warden's brain, villager breeding and gossip, and spawned mob gear are missing |
| Interaction and inventory | 1 | None: menu clicks match over 8,000 click sequences, the loom and the crafter included; recipe book placement (6,000 cases), its buttons (1,200), the saved book (1,500) and the loom and crafter screens (1,600) match vanilla |
| Menus, HUD and chat | 0.75 | Every command runs as vanilla (758 commands over 39,685 parsed inputs), scoreboards and teams match (81,000 operations), and the player list matches over 844,758 draw calls; the focused chat is not compared and player list heads use default skins |
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
| Block behaviours and ticking | 0.75 | Block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts) and block entity removal (containers spill, 4,000 cases) match vanilla; 8 of 435 sculk scenarios differ (shrieker warnings and warden summoning) and the copper golem's chest sorting is not compared |
### Next steps
### What's new since the previous version
1. Fix the divergences the failing harnesses report: block updates, random ticks, redstone, command parsing, menu clicks, save round-trip, whole chunks and entity models.
2. Let the item model harness finish, and draw every GUI icon through the item model pipeline.
3. An audio harness: sound engine attenuation, weighted sound events, music and ambience.
4. Mob AI, loot and spawn-rule harnesses, and an oracle for drowned, villagers, traders and the wither.
5. The missing commands, the tab list and world border rendering.
6. Test LAN hosting with a vanilla client, and upgrade saves from older DataVersions.
- **Mob AI matches vanilla**, compared tick by tick in 72 scenarios, with villagers on a port of vanilla's brain (memories, sensors, schedules and behaviours).
- **New mobs:** the breeze and wind charges, the creaking and the creaking heart, the sniffer, the happy ghast and ghastlings, llamas and trader llamas, and the copper golem; the warden's model, animations and emissive layers.
- **Raids and pillager patrols**, with the Raid Omen, the waves and the Hero of the Village effect.
- **Dispensers, crafters, the loom and the recipe book**, and containers spill their items when their block is replaced.
- **Every command runs as vanilla**, including /item modify, /spectate, /waypoint, /dialog and /place.
- **Scoreboards and teams** in gameplay (criteria, friendly fire, collision, name tags), the **player list**, **subtitles**, every **HUD overlay**, **block entity animations** (chests, shulker boxes, bells, conduits, heads, the enchanting table's book) and **End portals and gateways** drawn with vanilla's shader.
- **Worlds from 1.17 onwards** open and are upgraded as vanilla does; server.properties, eula.txt and the server user lists are read and written as vanilla does.
- **Builds for Linux, macOS (Apple Silicon) and Windows.**
### Known issues
- **Render distance 2 never finishes loading:** a world opened with the render distance at 2 chunks stays on "Loading terrain…". Use a render distance of 3 or more (in Options > Video, or `--render-distance 12` on the command line).
### Roadmap
1. Fix loading at render distance 2; finish the warden's brain and the sculk shrieker summoning it, and the tempted ghastling.
2. Villager breeding and gossip, and spawned mob gear enchantments (both started on their branches).
3. A dedicated server mode, and LAN hosting tested with a vanilla client.
4. Save upgrades from before 1.17, and the resource pack screen.
5. Platform-independent float math, so every harness also gives vanilla's results on macOS.
The rewrite is complete when it reaches one-to-one parity and packaged native builds pass the launch checks in [Rust rewrite status](docs/rust-parity.md#completion-criteria-and-playable-builds).
### After the rewrite
Once Horizon matches vanilla, the plan is to build on that exact base:
- **Performance:** optimize the client and the server as far as they can go (rendering, chunk generation and meshing, memory, multithreaded ticking) while the differential harnesses keep gameplay identical to vanilla.
- **Modding support:** a stable API for writing mods against Horizon.
- **Agent control:** an MCP server and a CLI so AI agents and scripts can play and drive the game, growing out of today's `horizon-ctl` scripting socket.
- **Built-in recording:** capture gameplay video in a keypress, without external tools.
- **Automatic version updates:** a pipeline that ports each new Minecraft release on its own: compare the new version with the pinned one, regenerate the data, rewrite the changed parts and prove them with the same differential harnesses.
## Contributing
Issues and pull requests are welcome. Before opening a pull request, run the offline checks:
```sh
python3 tools/prepare_native_client.py --prepare # needs JDK 25; extracts private vanilla resources
python3 tools/fetch_sounds.py # optional: vanilla sounds
cargo run --release -p horizon-client
```
**Target machine:** the Rust rewrite targets one machine, **ludodesktop**: Ubuntu 26.04, Intel i7-13700KF, NVIDIA RTX 4080, GNOME on Wayland. The final deliverable is a playable native Linux build validated there. macOS (including the M2 Pro Mac) and Windows are out of scope. Track implementation and acceptance criteria in [Rust rewrite status](docs/rust-parity.md).
**Java performance packs (no longer developed):** the earlier track of pinned Java performance packs, distant terrain, an original frame/tick recorder and client/server starter downloads. No release downloads are published; build the packs and starters locally with `python3 tools/build_release.py` (see [Build and run](#build-and-run)). They are no longer a development target.
## Java packs: Mac starter (out of scope)
Build **horizon-mac-starter-0.21.0.zip** with `python3 tools/build_release.py` (it lands in `dist/`), extract it and open `Mac-Start.command`. It verifies and installs official universal Prism Launcher in `~/Applications` if needed, then opens the balanced pack import. Sign into Microsoft **inside Prism yourself**. This project needs no credentials.
Use native **Java 25 ARM64**, maximum heap **6144 MiB**, minimum **512 MiB** on your 16 GB M2 Pro. Prism can download Java automatically. Start at 1280×800 or 1920×1200; full Retina resolution is a larger GPU workload. [START-HERE.txt](packs/START-HERE.txt) has details. The same `.mrpack` files import into Prism on Windows and Linux.
If Prism is already installed, follow the [Mac import and first comparison guide](docs/mac-first-test.md).
Core and balanced also pass [macOS title/demo-world rendering probes](docs/client-graphics-smoke.md)
on the hosted ARM64 software renderer, including readback, saves and clean exits.
Physical M2 GPU performance and macOS 27 remain to be tested.
| Profile | Full-detail chunks | Distant LOD radius | Suggested maximum heap |
| --- | ---: | ---: | ---: |
| baseline | 12 | none | 6144 MiB |
| core-performance | 12 | none | 6144 MiB |
| m2-balanced | 12 | 128 | 6144 MiB |
| adaptive (experiment) | 12 | 128 | 6144 MiB |
| worker-pacing (experiment) | 12 | 128 | 6144 MiB |
| adaptive-pacing (experiment) | 12 | 128 | 6144 MiB |
| queue-wait (experimental) | 12 | 128 | 6144 MiB |
| adaptive-queue (experimental) | 12 | 128 | 6144 MiB |
| dispatch-retry (experimental) | 12 | 128 | 6144 MiB |
| tick-dedup (experimental) | 12 | 128 | 6144 MiB |
| culling-empty (experimental) | 12 | 128 | 6144 MiB |
| quad-reuse (experimental) | 12 | 128 | 6144 MiB |
| terrain-position (experimental) | 12 | 128 | 6144 MiB |
| culling-rays (experimental) | 12 | 128 | 6144 MiB |
| foreground-first (combined experiment) | 12 | 128 | 6144 MiB |
| foreground-vulkan (combined experiment) | 12 | 128 | 6144 MiB |
| low-memory | 8 | none | 3072 MiB |
| far-distance | 12 | 256 | 8192 MiB, with sufficient physical RAM |
| cinematic | 12 | 128 | 6144 MiB |
| vulkan | 12 | 128 | 6144 MiB |
Balanced combines [Sodium](https://modrinth.com/mod/sodium), [Lithium](https://modrinth.com/mod/lithium), [FerriteCore](https://modrinth.com/mod/ferrite-core), [ImmediatelyFast](https://modrinth.com/mod/immediatelyfast), [EntityCulling](https://modrinth.com/mod/entityculling), and [Distant Horizons](https://modrinth.com/mod/distanthorizons). Cinematic adds [Iris](https://modrinth.com/mod/iris) and [Complementary Reimagined](https://modrinth.com/shader/complementary-reimagined), initially disabled. Vulkan independently experiments with the game's own backend and fallback, without Iris.
Distant LOD is simplified terrain. Nearby blocks retain full detail. Initial generation costs CPU/disk/memory and needs warm-up. DH adjusts clouds, chunk fade and transparency to blend its renderer; assess appearance as well as speed. Shaders cost GPU time and have [Mac-specific limits](https://shaders.properties/current/reference/miscellaneous/macos/). Universal CPU/GPU support and exact vanilla equivalence remain validation goals. Simulation distance stays 12 in every profile; normal 20 TPS rules are retained.
For the first comparison at the same view distance, use `baseline` versus
`core-performance`: both select 12 full-detail chunks and no distant LOD or shaders.
The latter adds only the pinned core performance mods and leaves every original
controller disabled. Then test balanced separately for its additional distant
terrain. Matching captured settings does not prove pixel-identical rendering.
[Core comparison protocol and selected observations](docs/core-performance.md)
include three repeated matched-view software-rendered pairs with 14–21% higher
FPS in this workload; M2 performance remains unmeasured.
[Heap budget experiment](docs/heap-budget.md): three pairs with 3 GiB versus
1.5 GiB maximum heap show variable RAM savings and GC/frame-time tradeoffs;
playable defaults remain unchanged.
[Matched graphics API comparison](docs/graphics-api-comparison.md): Vulkan adds
about 1.5% average FPS but about 12% CPU in three Linux software-rendered pairs;
backend defaults remain unchanged and physical Mac performance is unmeasured.
The optional `queue-wait` pack yields during pending distant-terrain debounce retries; `adaptive-queue` combines it with the existing controllers. [Queue experiment and measurement limits](docs/debounce-queue.md).
Version 0.16 adds a separate [terrain position arithmetic experiment](docs/terrain-position.md)
for the sampled full-data lookup. It retains the pinned library’s unusual
negative-coordinate rounding and invalid-level errors. Balanced keeps it off;
whole-game and Mac performance still require measurement.
Version 0.15 adds an isolated [culling ray scratch experiment](docs/culling-rays.md)
that reuses two numeric vectors within each culling instance while retaining
upstream arithmetic and decisions. Balanced keeps it off; hardware gains remain
unmeasured.
Version 0.14 adds an isolated [empty culling whitelist experiment](docs/culling-empty.md)
that avoids allocating iterators while there are no dynamic handlers. Live
registration and nonempty callbacks retain upstream behavior. Balanced keeps it
off; whole-game gains still need hardware comparisons. DH profiles also request
a scoped [interruptible blending-read wait](docs/terrain-read-wait.md) to let
background workers leave a pending metadata read during shutdown.
Version 0.13 adds an isolated [tick membership experiment](docs/tick-membership.md)
that reuses compact storage for DH's private per-tick membership checks. The
combined foreground profiles include it too; balanced keeps it off. The applied
holder test verifies action semantics and removes repeated entry allocations;
whole-game FPS and RAM gains still need hardware measurements.
Version 0.12 captures effective distant-terrain quality, radius, worker budgets,
Iris state and field of view. Start/end snapshots flag changed visual settings;
[measurement details](docs/terrain-measurements.md) explain their limits.
Version 0.11 adds combined foreground-first OpenGL/Vulkan profiles with a 120 FPS
adaptive goal, plus explicit collector experiments and GC measurement guidance.
See [combined controls](docs/foreground-first.md), [Vulkan on your Mac](docs/vulkan.md)
and [collector comparisons](docs/collectors.md). The frame goal is not a rendering cap.
Version 0.10 adds an isolated [rejected-dispatch retry experiment](docs/dispatch-retry.md).
It bounds retry passes when a DH worker rejects queued work, preserving original
requeue handling and allowing other executors to progress. Balanced keeps it off.
Version 0.9 also makes the queue's idle wait wakeable for incoming work, preserving
its debounce rules and bounded cache-maintenance cadence. It remains part of the
optional queue profiles, with separate wake-latency measurements.
Version 0.8 fixes a fresh-instance graphics preset that could override the selected
render distance. It also records actual GC/header settings and supports an optional
[compact object headers experiment](docs/compact-headers.md) for Java 25; packs
do not enable that JVM flag automatically.
## Measure on your hardware
The optional [adaptive terrain experiment](docs/adaptive-terrain.md) throttles distant
terrain background work when frames slow down or heap pressure rises, keeping the
balanced profile's view distances and simulation settings. It remains opt-in until
real hardware demonstrates a benefit.
The additional [precise worker pacing experiment](docs/worker-pacing.md) makes short
terrain tasks respect their duty-cycle budget and avoids boxed worker timestamps.
It changes generation speed and CPU use, so it stays separate from balanced.
Press **F8** during gameplay to start a 120-second recording, or again to stop early. Rebind in Controls if needed. This also works with restricted chat accounts. Alternatively: `/horizon bench <label> 120`, `/horizon stop`, `/horizon status`.
Use separate identical world copies and the same camera route, resolution, render/simulation distance, heap, VSync and FPS limit. Warm up, then alternate at least three baseline/optimized runs. Test fresh chunk exploration separately. For everyday play cap FPS or enable VSync to reduce power usage.
The instance's `horizon-benchmarks/` folder receives JSON/CSV frame and integrated-server tick timings: average FPS, slowest-1%-average FPS, p95/p99 durations, stalls, JVM heap/GC, sampled process RSS when available, GPU/backend/driver and render settings. Frames include presentation/limiting waits and exclude menus/pauses/unfocused time. Tick `meanRate` is execution capacity, **not TPS**; observed tick counts are separate. Version 0.20 [captures sampled tick-manager state](docs/tick-state.md), including restored freeze/step/sprint/rate changes; this does not measure completed world work. Resident process memory is sampled on a shared background thread only during recordings; it includes native resident pages, while heap measures JVM objects. Neither is a complete measurement of unified GPU memory. [Memory measurement details](docs/process-memory.md).
```sh
python3 tools/compare_benchmarks.py baseline-frames.json optimized-frames.json --strict
```
Open `Compare-Benchmarks.html` from the starter ZIP or [the source file](tools/Compare-Benchmarks.html) in your browser to compare without Python. Select each JSON and optionally its matching CSV. It exports summary JSON, flags settings and duration differences, and charts frames by recorded active time or ticks by sample number. It is self-contained and makes no network requests.
Version 0.18 adds an isolated [terrain quad reuse experiment](docs/quad-reuse.md).
It reuses a temporary quad discarded by successful premerge when the existing
pool would otherwise allocate. Generated geometry and thread ownership checks
pass; allocation results are not whole-game RAM or FPS evidence. It remains off
in combined profiles.
Version 0.17 also records loaded mod versions and the Java vendor, VM and runtime
build in client and server reports. Both comparison tools flag software changes
as explicit experiment variables; older reports remain usable with an unknown
software warning. The viewer can expand the complete loaded mod list.
[Live software identity check](benchmarks/software-identity-2026-10-04/live-observations.json)
records the metadata and file-count validation; its short capture is not a
performance comparison.
The comparison flags captured setting differences, but cannot verify route, world, shader state or cache warm-up. See [validation evidence](docs/playable-validation.md). Software-renderer test FPS is not evidence of a Mac/GPU speedup.
For CPU/allocation investigation, [capture and stream a private JFR summary](docs/profiling.md).
It distinguishes directly observed allocation counters from sampled hot methods;
profiling runs remain separate from normal timing comparisons.
## Build and run
Requires Python 3.10+ and JDK 25. No Gradle, Rust or native compilation is required for playable packs.
```sh
python3 tools/build_mod.py
python3 tools/build_release.py
```
Gitignored `dist/` contains twenty packs, our original instrumentation JAR, client/server starter ZIPs and SHA256SUMS. [mods.lock.json](packs/mods.lock.json) pins mod versions, exact dependency closure, SHA-1/SHA-512 and source/license; [fabric.lock.json](packs/fabric.lock.json) pins loader/library SHA-256. Packs use the [Modrinth format](https://support.modrinth.com/en/articles/8802351-modrinth-modpack-format-mrpack). They embed only original project code; third-party mods, game and assets download from their publishers.
The server starter uses Lithium/FerriteCore and `/horizonserver bench <label> <seconds>`. [Server instructions](docs/server-quickstart.txt) cover setup and a baseline. Files are prepared before you accept Minecraft's EULA locally. A 50-active-player workload remains to be measured.
Local smoke testing uses the game's built-in limited demo; normal play uses Prism's licensed account authentication:
```sh
python3 tools/build_pack.py --profile m2-balanced --instance .local/runtime/m2-balanced
vscreen run python3 tools/runtime.py demo --instance .local/runtime/m2-balanced
```
The standalone demo launcher applies the pinned official manifest's conditional
JVM flags, including macOS `-XstartOnFirstThread` and the Windows heap-dump-path
option. Prism applies its own official manifest. Server JVM policy is separate;
this launcher correction is not a measured graphics performance improvement.
## Local game inspection
Game JARs, decompiled Java, libraries, inventories, worlds and logs live **inside this checkout** under gitignored **`.local/`**. No game code is in Git history or release downloads. Fresh clones regenerate the inspection workspace from official downloads; Microsoft credentials are not needed for this step.
```sh
git clone https://github.com/ludovic111/horizon.git
cd horizon
git config core.hooksPath .githooks
python3 tools/lab.py prepare
python3 tools/lab.py decompile
python3 tools/lab.py status
```
`research-lock.json` pins the official manifest and Vineflower. The inner server JAR is extracted and checked; client/server libraries provide decompiler context. Output: `.local/minecraft/26.3/sources/{client,server}/` and coverage/inventory reports. Current inventories cover 11,383 client classes and 7,762 server classes, producing 7,301/5,037 Java files. See [decompilation status](docs/decompilation-status.md).
Class-file SourceFile metadata covers inner classes, which often share a source file. Coverage does not prove semantic equivalence or recompilability; original bytecode remains the reference. Completed source directories are not overwritten automatically. Older obfuscated releases require a separate remapping stage.
## Original Rust research
The dependency-free `horizon-core` crate starts the Rust simulation foundation:
integer block/chunk/section positions, legacy and xoroshiro seeded random streams and positional factories,
padded palette-index storage, scheduled block/fluid tick queues, and double-precision
bounding-box and voxel-grid collision geometry. Noise samplers, octave stacks and
bulk density kernels provide the next world-generation foundation. Compiled
density samplers add gradients, shifted and blended terrain noise, distance
metrics and arithmetic evaluation.
Differential runners compare selected
operations with the checksum-pinned vanilla client and server bytecode.
[Implemented scope, validation, and remaining rewrite work](docs/rust-parity.md).
```sh
python3 tools/test_rust_parity.py --prepare
python3 tools/test_world_parity.py
python3 tools/test_tick_parity.py
python3 tools/test_geometry_parity.py
python3 tools/test_collision_parity.py
python3 tools/test_noise_parity.py
python3 tools/test_density_parity.py
```
The core corpus checks 1,634,520 output lines per reference JAR, including
signed boundaries, RNG rejection/state consumption and raw storage words.
Passing it does not establish full gameplay parity or an FPS improvement.
The separate [noise suite](docs/noise-generation.md) checks 26.3 Perlin/simplex,
derivatives, density-volume accumulation, stack composition, normal-noise
normalization and modern/legacy initialization against both reference JARs.
The [density suite](docs/density-functions.md) checks compiled scalar and bulk
evaluation, including nested samplers, their different arithmetic paths, and
selected interval operations used for graph range analysis.
`horizon-world` adds NBT, four region compression codecs, `.mca`/`.mcc`
reading and export, saved palettes, a version-checked block-state registry,
and packed resident block sections. The world-data suite checks Rust output
with the actual vanilla NBT, region and block-state decoders on both JARs.
It covers all 35,723 exported state IDs and a 100,000-edit section trace.
[World-data commands and validation limits](docs/world-data.md).
Scheduled ticks now support vanilla ordering, duplicate suppression, save/reload,
chunk eligibility and callback mutations. The tick suite compares 97,776 output
lines per reference JAR and checks saved block/fluid lists through vanilla codecs
and region files. [Scheduler behavior and remaining integration](docs/scheduled-ticks.md).
Voxel shapes preserve vanilla collision tolerances, greedy box ordering, ray faces,
inside hits, and cube-versus-array cell lookup. A cached collision registry connects
imported shapes to resident block sections while checking exact state identities.
Movement helpers resolve supplied colliders and step-up candidates, including
landing adjustment and the second collider query. Movement-path block visits
preserve vanilla ordering, step indices and packed-position deduplication.
Uncached states require block behavior; the entity-context overload also needs
separate implementation. [Geometry and collision scope](docs/collision-geometry.md).
```sh
cargo test --workspace
cargo run --release --bin voxel-bench -- 100 > .local/voxel-benchmark.csv
```
The dependency-free occupancy experiment compares scalar and bitset exposed-face counting for synthetic 16³ sections. It has no real mesh, material/AO/light work, neighboring-section context, JNI bridge or game integration. Speedups are kernel-only, not Minecraft FPS. [Research plan](docs/optimization-plan.md) describes profiling, improved Java baselines, native integration and behavior checks before expanding scope.
Public branding and new mod downloads use **Horizon**. The existing Fabric mod
ID and `dev.horizonlab` namespace remain stable for installed instances and
measurement compatibility. The installer backs up both old `horizon-lab-*.jar`
and new `horizon-*.jar` versions before installing an update.
## Validation and ownership
```sh
python3 -m unittest discover -s tests -v
python3 tools/build_mod.py --test-only
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
python3 tools/check_public_tree.py
python3 tools/check_public_tree.py --history
```
CI checks offline tools/Rust and compiles the original mod/builds packs on Linux and Apple Silicon macOS. Game dependencies download to ephemeral `.local/`; CI uploads only our original JAR and packs. Compilation is not Mac GPU gameplay testing. Publication checks allow Java only under our original `dev/horizonlab` namespace; review diffs as well.
CI runs these checks on GitHub's Linux runners. The vanilla comparisons need the game, which Horizon never downloads, so they run only when started by hand on a self-hosted runner holding the maintainer's own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
[MIT](LICENSE) covers only our original tracked work. Dependencies retain their licenses. Mojang [removed obfuscation](https://www.minecraft.net/en-us/article/removing-obfuscation-in-java-edition) without changing [Minecraft's EULA](https://www.minecraft.net/en-us/eula). Decompiled game source remains local; published code implements independent tooling and instrumentation.
## Licence
Integrated-server reports record the shared graphics backend at recording start; see [the context and comparison limits](docs/server-graphics-context.md).
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
## Author
Made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)).
@@ -6,6 +6,7 @@ use horizon_assets::{
item_model::*,
math::Mat4,
model::{CuboidModel, Direction},
palette::{PalettedPermutations, apply_palette},
resolve::{MISSING_MODEL, ModelLibrary},
};
use serde_json::{Value, json};
@@ -23,6 +24,8 @@ fn bits(v: f32) -> u32 {
struct Sprites {
root: PathBuf,
/// The items atlas's paletted permutations (armour trim sprites).
permutations: Vec<PalettedPermutations>,
cache: RefCell<BTreeMap<String, Option<SpriteMask>>>,
}
impl SpriteLookup for Sprites {
@@ -38,27 +41,48 @@ impl SpriteLookup for Sprites {
}
}
impl Sprites {
fn load(&self, sprite: &str) -> Option<SpriteMask> {
let (namespace, path) = sprite.split_once(':')?;
let png = self
.root
.join(format!("assets/{namespace}/textures/{path}.png"));
let mut decoder = png::Decoder::new(BufReader::new(File::open(&png).ok()?));
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
let (namespace, path) = sprite.split_once(':').unwrap_or(("minecraft", sprite));
self.root
.join(format!("assets/{namespace}/{directory}/{path}.png"))
}
/// The image as ARGB pixels in reading order, with its size.
fn argb(png: &Path) -> Option<(usize, usize, Vec<u32>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(png).ok()?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let mut bytes = vec![0; reader.output_buffer_size()?];
let info = reader.next_frame(&mut bytes).ok()?;
let channels = info.color_type.samples();
let alpha = |pixel: &[u8]| match info.color_type {
png::ColorType::Rgba => pixel[3],
png::ColorType::GrayscaleAlpha => pixel[1],
_ => 255,
};
let (iw, ih) = (info.width as usize, info.height as usize);
let pixels: Vec<u8> = bytes[..info.buffer_size()]
let pixels = bytes[..info.buffer_size()]
.chunks_exact(channels)
.map(alpha)
.map(|p| {
let (r, g, b, a) = match info.color_type {
png::ColorType::Rgba => (p[0], p[1], p[2], p[3]),
png::ColorType::Rgb => (p[0], p[1], p[2], 255),
png::ColorType::GrayscaleAlpha => (p[0], p[0], p[0], p[1]),
_ => (p[0], p[0], p[0], 255),
};
u32::from_be_bytes([a, r, g, b])
})
.collect();
Some((info.width as usize, info.height as usize, pixels))
}
fn load(&self, sprite: &str) -> Option<SpriteMask> {
let png = self.png(sprite, "textures");
let (iw, ih, argb) = match Self::argb(&png) {
Some(image) => image,
None => {
// PalettedPermutations: the base texture through PaletteMapping.
let p = self.permutations.iter().find_map(|s| s.find(sprite))?;
let (w, h, mut pixels) = Self::argb(&self.png(p.base, "textures"))?;
let (_, _, key) = Self::argb(&self.png(p.palette_key, "textures/palettes"))?;
let (_, _, target) = Self::argb(&self.png(p.palette, "textures/palettes"))?;
apply_palette(&mut pixels, &key, &target)?;
(w, h, pixels)
}
};
let pixels: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
let meta = fs::read_to_string(png.with_extension("png.mcmeta")).ok();
let animation = meta
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
@@ -215,6 +239,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let library = ModelLibrary::with_item_generator(cuboids)?;
let sprites = Sprites {
root: root.clone(),
permutations: PalettedPermutations::from_atlas(
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
.unwrap_or_default(),
),
cache: RefCell::default(),
};
let grass = {
+4 -2
View File
@@ -342,9 +342,11 @@ fn optional_transformation(m: &Map<String, Value>) -> Result<Option<Mat4>> {
get(m, "transformation").map(transformation).transpose()
}
/// `Mth.floor(float)` as `ARGB.as8BitChannel` uses it.
/// `ARGB.as8BitChannel`: `Mth.floor(value * 255.0F)`.
fn channel(v: f32) -> i32 {
let i = v as i32;
if (i as f32) > v { i - 1 } else { i }
let scaled = v * 255.0;
let i = scaled as i32;
if (i as f32) > scaled { i - 1 } else { i }
}
// ---------------------------------------------------------------------------
@@ -207,9 +207,7 @@ impl SpecialModel {
}
SpecialKind::PlayerHead => "player_head",
SpecialKind::ShulkerBox { texture, openness } => {
if texture != "minecraft:shulker" {
put("texture", json!(texture));
}
put("texture", json!(texture));
if *openness != 0.0 {
put("openness", json!(openness));
}
+1
View File
@@ -9,6 +9,7 @@ pub mod item_model;
mod json;
pub mod math;
pub mod model;
pub mod palette;
pub mod resolve;
pub use json::DecodeError;
+260 -38
View File
@@ -1,31 +1,91 @@
//! Column-major `Matrix4f` arithmetic with the operand order of JOML's
//! default (non-FMA) code paths, for composing item-model transformations.
//! Column-major `Matrix4f` arithmetic as JOML 1.10.9 computes it, for composing
//! item-model transformations: each operation takes the path JOML picks from
//! the matrix properties (identity, translation, affine...), and `Math.fma` is
//! `a * b + c` since Minecraft leaves `joml.useMathFma` off.
/// A JOML `Matrix4f`: `m[column][row]`.
/// `Matrix4fc.PROPERTY_*` bits.
const PERSPECTIVE: u8 = 1;
const AFFINE: u8 = 2;
const IDENTITY: u8 = 4;
const TRANSLATION: u8 = 8;
const ORTHONORMAL: u8 = 16;
/// JOML `Math.fma` without `joml.useMathFma`.
#[inline]
fn fma(a: f32, b: f32, c: f32) -> f32 {
a * b + c
}
/// JOML `Math.absEqualsOne`.
fn abs_equals_one(r: f32) -> bool {
r.to_bits() & 0x7FFF_FFFF == 0x3F80_0000
}
/// A JOML `Matrix4f`: `m[column][row]`, with its property bits.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Mat4(pub [[f32; 4]; 4]);
pub struct Mat4(pub [[f32; 4]; 4], pub u8);
impl Mat4 {
pub const IDENTITY: Self = Self([
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]);
/// `new Matrix4f()`.
pub const IDENTITY: Self = Self(
[
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
],
IDENTITY | AFFINE | TRANSLATION | ORTHONORMAL,
);
/// `Matrix4f.translation(x, y, z)`.
pub fn translation(t: [f32; 3]) -> Self {
let mut m = Self::IDENTITY;
m.0[3][..3].copy_from_slice(&t);
m.1 = AFFINE | TRANSLATION | ORTHONORMAL;
m
}
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order.
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order, then
/// `determineProperties`.
pub fn from_row_major(values: [f32; 16]) -> Self {
let mut m = Self([[0.0; 4]; 4]);
let mut m = Self([[0.0; 4]; 4], 0);
for (i, v) in values.into_iter().enumerate() {
m.0[i & 3][i >> 2] = v;
}
m.1 = m.determine_properties();
m
}
/// `Matrix4f.determineProperties`.
fn determine_properties(&self) -> u8 {
let m = &self.0;
let mut properties = 0;
if m[0][3] == 0.0 && m[1][3] == 0.0 {
if m[2][3] == 0.0 && m[3][3] == 1.0 {
properties |= AFFINE;
if m[0] == [1.0, 0.0, 0.0, 0.0]
&& m[1] == [0.0, 1.0, 0.0, 0.0]
&& m[2] == [0.0, 0.0, 1.0, 0.0]
{
properties |= TRANSLATION | ORTHONORMAL;
if m[3][0] == 0.0 && m[3][1] == 0.0 && m[3][2] == 0.0 {
properties |= IDENTITY;
}
}
} else if m[0][1] == 0.0
&& m[0][2] == 0.0
&& m[1][0] == 0.0
&& m[1][2] == 0.0
&& m[2][0] == 0.0
&& m[2][1] == 0.0
&& m[3][0] == 0.0
&& m[3][1] == 0.0
&& m[3][3] == 0.0
{
properties |= PERSPECTIVE;
}
}
properties
}
fn has(&self, property: u8) -> bool {
self.1 & property != 0
}
/// The matrix as `[row][column]` entries, the order `getRowColumn` reads.
pub fn rows(&self) -> [[f32; 4]; 4] {
std::array::from_fn(|r| std::array::from_fn(|c| self.0[c][r]))
@@ -33,37 +93,143 @@ impl Mat4 {
/// `this * right` (`Matrix4f.mul`).
pub fn mul(&self, right: &Self) -> Self {
let (m, r) = (&self.0, &right.0);
if self.has(IDENTITY) {
return *right;
}
if right.has(IDENTITY) {
return *self;
}
let mut out = [[0.0; 4]; 4];
if self.has(TRANSLATION) && right.has(AFFINE) {
// mulTranslationAffine
for c in 0..4 {
out[c] = [r[c][0], r[c][1], r[c][2], m[c][3]];
}
for row in 0..3 {
out[3][row] = r[3][row] + m[3][row];
}
return Self(out, AFFINE | right.1 & ORTHONORMAL);
}
if self.has(AFFINE) && right.has(AFFINE) {
// mulAffine
for c in 0..3 {
for row in 0..3 {
out[c][row] = fma(
m[0][row],
r[c][0],
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
);
}
out[c][3] = m[c][3];
}
for row in 0..3 {
out[3][row] = fma(
m[0][row],
r[3][0],
fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])),
);
}
out[3][3] = m[3][3];
return Self(out, AFFINE | self.1 & right.1 & ORTHONORMAL);
}
if self.has(PERSPECTIVE) && right.has(AFFINE) {
// mulPerspectiveAffine
for c in 0..4 {
out[c] = [
m[0][0] * r[c][0],
m[1][1] * r[c][1],
m[2][2] * r[c][2],
m[2][3] * r[c][2],
];
}
out[3][2] = m[2][2] * r[3][2] + m[3][2];
out[3][3] = m[2][3] * r[3][2];
return Self(out, 0);
}
if right.has(AFFINE) {
// mulAffineR
for c in 0..3 {
for row in 0..4 {
out[c][row] = fma(
m[0][row],
r[c][0],
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
);
}
}
for row in 0..4 {
out[3][row] = fma(
m[0][row],
r[3][0],
fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])),
);
}
return Self(
out,
self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | ORTHONORMAL),
);
}
// mul0
for c in 0..4 {
for row in 0..4 {
out[c][row] = m[0][row] * r[c][0]
+ m[1][row] * r[c][1]
+ m[2][row] * r[c][2]
+ m[3][row] * r[c][3];
out[c][row] = fma(
m[0][row],
r[c][0],
fma(
m[1][row],
r[c][1],
fma(m[2][row], r[c][2], m[3][row] * r[c][3]),
),
);
}
}
Self(out)
Self(out, 0)
}
/// `Matrix4f.translate(x, y, z)` (`translateGeneric`).
/// `Matrix4f.translate(x, y, z)`.
pub fn translate(&self, t: [f32; 3]) -> Self {
if self.has(IDENTITY) {
return Self::translation(t);
}
// translateGeneric
let mut out = *self;
for row in 0..4 {
out.0[3][row] = self.0[0][row] * t[0]
+ (self.0[1][row] * t[1] + (self.0[2][row] * t[2] + self.0[3][row]));
out.0[3][row] = fma(
self.0[0][row],
t[0],
fma(
self.0[1][row],
t[1],
fma(self.0[2][row], t[2], self.0[3][row]),
),
);
}
out.1 = self.1 & !(PERSPECTIVE | IDENTITY);
out
}
/// `Matrix4f.scale(x, y, z)`: the first three columns are scaled.
/// `Matrix4f.scale(x, y, z)`.
pub fn scale(&self, s: [f32; 3]) -> Self {
let one = s.iter().all(|&v| abs_equals_one(v));
if self.has(IDENTITY) {
// scaling
let mut out = Self::IDENTITY;
for (c, v) in s.into_iter().enumerate() {
out.0[c][c] = v;
}
out.1 = AFFINE | if one { ORTHONORMAL } else { 0 };
return out;
}
// scaleGeneric: the first three columns are scaled.
let mut out = *self;
for c in 0..3 {
for row in 0..4 {
out.0[c][row] = self.0[c][row] * s[c];
}
}
out.1 =
self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | if one { 0 } else { ORTHONORMAL });
out
}
/// `Matrix4f.rotate(Quaternionfc)` (`rotateGeneric`).
/// `Matrix4f.rotate(Quaternionfc)`.
pub fn rotate(&self, q: [f32; 4]) -> Self {
let [x, y, z, w] = q;
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
@@ -71,14 +237,43 @@ impl Mat4 {
let (dzw, dxy, dxz, dyw, dyz, dxw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
let r = [
[w2 + x2 - z2 - y2, dxy + dzw, dxz - dyw],
[dxy - dzw, y2 - z2 + w2 - x2, dyz + dxw],
[-dzw + dxy, y2 - z2 + w2 - x2, dyz + dxw],
[dyw + dxz, dyz - dxw, z2 - y2 - x2 + w2],
];
let m = &self.0;
let mut out = *self;
for c in 0..3 {
for row in 0..4 {
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
if self.has(IDENTITY) {
// rotation
for c in 0..3 {
out.0[c][..3].copy_from_slice(&r[c]);
}
out.1 = AFFINE | ORTHONORMAL;
return out;
}
out.1 = self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION);
if self.has(TRANSLATION) {
// rotateTranslation
for c in 0..3 {
out.0[c] = [r[c][0], r[c][1], r[c][2], 0.0];
}
} else if self.has(AFFINE) {
// rotateAffine
for c in 0..3 {
for row in 0..3 {
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
}
out.0[c][3] = 0.0;
}
} else {
// rotateGeneric
for c in 0..3 {
for row in 0..4 {
out.0[c][row] = fma(
m[0][row],
r[c][0],
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
);
}
}
}
out
@@ -94,28 +289,50 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
let inv = 1.0 / (x * x + (y * y + (z * z + w * w))).sqrt();
[x * inv, y * inv, z * inv, w * inv]
}
/// JOML `Math.sin(float)` without fast math.
fn joml_sin(rad: f32) -> f32 {
f64::from(rad).sin() as f32
}
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
let cos = (1.0 - sin * sin).sqrt();
let tau = (std::f64::consts::PI * 2.0) as f32;
let a = angle + std::f32::consts::FRAC_PI_2;
let mut b = a - (a / tau) as i32 as f32 * tau;
if b < 0.0 {
b += tau;
}
if b >= std::f32::consts::PI { -cos } else { cos }
}
/// `Quaternionf.rotationXYZ` from angles in radians.
pub fn quaternion_xyz(x: f32, y: f32, z: f32) -> [f32; 4] {
let half = |a: f32| -> (f32, f32) {
let h = a * 0.5;
((f64::from(h).sin()) as f32, (f64::from(h).cos()) as f32)
let s = joml_sin(h);
(s, cos_from_sin(s, h))
};
let ((sx, cx), (sy, cy), (sz, cz)) = (half(x), half(y), half(z));
let (cx_cy, sx_cy, cx_sy, sx_sy) = (cx * cy, sx * cy, cx * sy, sx * sy);
let (cycz, sysz, sycz, cysz) = (cy * cz, sy * sz, sy * cz, cy * sz);
[
sx_cy * cz + cx_sy * sz,
cx_sy * cz - sx_cy * sz,
cx_cy * sz + sx_sy * cz,
cx_cy * cz - sx_sy * sz,
sx * cycz + cx * sysz,
cx * sycz - sx * cysz,
cx * cysz + sx * sycz,
cx * cycz - sx * sysz,
]
}
/// `new Quaternionf(new AxisAngle4f(angle, axis))`.
/// `new Quaternionf(new AxisAngle4f(angle, axis))`: the angle is wrapped into
/// [0, 2π) and the axis is used as given.
pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
let half = angle * 0.5;
let (sin, cos) = (half.sin(), half.cos());
let norm = 1.0 / (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
let s = sin * norm;
[axis[0] * s, axis[1] * s, axis[2] * s, cos]
let tau = (std::f64::consts::PI * 2.0) as f32;
let angle = if angle < 0.0 {
tau + angle % tau
} else {
angle
} % tau;
let sin = joml_sin(angle * 0.5);
let cos = cos_from_sin(sin, angle * 0.5);
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
}
#[cfg(test)]
@@ -137,4 +354,9 @@ mod tests {
let m = Mat4::IDENTITY.rotate(q);
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
}
#[test]
fn half_turn_has_exact_zeros_like_joml() {
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
}
}
+144
View File
@@ -0,0 +1,144 @@
//! `PalettedPermutations` atlas sources: sprites such as
//! `trims/items/boots_trim_quartz` are the base texture with its colours
//! swapped through a palette pair (`PaletteMapping`), not files of their own.
use serde_json::Value;
use std::collections::BTreeMap;
/// One `minecraft:paletted_permutations` source of an atlas definition.
#[derive(Clone, Debug)]
pub struct PalettedPermutations {
/// Base sprite names (`minecraft:trims/items/boots_trim`).
pub textures: Vec<String>,
/// The key palette (`minecraft:trim_base`).
pub palette_key: String,
/// Suffix to target palette (`quartz` → `minecraft:trim/quartz`).
pub permutations: BTreeMap<String, String>,
pub separator: String,
}
/// A permuted sprite: its base texture and the palette pair to apply.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Permutation<'a> {
pub base: &'a str,
pub palette_key: &'a str,
pub palette: &'a str,
}
fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
impl PalettedPermutations {
/// The `paletted_permutations` sources of an atlas JSON (`atlases/items.json`).
pub fn from_atlas(atlas: &str) -> Vec<Self> {
let Ok(json) = serde_json::from_str::<Value>(atlas) else {
return Vec::new();
};
let sources = json
.get("sources")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
sources
.iter()
.filter(|s| {
s.get("type")
.and_then(Value::as_str)
.map(qualified)
.as_deref()
== Some("minecraft:paletted_permutations")
})
.filter_map(|s| {
Some(Self {
textures: s
.get("textures")?
.as_array()?
.iter()
.filter_map(Value::as_str)
.map(qualified)
.collect(),
palette_key: qualified(s.get("palette_key")?.as_str()?),
permutations: s
.get("permutations")?
.as_object()?
.iter()
.filter_map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
.collect(),
separator: s
.get("separator")
.and_then(Value::as_str)
.unwrap_or("_")
.to_owned(),
})
})
.collect()
}
/// The permutation that produces `sprite` (`withSuffix(separator + key)`).
pub fn find(&self, sprite: &str) -> Option<Permutation<'_>> {
let sprite = qualified(sprite);
self.textures.iter().find_map(|base| {
let suffix = sprite
.strip_prefix(base.as_str())?
.strip_prefix(self.separator.as_str())?;
Some(Permutation {
base,
palette_key: &self.palette_key,
palette: self.permutations.get(suffix)?,
})
})
}
}
/// `PaletteMapping.create(key, target).apply` over ARGB pixels; the palettes
/// are the pixels of their images in reading order (`Palette.from`).
pub fn apply_palette(pixels: &mut [u32], key: &[u32], target: &[u32]) -> Option<()> {
if key.len() != target.len() {
return None;
}
let mut map = BTreeMap::new();
for (&k, &t) in key.iter().zip(target) {
if k >> 24 != 0 {
map.insert(k | 0xFF00_0000, t);
}
}
for pixel in pixels {
let alpha = *pixel >> 24;
if alpha == 0 {
continue;
}
let rgb = *pixel | 0xFF00_0000;
let target = map.get(&rgb).copied().unwrap_or(rgb);
*pixel = (alpha * (target >> 24) / 255) << 24 | target & 0x00FF_FFFF;
}
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_trim_permutations_and_maps_colours() {
let atlas = r#"{"sources":[{"type":"minecraft:paletted_permutations","palette_key":"minecraft:trim_base",
"permutations":{"quartz":"minecraft:trim/quartz"},"textures":["minecraft:trims/items/boots_trim"]}]}"#;
let sources = PalettedPermutations::from_atlas(atlas);
let p = sources[0]
.find("minecraft:trims/items/boots_trim_quartz")
.unwrap();
assert_eq!(
(p.base, p.palette),
("minecraft:trims/items/boots_trim", "minecraft:trim/quartz")
);
assert!(
sources[0]
.find("minecraft:trims/items/boots_trim_gold")
.is_none()
);
let mut pixels = [0xFF11_2233, 0x8011_2233, 0x0011_2233, 0xFF44_5566];
apply_palette(&mut pixels, &[0xFF11_2233], &[0xFFAA_BBCC]).unwrap();
assert_eq!(pixels, [0xFFAA_BBCC, 0x80AA_BBCC, 0x0011_2233, 0xFF44_5566]);
}
}
+4
View File
@@ -23,5 +23,9 @@ cpal = "0.16"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
[features]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
[lints]
workspace = true
@@ -0,0 +1,185 @@
//! Replays `tools/test_ambient_sound_parity.py` scripts through Horizon's
//! ambient sound handlers over its `SoundEngine`:
//! `ambient_fixture ROOT INPUT OUTPUT`.
#[path = "audio_common/mod.rs"]
mod common;
use common::{Fake, d, f, flush};
use horizon_client::ambience::{Ambience, AmbientContext, AmbientSounds, Mood, bubble_column_in};
use horizon_client::sound_engine::{Bound, SoundEngine, TickContext, Ticking};
use horizon_client::sound_events::{SoundRegistry, normalize};
use horizon_core::random::RandomSource;
use std::io::Write;
/// The oracle's scripted brightness.
fn light(mode: i32, x: i32, y: i32, z: i32) -> (u8, u8) {
let h = x.wrapping_mul(73856093) ^ y.wrapping_mul(19349663) ^ z.wrapping_mul(83492791);
let u = h as u32;
match mode {
0 => (15, 0),
1 => (0, 0),
2 => (0, ((u >> 4) & 15) as u8),
_ => (
if h & 3 == 0 { ((u >> 8) & 15) as u8 } else { 0 },
((u >> 12) & 15) as u8,
),
}
}
fn sounds(a: &[&str]) -> AmbientSounds {
AmbientSounds {
r#loop: (a[1] != "-").then(|| normalize(a[1])),
mood: (a[2] != "-").then(|| {
let m: Vec<&str> = a[2].split(',').collect();
Mood {
sound: normalize(m[0]),
tick_delay: m[1].parse().expect("delay"),
block_search_extent: m[2].parse().expect("extent"),
offset: d(m[3]),
}
}),
additions: a[3..]
.iter()
.map(|s| {
let p: Vec<&str> = s.split(',').collect();
(normalize(p[0]), d(p[1]))
})
.collect(),
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
engine.listening = true;
let mut backend = Fake::default();
let mut ambience = Ambience::new(0);
let mut current = AmbientSounds::default();
let (mut position, mut eye, mut eye_in_water, mut spectator, mut removed, mut mode) =
([0.0f64; 3], 0.0f32, false, false, false, 0);
let mut height = 1.8f32;
let mut columns: Vec<([i32; 3], bool)> = Vec::new();
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
writeln!(out, "{line}").expect("write");
let a: Vec<&str> = line.split(' ').collect();
match a[0] {
"start" => {
engine.stop_all(&mut backend);
ambience.clear(&mut Bound {
engine: &mut engine,
backend: &mut backend,
});
ambience = Ambience::new(a[1].parse().expect("seed"));
current = AmbientSounds::default();
(position, eye, eye_in_water, spectator, removed, mode) =
([0.0; 3], 0.0, false, false, false, 0);
height = 1.8;
columns.clear();
}
"sounds" => current = sounds(&a),
"player" => {
position = [d(a[1]), d(a[2]), d(a[3])];
eye = f(a[4]);
eye_in_water = a[5] == "1";
spectator = a[6] == "1";
removed = a[7] == "1";
mode = a[8].parse().expect("mode");
height = f(a[9]);
columns = a[10..]
.iter()
.map(|c| {
let p: Vec<&str> = c.split(',').collect();
let n = |i: usize| p[i].parse::<i32>().expect("column");
([n(0), n(1), n(2)], p[3] == "1")
})
.collect();
}
"volume" => {
let source: usize = a[1].parse().expect("source");
engine.source_volumes[source] = f(a[2]);
engine.refresh_category_volume(source, &mut backend);
}
"tick" => {
let light = |b: [i32; 3]| light(mode, b[0], b[1], b[2]);
let ctx = AmbientContext {
sounds: &current,
position,
eye_y: position[1] + f64::from(eye),
underwater: eye_in_water,
spectator,
light: &light,
bubble_column: bubble_column_in(
[
position[0] - f64::from(0.3f32),
position[1],
position[2] - f64::from(0.3f32),
position[0] + f64::from(0.3f32),
position[1] + f64::from(height),
position[2] + f64::from(0.3f32),
],
|b| columns.iter().find(|c| c.0 == b).map(|c| c.1),
),
};
ambience.tick(
&ctx,
&mut Bound {
engine: &mut engine,
backend: &mut backend,
},
);
let tick = TickContext {
player_underwater: ambience.was_underwater(),
player_removed: removed,
};
engine.tick(false, &tick, &mut backend);
}
_ => panic!("{line}"),
}
writeln!(
out,
" rand={} mood={:08x} underwater={} delay={}",
ambience.random.clone().next_long(),
ambience.moodiness.to_bits(),
ambience.was_underwater(),
ambience.underwater_delay()
)
.expect("write");
for (event, id) in ambience.loops() {
let i = engine.instance(*id).expect("loop");
let Ticking::BiomeLoop { fade, direction } = i.ticking else {
panic!("loop kind");
};
writeln!(
out,
" loop {event} fade={fade} direction={direction} stopped={} active={}",
i.stopped,
engine.is_active(*id)
)
.expect("write");
}
let mut playing: Vec<String> = engine
.active_instances()
.into_iter()
.map(|id| {
let i = engine.instance(id).expect("instance");
format!(
" playing {} volume={:08x} looping={} relative={} at={:016x},{:016x},{:016x} stopped={}",
i.identifier,
i.volume.to_bits(),
i.looping,
i.relative,
i.position[0].to_bits(),
i.position[1].to_bits(),
i.position[2].to_bits(),
i.tickable() && i.stopped
)
})
.collect();
playing.sort();
for p in playing {
writeln!(out, "{p}").expect("write");
}
flush(&mut engine, &mut out);
}
}
@@ -0,0 +1,85 @@
//! Audio environment attributes from Horizon's `MusicTables` and
//! `AmbientTables` for `tools/test_audio_attributes_parity.py`:
//! `audio_attributes_fixture ROOT INPUT OUTPUT`.
use horizon_client::ambience::AmbientTables;
use horizon_client::music::{Music, MusicTables};
use horizon_client::sound_events::normalize;
use std::io::Write;
fn music(m: Option<&Music>) -> String {
m.map_or("-".to_owned(), |m| {
format!(
"{},{},{},{}",
normalize(&m.sound),
m.min_delay,
m.max_delay,
m.replace_current_music
)
})
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let root = std::path::Path::new(&args[1]);
let tables = MusicTables::load(root);
let ambient = AmbientTables::load(root);
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for (name, m) in [
("MENU", Music::menu()),
("CREDITS", Music::credits()),
("END_BOSS", Music::end_boss()),
("CREATIVE", Music::creative()),
("END", Music::end()),
("UNDER_WATER", Music::under_water()),
("GAME", Music::game()),
] {
writeln!(out, "{name} {}", music(Some(&m))).expect("write");
}
for line in input.lines() {
let f: Vec<&str> = line.split(' ').collect();
let (dimension, biome) = (f[0], f[1]);
let bg = tables.background(dimension, biome);
let mut s = format!(
"{line} default={} creative={} underwater={}",
music(bg.default.as_ref()),
music(bg.creative.as_ref()),
music(bg.underwater.as_ref())
);
for creative in [false, true] {
for underwater in [false, true] {
s.push_str(&format!(
" select={}",
music(bg.select(creative, underwater))
));
}
}
s.push_str(&format!(
" volume={:08x}",
tables.volume(dimension, biome).to_bits()
));
let a = ambient.get(dimension, biome);
s.push_str(&format!(
" loop={}",
a.r#loop.as_deref().map_or("-".to_owned(), normalize)
));
s.push_str(&format!(
" mood={}",
a.mood.as_ref().map_or("-".to_owned(), |m| format!(
"{},{},{},{:016x}",
normalize(&m.sound),
m.tick_delay,
m.block_search_extent,
m.offset.to_bits()
))
));
for (sound, chance) in &a.additions {
s.push_str(&format!(
" addition={},{:016x}",
normalize(sound),
chance.to_bits()
));
}
writeln!(out, "{s}").expect("write");
}
}
@@ -0,0 +1,176 @@
//! Shared by the audio fixtures: a backend recording what an OpenAL source
//! would hold, and the per-step dump the Java oracles print.
#![allow(dead_code)]
use horizon_client::sound_engine::{Backend, ChannelId, InstanceId, PlayResult, SoundEngine};
use horizon_client::sound_events::Sound;
use std::collections::HashMap;
use std::io::Write;
const INITIAL: u32 = 0x1011;
const PLAYING: u32 = 0x1012;
const PAUSED: u32 = 0x1013;
const STOPPED: u32 = 0x1014;
/// An OpenAL source's state as `alGetSource*` reports it.
#[derive(Clone)]
pub struct Source {
pub state: u32,
pub gain: f32,
pub pitch: f32,
pub position: [f32; 3],
pub relative: bool,
pub looping: bool,
pub model: u32,
pub max: f32,
pub rolloff: f32,
pub reference: f32,
}
impl Default for Source {
fn default() -> Self {
Self {
state: INITIAL,
gain: 1.0,
pitch: 1.0,
position: [0.0; 3],
relative: false,
looping: false,
model: 0xD002,
max: f32::MAX,
rolloff: 1.0,
reference: 1.0,
}
}
}
/// Buffers and streams are ready at once (silence in the oracle); stopping an
/// initial source leaves it initial, as in OpenAL Soft.
#[derive(Default)]
pub struct Fake {
pub sources: HashMap<ChannelId, Source>,
next: ChannelId,
}
impl Backend for Fake {
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
self.next += 1;
self.sources.insert(self.next, Source::default());
Some(self.next)
}
fn destroy(&mut self, channel: ChannelId) {
self.sources.remove(&channel);
}
fn set_pitch(&mut self, c: ChannelId, pitch: f32) {
self.sources.get_mut(&c).expect("source").pitch = pitch;
}
fn set_volume(&mut self, c: ChannelId, volume: f32) {
self.sources.get_mut(&c).expect("source").gain = volume;
}
fn set_attenuation(&mut self, c: ChannelId, linear: Option<f32>) {
let s = self.sources.get_mut(&c).expect("source");
match linear {
Some(max) => {
s.model = 0xD003;
s.max = max;
s.rolloff = 1.0;
s.reference = 0.0;
}
None => s.model = 0,
}
}
fn set_looping(&mut self, c: ChannelId, looping: bool) {
self.sources.get_mut(&c).expect("source").looping = looping;
}
fn set_position(&mut self, c: ChannelId, p: [f64; 3]) {
self.sources.get_mut(&c).expect("source").position =
[p[0] as f32, p[1] as f32, p[2] as f32];
}
fn set_relative(&mut self, c: ChannelId, relative: bool) {
self.sources.get_mut(&c).expect("source").relative = relative;
}
fn start(&mut self, c: ChannelId, _sound: &Sound, _looping: bool) {
self.sources.get_mut(&c).expect("source").state = PLAYING;
}
fn stop(&mut self, c: ChannelId) {
if let Some(s) = self.sources.get_mut(&c)
&& s.state != INITIAL
{
s.state = STOPPED;
}
}
fn pause(&mut self, c: ChannelId) {
let s = self.sources.get_mut(&c).expect("source");
if s.state == PLAYING {
s.state = PAUSED;
}
}
fn unpause(&mut self, c: ChannelId) {
let s = self.sources.get_mut(&c).expect("source");
if s.state == PAUSED {
s.state = PLAYING;
}
}
fn stopped(&mut self, c: ChannelId) -> bool {
self.sources.get(&c).is_none_or(|s| s.state == STOPPED)
}
}
pub fn bits(v: f32) -> String {
format!("{:08x}", v.to_bits())
}
pub fn f(hex: &str) -> f32 {
f32::from_bits(u32::from_str_radix(hex, 16).expect("float"))
}
pub fn d(hex: &str) -> f64 {
f64::from_bits(u64::from_str_radix(hex, 16).expect("double"))
}
/// The shared per-step dump (instances, notifications, pool counts).
pub fn describe(engine: &SoundEngine, backend: &Fake, name: &str, id: InstanceId) -> String {
let mut s = format!(" {name} active={}", engine.is_active(id));
match engine.channel(id).and_then(|c| backend.sources.get(&c)) {
None => s.push_str(" channel=-"),
Some(src) => s.push_str(&format!(
" state={:x} gain={} pitch={} pos={},{},{} rel={} loop={} model={:x} max={} rolloff={} ref={}",
src.state,
bits(src.gain),
bits(src.pitch),
bits(src.position[0]),
bits(src.position[1]),
bits(src.position[2]),
u8::from(src.relative),
u8::from(src.looping),
src.model,
bits(src.max),
bits(src.rolloff),
bits(src.reference)
)),
}
s
}
pub fn flush(engine: &mut SoundEngine, out: &mut impl Write) {
for n in std::mem::take(&mut engine.notifications) {
writeln!(
out,
"notify {} {} {} {} {:016x} {:016x} {:016x}",
n.identifier,
n.subtitle.as_deref().unwrap_or("-"),
bits(n.range),
n.looping,
n.position[0].to_bits(),
n.position[1].to_bits(),
n.position[2].to_bits()
)
.expect("write");
}
let [used, streaming] = engine.used_channels();
writeln!(
out,
" Sounds: {used}/{} + {streaming}/{}",
engine.limits[0], engine.limits[1]
)
.expect("write");
}
pub fn result_name(r: PlayResult) -> &'static str {
match r {
PlayResult::Started => "STARTED",
PlayResult::StartedSilently => "STARTED_SILENTLY",
PlayResult::NotStarted => "NOT_STARTED",
}
}
@@ -0,0 +1,117 @@
#![forbid(unsafe_code)]
//! Runs tools/test_auto_jump_parity.py's cases through the local player's
//! `MotionWorld::update_auto_jump` over the client's resident test scene
//! (actual resources, collision registry and block-collision scan).
use horizon_client::{
assets::Assets,
collisions,
motion::{MotionResources, MotionWorld, PlayerMotion},
world::World,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs,
io::{BufRead, BufReader, BufWriter, Write},
path::PathBuf,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn fl(v: &Value) -> f32 {
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn db(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
let [root, input, output, scene_json] = &args[..] else {
return Err("usage: auto_jump_probe RESOURCES INPUT OUTPUT SCENE_JSON".into());
};
let root = PathBuf::from(root);
let assets = Assets::load(&root)?;
let mut scene = World::test_scene(&assets, 1)?;
let binding = collisions::load(&root, assets.registry)?;
let resources = MotionResources::load(&root, &assets)?;
fs::write(
scene_json,
serde_json::to_vec(&serde_json::json!({
"radius": 1, "resident_chunks": scene.chunks.len(), "model_gallery": assets.model_gallery,
}))?,
)?;
let mut out = BufWriter::new(fs::File::create(output)?);
for (n, line) in BufReader::new(fs::File::open(input)?).lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let mut restore = Vec::new();
for b in c["blocks"].as_array().unwrap() {
let (x, y, z) = (
b[0].as_i64().unwrap() as i32,
b[1].as_i64().unwrap() as i32,
b[2].as_i64().unwrap() as i32,
);
let mut state = assets
.registry
.default_state(b[3].as_str().unwrap())
.ok_or("missing fixture block")?;
if let Some(properties) = b[4].as_object()
&& !properties.is_empty()
{
let mut definition = assets.registry.state(state).unwrap().clone();
for (name, value) in properties {
definition
.properties
.insert(name.clone(), value.as_str().unwrap().into());
}
state = assets
.registry
.id(&definition)
.ok_or("missing fixture state")?;
}
restore.push((x, y, z, scene.block(x, y, z)));
scene.set(x, y, z, state)?;
}
let p = &c["player"];
let position = Vec3::new(db(&p["pos"][0]), db(&p["pos"][1]), db(&p["pos"][2]));
let mut player = PlayerMotion::new(&resources, position)?;
player.ground.on_ground = p["ground"].as_bool().unwrap();
player.shift_key_down = p["shift"].as_bool().unwrap();
player.yaw = fl(&p["yr"]);
player.pitch = fl(&p["xr"]);
let key = player
.attributes
.key("minecraft:movement_speed")
.ok_or("speed")?;
let handle = player
.attributes
.get_instance(Some(key))
.map_err(|e| format!("{e:?}"))?
.ok_or("speed instance")?;
let speed = f64::from(fl(&p["speed"]));
player
.attributes
.edit(&handle, |a, o| a.set_base_value(speed, o))
.map_err(|e| format!("{e:?}"))?;
player.move_vector = [fl(&p["move"][0]), fl(&p["move"][1])];
player.dimensions.width = fl(&p["width"]);
player.dimensions.height = fl(&p["height"]);
player.status.jump_boost = p["boost"].as_i64().map(|a| a as i32);
player.auto_jump_enabled = p["enabled"].as_bool().unwrap();
player.auto_jump_time = p["time"].as_i64().unwrap() as i32;
MotionWorld {
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
}
.update_auto_jump(fl(&c["xa"]), fl(&c["za"]))?;
writeln!(out, "jump {}", player.auto_jump_time)?;
for (x, y, z, state) in restore.into_iter().rev() {
scene.set(x, y, z, state)?;
}
}
out.flush()?;
Ok(())
}
@@ -0,0 +1,494 @@
//! Headless transport for the block-entity animation parity gate: replays each
//! scripted case (block, neighbourhood, entities, block events, game times)
//! through `horizon_client::block_entity_anim` and prints the same lines as
//! `VanillaBlockEntityAnimOracle`: the block entity's fields, the level calls
//! its tick made and the render state the renderer derives from `BlockAnim`.
#![forbid(unsafe_code)]
use horizon_client::{
assets::Assets,
block_entity_anim::{
Bell, BellEntity, BlockEntityAnims, ChestLid, Conduit, ConduitTarget, DIRECTIONS,
EndGateway, PotWobble, Shulker, ShulkerStatus, Skull, WobbleStyle, chest_lid_x_rot,
direction_index,
},
block_entity_render::{BlockEntityRenderer, gateway_beam},
blocks::{BlockFacts, Fluid},
items::Items,
};
use horizon_core::random::LegacyRandom;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::Value;
use std::{
collections::HashMap,
fmt::Write as _,
fs,
io::{BufWriter, Write},
path::PathBuf,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn f(v: f32) -> String {
format!("{:08x}", v.to_bits())
}
fn d(v: f64) -> String {
format!("{:016x}", v.to_bits())
}
fn bx(b: [f64; 6]) -> String {
b.map(d).join(" ")
}
fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
let (name, props) = match spec.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (spec, ""),
};
let wanted: Vec<(&str, &str)> = props
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.collect();
let default = registry.state(registry.default_state(name)?)?.clone();
(0..registry.len() as u32).find(|&id| {
registry.state(BlockStateId(id)).is_some_and(|s| {
s.name == name
&& s.properties.iter().all(|(k, v)| {
wanted
.iter()
.find(|(wk, _)| wk == k)
.map_or(default.properties.get(k) == Some(v), |(_, wv)| wv == v)
})
})
})
}
fn prop<'a>(spec: &'a str, key: &str) -> Option<&'a str> {
let props = spec.split_once('[')?.1.trim_end_matches(']');
props
.split(',')
.filter_map(|p| p.split_once('='))
.find(|(k, _)| *k == key)
.map(|(_, v)| v)
}
fn offset(key: &str) -> [i32; 3] {
let mut it = key.split(',').map(|v| v.parse::<i32>().unwrap_or(0));
[
it.next().unwrap_or(0),
it.next().unwrap_or(0),
it.next().unwrap_or(0),
]
}
fn mob(e: &Value) -> BellEntity {
let p = |i: usize| e["pos"][i].as_f64().unwrap_or(0.0);
BellEntity {
position: [p(0), p(1), p(2)],
alive: e["alive"].as_bool().unwrap_or(true) && !e["removed"].as_bool().unwrap_or(false),
raider: e["raider"].as_bool().unwrap_or(false),
}
}
fn lid_text(l: Option<&ChestLid>) -> String {
l.map_or("-".to_owned(), |l| {
format!("{} {} {}", l.should_be_open, f(l.openness), f(l.o_openness))
})
}
/// The case's players: feet positions and whether each is a spectator.
fn players(v: &Value) -> Vec<([f64; 3], bool)> {
v.as_array()
.into_iter()
.flatten()
.map(|p| {
let c = |i: usize| p["pos"][i].as_f64().unwrap_or(0.0);
(
[c(0), c(1), c(2)],
p["spectator"].as_bool().unwrap_or(false),
)
})
.collect()
}
fn status(s: ShulkerStatus) -> &'static str {
match s {
ShulkerStatus::Closed => "CLOSED",
ShulkerStatus::Opening => "OPENING",
ShulkerStatus::Opened => "OPENED",
ShulkerStatus::Closing => "CLOSING",
}
}
fn main() -> Result<()> {
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let [root, inputs, output] = args.as_slice() else {
return Err("usage: block_entity_anim_fixture RESOURCES INPUTS OUTPUT".into());
};
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let renderer = BlockEntityRenderer::load(root, registry)?;
let tags = Items::tags_only(root)?;
let frames = tags
.block_tags
.get("minecraft:conduit_effect_block")
.cloned()
.unwrap_or_default();
let mut out = BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(inputs)?
.lines()
.filter(|l| !l.is_empty())
{
let case: Value = serde_json::from_str(line)?;
writeln!(out, "case {}", case["name"].as_str().unwrap_or_default())?;
let block = case["block"].as_str().unwrap_or_default().to_owned();
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
let pos = [
int(&case["pos"][0]),
int(&case["pos"][1]),
int(&case["pos"][2]),
];
// The neighbourhood as block states (conduits).
let mut blocks: HashMap<[i32; 3], String> = HashMap::new();
blocks.insert(pos, block.clone());
let place = |blocks: &mut HashMap<[i32; 3], String>, set: &Value| {
for (k, v) in set.as_object().into_iter().flatten() {
let o = offset(k);
blocks.insert(
[pos[0] + o[0], pos[1] + o[1], pos[2] + o[2]],
v.as_str().unwrap_or_default().to_owned(),
);
}
};
place(&mut blocks, &case["world"]);
let entities: Vec<BellEntity> = case["entities"]
.as_array()
.into_iter()
.flatten()
.map(mob)
.collect();
let target = case.get("target").map(|t| {
let e = mob(t);
ConduitTarget {
position: e.position,
eye_y: e.position[1] + f64::from(t["eye"].as_f64().unwrap_or(0.0) as f32),
width: t["width"].as_f64().unwrap_or(0.0) as f32,
height: t["height"].as_f64().unwrap_or(0.0) as f32,
}
});
let mut random = LegacyRandom::new(case["seed"].as_i64().unwrap_or(0));
let name = block.split('[').next().unwrap_or_default().to_owned();
let mut anims = BlockEntityAnims::default();
let partner = case["partner"].as_str().map(|k| {
let o = offset(k);
[pos[0] + o[0], pos[1] + o[1], pos[2] + o[2]]
});
let mut gamers = players(&case["players"]);
if name == "minecraft:enchanting_table" {
anims.book_random = LegacyRandom::new(case["seed"].as_i64().unwrap_or(0));
}
let facing = prop(&block, "facing")
.and_then(direction_index)
.unwrap_or(1);
// End gateways: the saved age, the level's height range and the
// integrated server's copy.
let initial = EndGateway {
age: case["age"].as_i64().unwrap_or(0),
teleport_cooldown: 0,
};
let max_y = int(&case["level"][0]) + int(&case["level"][1]) - 1;
let mut server = initial;
let mut text = String::new();
for step in case["steps"].as_array().into_iter().flatten() {
let time = step["time"].as_i64().unwrap_or(0);
let partial = step["partial"].as_f64().unwrap_or(0.0) as f32;
let mut log = String::new();
place(&mut blocks, &step["set"]);
let event = step["event"].as_array().map(|e| (int(&e[0]), int(&e[1])));
text.clear();
if step.get("players").is_some() {
gamers = players(&step["players"]);
}
if name.ends_with("chest") {
let lid = anims.chests.entry(pos).or_default();
if let Some((a, b)) = event {
let handled = lid.trigger_event(a, b);
let _ = write!(log, " event {handled}");
}
if let Some(other) = partner {
let lid = anims.chests.entry(other).or_default();
if let Some(e) = step["partnerEvent"].as_array() {
let handled = lid.trigger_event(int(&e[0]), int(&e[1]));
let _ = write!(log, " partner event {handled}");
}
}
// lidAnimateTick: this chest, then the other half.
if let Some(l) = anims.chests.get_mut(&pos) {
l.tick();
}
if let Some(l) = partner.and_then(|p| anims.chests.get_mut(&p)) {
l.tick();
}
let state_at = |p: [i32; 3]| {
find_state(
registry,
blocks.get(&p).map_or("minecraft:air", String::as_str),
)
.unwrap_or(0)
};
let (open, _) = renderer
.chest_openness_of(pos, state_at(pos), &state_at, &|p| anims.lid(p, partial));
let _ = write!(
text,
"{} partner {} render {} {}",
lid_text(anims.chests.get(&pos)),
partner.map_or("none".to_owned(), |p| lid_text(anims.chests.get(&p))),
f(open),
f(chest_lid_x_rot(open))
);
} else if name == "minecraft:enchanting_table" {
let feet: Vec<[f64; 3]> = gamers
.iter()
.filter(|(_, spectator)| !spectator)
.map(|(p, _)| *p)
.collect();
let BlockEntityAnims {
books, book_random, ..
} = &mut anims;
books.entry(pos).or_default().tick(pos, &feet, book_random);
let b = anims.books[&pos];
let r = anims
.block_anim(pos, time, partial, Default::default())
.book
.render(partial);
let _ = write!(
text,
"{} {} {} {} {} {} {} {} {} {} render {} {} {} {} book {} {} {}",
b.time,
f(b.flip),
f(b.o_flip),
f(b.flip_t),
f(b.flip_a),
f(b.open),
f(b.o_open),
f(b.rot),
f(b.o_rot),
f(b.t_rot),
f(r.flip),
f(r.open),
f(r.time),
f(r.y_rot),
f(r.openness),
f(r.page_flip1),
f(r.page_flip2)
);
} else if name.ends_with("shulker_box") {
let s = anims.shulkers.entry(pos).or_insert_with(Shulker::default);
if let Some((a, b)) = event {
let handled = s.trigger_event(a, b);
let _ = write!(log, " event {handled}");
}
let tick = s.tick(pos, facing);
for _ in 0..tick.neighbor_updates {
log.push_str(" neighbors");
}
if let Some(b) = tick.push {
let _ = write!(log, " push {}", bx(b));
}
let (count, st) = (s.open_count, s.status);
let a = anims.block_anim(pos, time, partial, Default::default());
// ShulkerBoxRenderer: getProgress(partial).
let (progress, old) = a.shulker;
let render = old + partial * (progress - old);
let s = &anims.shulkers[&pos];
let _ = write!(
text,
"{} {} {} {} render {}",
status(st),
count,
f(s.progress),
f(s.progress_old),
f(render)
);
} else if name == "minecraft:bell" {
let b = anims.bells.entry(pos).or_insert_with(Bell::default);
if let Some((e0, e1)) = event {
let handled = b.trigger_event(e0, e1, time, || {
let inflated = [
f64::from(pos[0]) - 48.0,
f64::from(pos[1]) - 48.0,
f64::from(pos[2]) - 48.0,
f64::from(pos[0]) + 1.0 + 48.0,
f64::from(pos[1]) + 1.0 + 48.0,
f64::from(pos[2]) + 1.0 + 48.0,
];
let _ = write!(log, " gather {}", bx(inflated));
(0..entities.len() as u64)
.filter(|&i| {
let p = entities[i as usize].position;
(0..3).all(|a| p[a] >= inflated[a] && p[a] < inflated[a + 3])
})
.collect()
});
let _ = write!(log, " event {handled}");
}
if !step["noTick"].as_bool().unwrap_or(false) {
let tick = b.tick(pos, |h| entities.get(h as usize).copied());
if tick.resonate {
log.push_str(" sound minecraft:block.bell.resonate");
}
if let Some((particles, _)) = tick.ended {
for (p, color) in particles {
let _ = write!(
log,
" particle minecraft:entity_effect {color} {} {} {} {} {} {}",
d(p[0]),
d(p[1]),
d(p[2]),
d(0.0),
d(0.0),
d(0.0)
);
}
}
}
let b = &anims.bells[&pos];
let a = anims.block_anim(pos, time, partial, Default::default());
let dir = |d: Option<&str>| d.map_or("null".to_owned(), str::to_owned);
let _ = write!(
text,
"{} {} {} {} {} {} render {} {}",
b.ticks,
b.shaking,
dir(b.click_direction.map(|d| DIRECTIONS[usize::from(d)])),
b.resonating,
b.resonation_ticks,
b.last_ring_timestamp,
f(a.bell_ticks as f32 + partial),
dir(a.bell_shake)
);
} else if name == "minecraft:conduit" {
let c = anims.conduits.entry(pos).or_insert_with(Conduit::default);
let state_of = |p: [i32; 3]| -> Option<u32> {
find_state(
registry,
blocks.get(&p).map_or("minecraft:air", String::as_str),
)
};
let is_water = |p: [i32; 3]| {
state_of(p).is_some_and(|s| facts.get(BlockStateId(s)).fluid == Fluid::Water)
};
let is_frame = |p: [i32; 3]| {
let spec = blocks.get(&p).map_or("minecraft:air", String::as_str);
frames.contains(spec.split('[').next().unwrap_or_default())
};
let particles = c.client_tick(pos, time, is_water, is_frame, target, &mut random);
for (p, v) in particles {
let _ = write!(
log,
" particle minecraft:nautilus {} {} {} {} {} {}",
d(p[0]),
d(p[1]),
d(p[2]),
d(v[0]),
d(v[1]),
d(v[2])
);
}
let c = &anims.conduits[&pos];
let a = anims.block_anim(pos, time, partial, Default::default());
// ConduitRenderer.extractRenderState as block_entity_render derives it.
let (ticks, active, hunting, raw) = a.conduit;
let rotation = (raw + if active { partial } else { 0.0 }) * -0.0375;
let _ = write!(
text,
"{} {} {} {} {} render {} {} {} {} {}",
c.tick_count,
c.is_active,
c.is_hunting,
f(c.active_rotation),
c.effect_blocks.len(),
active,
f(rotation),
f(ticks as f32 + partial),
ticks / 66 % 3,
hunting
);
} else if name.ends_with("_head") || name.ends_with("_skull") {
let now = step["state"].as_str().unwrap_or(&block);
let s = anims.skulls.entry(pos).or_insert_with(Skull::default);
s.tick(prop(now, "powered") == Some("true"));
let s = anims.skulls[&pos];
let a = anims.block_anim(pos, time, partial, Default::default());
// block_entity_render: count + partial while animating.
let (animating, count) = a.skull;
let render = count as f32 + if animating { partial } else { 0.0 };
let _ = write!(
text,
"{} {} render {}",
s.animating,
s.tick_count,
f(render)
);
} else if name == "minecraft:decorated_pot" {
let w = anims.pots.entry(pos).or_insert_with(PotWobble::default);
if let Some((e0, e1)) = event {
let handled = w.trigger_event(e0, e1, time);
let _ = write!(log, " event {handled}");
}
let w = anims.pots[&pos];
let a = anims.block_anim(pos, time, partial, Default::default());
let (style, progress) = match a.wobble {
Some((true, p)) => ("POSITIVE", p),
Some((false, p)) => ("null", p),
None => ("null", 0.0),
};
let _ = write!(
text,
"{} {} render {} {}",
w.started_at,
match w.style {
Some(WobbleStyle::Positive) => "POSITIVE",
Some(WobbleStyle::Negative) => "NEGATIVE",
None => "null",
},
f(progress),
style
);
} else if name == "minecraft:end_gateway" {
let client = anims.gateways.entry(pos).or_insert(initial);
if step["enter"].as_bool() == Some(true) && server.enter() {
log.push_str(" blockEvent 1 0");
client.trigger_event(1, 0);
}
if server.portal_tick() {
log.push_str(" blockEvent 1 0");
client.trigger_event(1, 0);
}
if let Some((e0, e1)) = event {
let handled = client.trigger_event(e0, e1);
let _ = write!(log, " event {handled}");
}
client.tick();
let g = *client;
let a = anims.block_anim(pos, time, partial, Default::default());
let b = gateway_beam(a.gateway, partial, max_y, time);
let _ = write!(
text,
"{} {} server {} {} render {} {} {:x} {}",
g.age,
g.teleport_cooldown,
server.age,
server.teleport_cooldown,
b.height,
f(b.scale),
b.color,
f(b.animation_time)
);
} else {
text = format!("unsupported {block}");
}
writeln!(out, "{text} |{log}")?;
}
}
Ok(())
}
@@ -0,0 +1,430 @@
//! Headless transport for the block-entity render parity gate: replays each
//! case (block state, light, partial tick and ticked block-entity fields)
//! through `BlockEntityRenderer` at the block origin and writes the quads
//! (texture, sprite-local UVs, baked colour, light) and sign text calls.
#![forbid(unsafe_code)]
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_client::{
assets::Assets,
banner::{self, Banners},
block_entity_anim::EnchantingBook,
block_entity_render::{
BlockAnim, BlockEntityRenderer, END_GATEWAY_LAYER, END_PORTAL_LAYER, Scene, TextCall,
},
chunk::{Chunk, Light},
hud::{HudAtlas, Rect},
item_render::{ItemRenderer, gui},
signs::{DYE_TEXT, SignData, SignText},
world::World,
};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::{Value, json};
use std::{
cell::RefCell,
collections::HashMap,
fs,
io::{BufWriter, Write},
path::{Path, PathBuf},
sync::Arc,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
/// Every texture the vanilla oracle used gets its own unit square on a small
/// grid (offsets stay small so f32 UVs keep their precision), so a vertex's
/// texture and sprite-local UV can be recovered.
fn fake_sprites(list: &Path) -> Result<(HashMap<String, Rect>, Vec<String>, usize)> {
let names: Vec<String> = fs::read_to_string(list)?
.lines()
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect();
let grid = (names.len() as f64).sqrt().ceil().max(1.0) as usize;
let map = names
.iter()
.enumerate()
.map(|(i, n)| {
let (u0, v0) = ((i % grid) as f32 * 2.0, (i / grid) as f32 * 2.0);
(
n.clone(),
Rect {
u0,
v0,
u1: u0 + 1.0,
v1: v0 + 1.0,
w: 16,
h: 16,
},
)
})
.collect();
Ok((map, names, grid))
}
fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
let (name, props) = match spec.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (spec, ""),
};
let wanted: Vec<(&str, &str)> = props
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.collect();
let default = registry.state(registry.default_state(name)?)?.clone();
(0..registry.len() as u32).find(|&id| {
registry.state(BlockStateId(id)).is_some_and(|s| {
s.name == name
&& s.properties.iter().all(|(k, v)| {
wanted
.iter()
.find(|(wk, _)| wk == k)
.map_or(default.properties.get(k) == Some(v), |(_, wv)| wv == v)
})
})
})
}
fn sign_side(v: &Value) -> SignText {
let mut side = SignText::default();
for (line, text) in side
.lines
.iter_mut()
.zip(v["lines"].as_array().into_iter().flatten())
{
*line = text.as_str().unwrap_or_default().to_owned();
}
let color = v["color"].as_str().unwrap_or("black");
side.color = DYE_TEXT.iter().position(|(n, _)| *n == color).unwrap_or(15) as u8;
side.glowing = v["glowing"].as_bool().unwrap_or(false);
side
}
fn intern(s: &str) -> &'static str {
match s {
"north" => "north",
"south" => "south",
"west" => "west",
"east" => "east",
"up" => "up",
_ => "down",
}
}
fn main() -> Result<()> {
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let [root, inputs, textures, output] = args.as_slice() else {
return Err("usage: block_entity_render_fixture RESOURCES INPUTS TEXTURES OUTPUT".into());
};
let assets = Assets::load(root)?;
let registry = assets.registry;
let renderer = BlockEntityRenderer::load(root, registry)?;
let atlas = HudAtlas::build(root)?;
let banners = Banners::load(root);
let items = ItemRenderer::load(root)?;
let (sprites, names, grid) = fake_sprites(textures)?;
let air = registry.default_state("minecraft:air").ok_or("no air")?;
let mut out = BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(inputs)?
.lines()
.filter(|l| !l.is_empty())
{
let case: Value = serde_json::from_str(line)?;
let block = case["block"].as_str().unwrap_or_default();
if let Some(loom) = case.get("loom") {
// The loom's result preview, as LoomScreen draws it at (0, 0).
let layers: Vec<(String, String)> = loom["patterns"]
.as_array()
.into_iter()
.flatten()
.map(|p| {
(
banners.banner_texture(p[0].as_str().unwrap_or_default()),
p[1].as_str().unwrap_or_default().to_owned(),
)
})
.collect();
let base = loom["base"].as_str().unwrap_or_default();
let flat = gui::items_flat_lights();
let quads: Vec<Value> =
gui::banner_result_quads(&items, &sprites, 0.0, 0.0, base, &layers)
.iter()
.map(|q| {
let (cu, cv) = ((q.uv[0][0] / 2.0).floor(), (q.uv[0][1] / 2.0).floor());
let texture = names
.get(cu as usize + cv as usize * grid)
.cloned()
.unwrap_or_default();
let v: Vec<Value> = (0..4)
.map(|k| {
let p = q.pos[k];
json!([p.x, p.y, p.z, q.uv[k][0] - cu * 2.0, q.uv[k][1] - cv * 2.0])
})
.collect();
// Full bright (15728880).
json!({"texture": texture, "v": v, "color": gui::lit_color(q, flat), "light": 0xF0F0})
})
.collect();
writeln!(out, "{}", json!({"quads": quads, "texts": []}))?;
continue;
}
if block == "minecraft:beacon" {
// BeaconRenderer: the beam sections from the case, as Horizon's beacon keeps them.
let int = |v: &Value| v.as_i64().unwrap_or(0);
let pos = [
int(&case["pos"][0]),
int(&case["pos"][1]),
int(&case["pos"][2]),
];
let partial = case["partial"].as_f64().unwrap_or(0.0) as f32;
let time = int(&case["gameTime"]).rem_euclid(40) as f32 + partial;
let cam = &case["camera"];
let dx = cam[0].as_f64().unwrap_or(0.0) - (pos[0] as f64 + 0.5);
let dz = cam[2].as_f64().unwrap_or(0.0) - (pos[2] as f64 + 0.5);
let scale = ((dx * dx + dz * dz).sqrt() as f32 / 96.0).max(1.0);
let sections: Vec<(u32, i32)> = case["beam"]
.as_array()
.into_iter()
.flatten()
.map(|s| (int(&s[0]) as u32 & 0xFF_FFFF, int(&s[1]) as i32))
.collect();
let texture = "entity/beacon/beacon_beam";
let rect = sprites.get(texture).copied().ok_or("no beam sprite")?;
let mut verts = Vec::new();
horizon_client::block_entity_render::beacon_beam(
Vec3::ZERO,
&sections,
time,
scale,
rect,
&mut verts,
);
let quads: Vec<Value> = verts
.chunks(4)
.map(|q| {
let v: Vec<Value> = q
.iter()
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0] - rect.u0, v.uv[1]]))
.collect();
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light, "custom": true})
})
.collect();
writeln!(out, "{}", json!({"quads": quads, "texts": []}))?;
continue;
}
let Some(state) = find_state(registry, block) else {
writeln!(out, "{}", json!({"skip": "unknown block state"}))?;
continue;
};
if !renderer.renders(state) {
writeln!(out, "{}", json!({"skip": "no renderer"}))?;
continue;
}
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
let float = |v: &Value| v.as_f64().unwrap_or(0.0) as f32;
let pos = [
int(&case["pos"][0]),
int(&case["pos"][1]),
int(&case["pos"][2]),
];
let packed = (int(&case["light"][0]) << 4 | int(&case["light"][1])) as u8;
let mut world = World::new(air, 0);
for dx in -1..=1 {
for dz in -1..=1 {
let (cx, cz) = ((pos[0] >> 4) + dx, (pos[2] >> 4) + dz);
let mut chunk = Chunk::empty(cx, cz);
for section in &mut chunk.sections {
Arc::make_mut(section).light = Light::Uniform(packed);
}
world.chunks.insert((cx, cz), Arc::new(chunk));
}
}
// Neighbour blocks the case sets (End gateway faces).
for (direction, spec) in case["neighbours"].as_object().into_iter().flatten() {
let d = match direction.as_str() {
"down" => [0, -1, 0],
"up" => [0, 1, 0],
"north" => [0, 0, -1],
"south" => [0, 0, 1],
"west" => [-1, 0, 0],
_ => [1, 0, 0],
};
let neighbour = find_state(registry, spec.as_str().unwrap_or_default())
.ok_or("unknown neighbour state")?;
world.set(
pos[0] + d[0],
pos[1] + d[1],
pos[2] + d[2],
BlockStateId(neighbour),
)?;
}
let book = case
.get("book")
.map_or_else(EnchantingBook::default, |book| {
let [flip, o_flip, open, o_open, rot, o_rot] =
["flip", "oFlip", "open", "oOpen", "rot", "oRot"].map(|k| float(&book[k]));
EnchantingBook {
time: int(&book["time"]),
flip,
o_flip,
open,
o_open,
rot,
o_rot,
..EnchantingBook::default()
}
});
let sign = case.get("sign").map(|s| {
let mut data = SignData {
hanging: block.contains("hanging"),
..SignData::default()
};
data.front = sign_side(&s["front"]);
data.back = sign_side(&s["back"]);
data
});
let layers: Vec<(String, u32)> = case["patterns"]
.as_array()
.into_iter()
.flatten()
.map(|p| {
(
banners.banner_texture(p[0].as_str().unwrap_or_default()),
banner::dye_rgb(p[1].as_str().unwrap_or_default()),
)
})
.collect();
let anim = BlockAnim {
shulker: (float(&case["progress"]), float(&case["progressOld"])),
bell_ticks: int(&case["bellTicks"]),
bell_shake: case["shake"].as_str().map(intern),
conduit: (
int(&case["conduit"]["tickCount"]),
case["conduit"]["active"].as_bool().unwrap_or(false),
case["conduit"]["hunting"].as_bool().unwrap_or(false),
float(&case["conduit"]["activeRotation"]),
),
skull: (
case["skullAnimation"][0].as_bool().unwrap_or(false),
int(&case["skullAnimation"][1]),
),
sherds: [0, 1, 2, 3].map(|i| {
case["sherds"][i]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_owned)
}),
wobble: case["wobble"]
.as_array()
.map(|w| (w[0].as_str() == Some("POSITIVE"), float(&w[1]))),
book,
gateway: (
case["gateway"]["age"].as_i64().unwrap_or(0),
int(&case["gateway"]["cooldown"]),
),
};
// The case's level height (minimum y, height): the End's by default.
let max_y = match case["level"].as_array() {
Some(l) => int(&l[0]) + int(&l[1]) - 1,
None => 255,
};
let open = float(&case["open"]);
let near = case["sign"]["near"].as_bool().unwrap_or(false);
let camera = DVec3::new(
case["camera"][0].as_f64().unwrap_or(0.0),
case["camera"][1].as_f64().unwrap_or(0.0),
case["camera"][2].as_f64().unwrap_or(0.0),
);
// Signs pick the outline from the camera distance: put the camera where the case says.
let camera = if case.get("sign").is_some() {
let centre = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])) + 0.5;
centre
+ if near {
DVec3::ZERO
} else {
DVec3::new(20.0, 0.0, 0.0)
}
} else {
camera
};
let (yaw, pitch) = (float(&case["yaw"]), float(&case["pitch"]));
let texts = RefCell::new(Vec::<TextCall>::new());
let scene = Scene {
camera,
orientation: Quat::from_euler(
glam::EulerRot::YXZ,
std::f32::consts::PI - yaw * (std::f32::consts::PI / 180.0),
-pitch * (std::f32::consts::PI / 180.0),
0.0,
),
anim: &|_| anim.clone(),
texts: Some(&texts),
partial: float(&case["partial"]),
game_time: case["gameTime"].as_i64().unwrap_or(0),
max_y,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| open,
sign: &|_| sign.as_ref(),
banner: &|_| layers.clone(),
glyph: &|c| atlas.glyph_cell(c),
};
let mut verts = Vec::new();
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
let quads: Vec<Value> = verts
.chunks(4)
.map(|q| {
// Custom geometry: the End portal shader's position-only cube (its
// render type samples end_sky first) and End gateway beams.
if q[0].layer == END_PORTAL_LAYER || q[0].layer == END_GATEWAY_LAYER {
let v: Vec<Value> = q
.iter()
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0], v.uv[1]]))
.collect();
return json!({"texture": "environment/end_sky", "v": v, "color": q[0].color,
"light": q[0].light, "custom": true});
}
if q[0].layer == 6 {
let texture = "entity/end_portal/end_gateway_beam";
let u0 = sprites.get(texture).map_or(0.0, |r| r.u0);
let v: Vec<Value> = q
.iter()
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0] - u0, v.uv[1]]))
.collect();
return json!({"texture": texture, "v": v, "color": q[0].color,
"light": q[0].light, "custom": true});
}
let (cu, cv) = ((q[0].uv[0] / 2.0).floor(), (q[0].uv[1] / 2.0).floor());
let texture = names
.get(cu as usize + cv as usize * grid)
.cloned()
.unwrap_or_default();
let v: Vec<Value> = q
.iter()
.map(|v| {
json!([
v.pos[0],
v.pos[1],
v.pos[2],
v.uv[0] - cu * 2.0,
v.uv[1] - cv * 2.0
])
})
.collect();
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light})
})
.collect();
let texts: Vec<Value> = texts
.borrow()
.iter()
.filter(|t| !t.text.is_empty())
.map(|t| {
json!({"matrix": t.matrix.to_cols_array(), "x": t.x, "y": t.y, "text": t.text,
"color": t.color, "outline": t.outline,
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF))})
})
.collect();
writeln!(out, "{}", json!({"quads": quads, "texts": texts}))?;
}
Ok(())
}
@@ -0,0 +1,342 @@
#![forbid(unsafe_code)]
//! Runs the JSON-lines scenarios of tools/test_camera_parity.py through the
//! Rust camera, field-of-view, avatar bob and view/hurt bobbing code.
use horizon_client::{
Result,
camera::{
AvatarBob, BobState, Camera, CameraEntity, CameraFluid, CameraType, FovPlayer, HurtBob,
LivingCamera, UseItem, bob_matrix, field_of_view_modifier,
},
};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
};
use horizon_world::world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world};
use serde_json::Value;
use std::{
collections::HashMap,
convert::Infallible,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn fl(v: &Value) -> f32 {
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn db(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn fb(v: f32) -> String {
format!("{:08x}", if v.is_nan() { 0x7fc0_0000 } else { v.to_bits() })
}
fn dbs(v: f64) -> String {
format!(
"{:016x}",
if v.is_nan() {
0x7ff8_0000_0000_0000
} else {
v.to_bits()
}
)
}
fn b(v: &Value) -> bool {
v.as_bool().unwrap()
}
fn vec3(v: &Value) -> [f64; 3] {
[db(&v[0]), db(&v[1]), db(&v[2])]
}
fn fov_player(c: &Value) -> FovPlayer {
FovPlayer {
flying: b(&c["flying"]),
walking_speed: fl(&c["ws"]),
movement_speed: db(&c["speed"]),
using: c["use"].as_array().map(|u| {
let item = match u[0].as_str().unwrap() {
"bow" => UseItem::Bow,
"spyglass" => UseItem::Spyglass,
_ => UseItem::Other,
};
(item, u[1].as_i64().unwrap() as i32)
}),
}
}
/// A level of visual shapes keyed by cell, without fluids.
struct Cells {
shapes: Vec<VoxelShape>,
index: HashMap<BlockPos, usize>,
}
impl RayWorld for Cells {
type Error = Infallible;
type Block = usize;
type Fluid = ();
fn block_state(&mut self, p: BlockPos) -> std::result::Result<usize, Infallible> {
Ok(self.index.get(&p).copied().unwrap_or(0))
}
fn fluid_state(&mut self, _: BlockPos) -> std::result::Result<(), Infallible> {
Ok(())
}
fn block_shape(
&mut self,
s: usize,
_: BlockPos,
_: RayBlockMode,
) -> std::result::Result<VoxelShape, Infallible> {
Ok(self.shapes[s].clone())
}
fn interaction_shape(
&mut self,
_: usize,
_: BlockPos,
) -> std::result::Result<VoxelShape, Infallible> {
Ok(VoxelShape::empty())
}
fn fluid_is_source(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
Ok(false)
}
fn fluid_is_empty(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
Ok(true)
}
fn fluid_is_water(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
Ok(false)
}
fn fluid_shape(&mut self, _: (), _: BlockPos) -> std::result::Result<VoxelShape, Infallible> {
Ok(VoxelShape::empty())
}
}
fn cells(c: &Value) -> Result<Cells> {
let mut world = Cells {
shapes: vec![VoxelShape::empty()],
index: HashMap::new(),
};
for (key, boxes) in c["cells"].as_object().unwrap() {
let at: Vec<i32> = key.split(' ').map(|v| v.parse().unwrap()).collect();
let mut shape = VoxelShape::empty();
for a in boxes.as_array().unwrap() {
shape = shape.join(
&VoxelShape::from_box(Aabb::new(
Vec3::new(db(&a[0]), db(&a[1]), db(&a[2])),
Vec3::new(db(&a[3]), db(&a[4]), db(&a[5])),
))?,
ShapeOp::OR,
)?;
}
world
.index
.insert(BlockPos::new(at[0], at[1], at[2]), world.shapes.len());
world.shapes.push(shape);
}
Ok(world)
}
fn camera_type(v: &Value) -> CameraType {
[
CameraType::FirstPerson,
CameraType::ThirdPersonBack,
CameraType::ThirdPersonFront,
][v.as_u64().unwrap() as usize]
}
/// The camera entity and, for players, what the field of view reads.
fn entity(s: &Value) -> (CameraEntity, Option<FovPlayer>) {
let player = s["kind"] == "player";
let living = player.then(|| LivingCamera {
scale: db(&s["scale_attr"]) as f32,
camera_distance: db(&s["dist"]),
sleeping: s["sleeping"].as_array().map(|a| {
a[0].as_u64()
.map(|d| [0.0f32, 90.0, 180.0, 270.0][d as usize])
}),
dead: s["dead"].as_i64().map(|t| t as i32),
});
let mount = if player {
s["mount"].as_array().map(|a| (db(&a[0]) as f32, db(&a[1])))
} else {
None
};
(
CameraEntity {
position: vec3(&s["pos"]),
previous: vec3(&s["prev"]),
x_rot: fl(&s["xr"]),
y_rot: fl(&s["yr"]),
x_rot_o: fl(&s["xro"]),
y_rot_o: fl(&s["yro"]),
eye_height: fl(&s["eye"]),
living,
mount,
},
player.then(|| fov_player(s)),
)
}
fn camera(c: &Value, out: &mut impl Write) -> Result<()> {
let mut world = cells(c)?;
let fov_option = c["fov"].as_i64().unwrap() as i32;
let effect = db(&c["effect"]) as f32;
let mut kind = camera_type(&c["type"]);
let mut camera = Camera::default();
let mut current: Option<(CameraEntity, Option<FovPlayer>)> = None;
for op in c["ops"].as_array().unwrap() {
match op[0].as_str().unwrap() {
"entity" => {
current = Some(entity(&op[1]));
writeln!(out, "entity")?;
}
"type" => {
kind = camera_type(&op[1]);
writeln!(out, "type")?;
}
"tick" => {
let target = current
.as_ref()
.and_then(|(_, p)| p.as_ref())
.map_or(1.0, |p| {
field_of_view_modifier(p, kind.is_first_person(), effect)
});
camera.tick(current.as_ref().map(|(e, _)| e.eye_height), target);
writeln!(
out,
"tick {} {} {} {}",
fb(camera.eye_height),
fb(camera.eye_height_old),
fb(camera.fov_modifier),
fb(camera.old_fov_modifier)
)?;
}
"frame" => {
let pt = fl(&op[1]);
let fluid = [
CameraFluid::None,
CameraFluid::Water,
CameraFluid::Lava,
CameraFluid::PowderSnow,
][op[2].as_u64().unwrap() as usize];
let panoramic = b(&op[3]);
let (e, _) = current.as_ref().expect("entity");
camera.align_with_entity(e, pt, kind, |from, to| {
let Ok(hit) = clip_world(
&mut world,
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
RayBlockMode::Visual,
RayFluidMode::None,
usize::MAX,
) else {
panic!("ray")
};
(!hit.miss).then(|| {
let l = hit.hit.location;
[l.x, l.y, l.z]
})
});
let dead = e.living.and_then(|l| l.dead);
let fov = camera.calculate_fov(pt, fov_option, effect, dead, fluid, panoramic);
let hud = camera.calculate_hud_fov(pt, effect, dead, fluid);
let p = camera.position;
let q = camera.rotation;
let v = |a: [f32; 3]| format!("{} {} {}", fb(a[0]), fb(a[1]), fb(a[2]));
writeln!(
out,
"frame {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
dbs(p[0]),
dbs(p[1]),
dbs(p[2]),
fb(camera.x_rot),
fb(camera.y_rot),
fb(q.x),
fb(q.y),
fb(q.z),
fb(q.w),
v(camera.forwards),
v(camera.up),
v(camera.left),
camera.detached,
fb(fov),
fb(hud)
)?;
}
other => panic!("{other}"),
}
}
Ok(())
}
fn bob(c: &Value, out: &mut impl Write) -> Result<()> {
let state = BobState {
living: (!c["living"].is_null()).then(|| {
let l = &c["living"];
HurtBob {
hurt_time: fl(&l["hurt"]),
hurt_duration: l["duration"].as_i64().unwrap() as i32,
hurt_dir: fl(&l["dir"]),
death_time: (!l["death"].is_null()).then(|| fl(&l["death"])),
}
}),
player: c["player"].as_array().map(|p| (fl(&p[0]), fl(&p[1]))),
};
let m = bob_matrix(&state, db(&c["tilt"]), b(&c["view"]));
let mut line = String::from("bob");
for v in m.m {
line.push(' ');
line.push_str(&fb(v));
}
writeln!(out, "{line} {}", m.properties)?;
Ok(())
}
fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
let mut state = AvatarBob::default();
for op in c["ops"].as_array().unwrap() {
let name = op[0].as_str().unwrap();
match name {
"tick" => state.tick(),
"move" => state.add_walked(fl(&op[1]), fl(&op[2])),
"bob" => state.update_bob(b(&op[1]) && !b(&op[2]) && !b(&op[3]), vec3(&op[4])),
"reset" => state.reset_bob(),
"frame" => {
let pt = fl(&op[1]);
writeln!(
out,
"frame {} {}",
fb(state.interpolated_bob(pt)),
fb(state.backwards_interpolated_walk_distance(pt))
)?;
continue;
}
other => panic!("{other}"),
}
writeln!(out, "{name}")?;
}
Ok(())
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
match c["t"].as_str().unwrap() {
"fov" => writeln!(
out,
"{}",
fb(field_of_view_modifier(
&fov_player(&c),
b(&c["fp"]),
fl(&c["scale"])
))
)?,
"camera" => camera(&c, &mut out)?,
"bob" => bob(&c, &mut out)?,
"avatar" => avatar(&c, &mut out)?,
other => panic!("{other}"),
}
}
out.flush()?;
Ok(())
}
@@ -80,10 +80,13 @@ fn main() -> horizon_client::Result<()> {
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
// The inventory menu never reads banner patterns.
let banners = horizon_client::banner::Banners::default();
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
};
let mut out = String::new();
for line in input.lines() {
@@ -54,11 +54,14 @@ fn parser_id(ty: &ArgType) -> String {
ArgType::ResOrTagKey(_) => "minecraft:resource_or_tag_key".into(),
ArgType::StrEnum(values, _) => match values.first().copied() {
Some("world_surface") => "minecraft:heightmap".into(),
Some("none") if values.len() == 3 && values[1] == "left_right" => "minecraft:template_mirror".into(),
Some("none") if values.len() == 3 && values[1] == "left_right" => {
"minecraft:template_mirror".into()
}
Some("none") if values.len() == 3 => "minecraft:swing_animation".into(),
_ => "minecraft:template_rotation".into(),
},
ArgType::HexColor => "minecraft:hex_color".into(),
ArgType::ResourceSelector(_) => "minecraft:resource_selector".into(),
ArgType::Uuid => "minecraft:uuid".into(),
ArgType::GameProfile => "minecraft:game_profile".into(),
ArgType::Style => "minecraft:style".into(),
@@ -118,7 +121,8 @@ fn tree(d: &Dispatcher, node: usize) -> Value {
}
children.insert(child.name().to_owned(), v);
}
let mut v = json!({"children": children});
let order: Vec<&str> = n.children.iter().map(|&c| d.nodes[c].name()).collect();
let mut v = json!({"children": children, "order": order});
if n.command.is_some() {
v["executable"] = json!(true);
}
@@ -194,10 +198,12 @@ fn main() -> horizon_client::Result<()> {
reg.persistent_components = strings(&r["persistent_components"]);
reg.learnable_recipes = strings(&r["learnable_recipes"]);
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
reg.clock_time_markers.insert(clock.clone(), strings(markers));
reg.clock_time_markers
.insert(clock.clone(), strings(markers));
}
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
reg.advancement_criteria.insert(id.clone(), strings(criteria));
reg.advancement_criteria
.insert(id.clone(), strings(criteria));
}
reg.datapacks_enabled = strings(&r["datapacks_selected"]);
let enabled = reg.datapacks_enabled.clone();
@@ -8,7 +8,7 @@ use horizon_client::mobs::{
anim::{self, Poses, State},
aquatic, body_anim as b_anim, equine, guardian,
keyframes::AnimationState,
shulker, species, tame,
shulker, sniffer, species, tame,
};
use horizon_core::geometry::Vec3;
use serde_json::{Value, json};
@@ -173,6 +173,56 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
mob.anim.states[i].started = case[k].as_f64().map(|v| v as f32);
}
}
Species::Breeze => {
// breeze::anim's state slots.
for (i, k) in ["idle", "shoot", "slide", "slideBack", "inhale", "longJump"]
.into_iter()
.enumerate()
{
mob.anim.states[i].started = case[k].as_f64().map(|v| v as f32);
}
}
Species::Sniffer => {
if flag("isSearching") {
mob.extra.variant = sniffer::SEARCHING;
}
// Sniffer's animation states, in `sniffer::anim`'s order.
for (i, k) in [
"feelingHappyAnimationState",
"scentingAnimationState",
"sniffingAnimationState",
"diggingAnimationState",
"risingAnimationState",
]
.into_iter()
.enumerate()
{
mob.anim.states[i].started = case[k].as_f64().map(|v| v as f32);
}
}
Species::HappyGhast => {
mob.extra.head = case["bodyItem"].as_str().map(Into::into);
if flag("isRidden") {
mob.rider = Some(Default::default());
}
}
Species::Warden => {
use horizon_client::mobs::warden;
mob.anim.v[warden::TENDRIL] = num("tendrilAnimation") * 10.0;
mob.anim.vo[warden::TENDRIL] = num("tendrilAnimation") * 10.0;
mob.anim.v[warden::HEART] = num("heartAnimation") * 10.0;
mob.anim.vo[warden::HEART] = num("heartAnimation") * 10.0;
for (i, k) in [
(warden::ROAR, "roarAnimationState"),
(warden::SNIFF, "sniffAnimationState"),
(warden::EMERGE, "emergeAnimationState"),
(warden::DIGGING, "diggingAnimationState"),
(warden::ATTACK, "attackAnimationState"),
(warden::SONIC_BOOM, "sonicBoomAnimationState"),
] {
mob.anim.states[i].started = case[k].as_f64().map(|v| v as f32);
}
}
Species::Witch if flag("isHoldingItem") => {
mob.extra.main_hand = Some("minecraft:potion".into());
}
@@ -215,6 +265,13 @@ fn main() -> horizon_client::Result<()> {
base = base.replace(cut, "");
}
let base = base.replace("undead_horse#", "zombie_horse#");
// The breeze's wind and eyes and the creaking's eyes are layers of
// the species' own model class; LlamaDecorLayer's model animates like
// the llama.
let base = base
.replace("#wind", "#main")
.replace("#eyes", "#main")
.replace("#decor", "#main");
let base = base.trim_start_matches("cold_").trim_start_matches("warm_");
let Some(def) = species::ALL.iter().find(|d| {
d.model == layer
@@ -222,6 +279,11 @@ fn main() -> horizon_client::Result<()> {
|| d.model == base
|| d.model.trim_end_matches("#main") == base.trim_end_matches("#main")
|| norm(d.model) == base
// The warden's emissive layers are its model over other parts.
|| (["#tendrils", "#heart", "#bioluminescent", "#pulsating_spots"]
.iter()
.any(|s| layer.ends_with(s))
&& d.model == format!("{}#main", layer.split('#').next().unwrap_or_default()))
}) else {
out.push_str(&format!("{}\n", json!({"skip": "no species"})));
continue;
@@ -370,6 +432,22 @@ fn main() -> horizon_client::Result<()> {
},
&animations,
),
Species::CopperGolem => {
// CopperGolemRenderState's animation states, in the golem's slots.
for (slot, key) in [
"idleAnimationState",
"interactionGetItem",
"interactionGetNoItem",
"interactionDropItem",
"interactionDropNoItem",
]
.into_iter()
.enumerate()
{
mob.anim.states[slot] = started(key);
}
horizon_client::mobs::copper_golem::anim(&mob, s, p, &animations);
}
Species::Rabbit => b_anim::rabbit(
p,
s,
@@ -0,0 +1,278 @@
//! Entity death drops for differential tests against vanilla
//! (tools/test_entity_death_loot_parity.py): reads the cases of
//! `VanillaEntityDeathLootOracle` and prints what `mobs::drops::die` drops.
//!
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
random_sequences::RandomSequences,
};
use horizon_core::{geometry::Vec3, random::LegacyRandom};
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack};
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
/// `id[;enchantment:level...]`.
fn stack(spec: &str) -> LootStack {
let mut parts = spec.split(';');
let mut s = LootStack {
item: parts.next().unwrap_or("minecraft:air").to_owned(),
count: 1,
damage: 0,
enchantments: Vec::new(),
potion: None,
map: false,
ominous_amplifier: None,
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
s.enchantments.push((name, level.parse().unwrap_or(1)));
}
s
}
fn line(s: &LootStack) -> String {
let mut out = format!("drop {} {}", s.item, s.count);
if s.damage != 0 {
let _ = write!(out, " damage={}", s.damage);
}
for (e, l) in &s.enchantments {
let _ = write!(out, " ench={e}:{l}");
}
if let Some(p) = &s.potion {
let _ = write!(out, " potion={p}");
}
if let Some(a) = s.ominous_amplifier {
let _ = write!(out, " ominous={a}");
}
out
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(
args.get(1)
.ok_or("usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let types = MobTypes::load(root)?;
let loot = LootData::load(root)?;
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() || raw.starts_with('#') {
continue;
}
let mut c = BTreeMap::new();
let mut name = "";
for token in raw.split_whitespace() {
match token.split_once('=') {
Some((k, v)) => {
c.insert(k, v);
}
None => name = token,
}
}
let get =
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
let _ = writeln!(out, "== {name}");
let Some(kind) = types.types.get(c["type"]) else {
let _ = writeln!(out, "error unsupported {}", c["type"]);
continue;
};
let kind = Arc::new(kind.clone());
let world_seed: i64 = get("seed", "0").parse()?;
let kills: i64 = get("kills", "1").parse()?;
let rand: i64 = get("rand", "0").parse()?;
let mut sequences = RandomSequences::default();
for k in 0..kills {
let _ = writeln!(out, "kill {k}");
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
// EntityType.create(LOAD) gives no equipment.
for slot in drops::SLOTS {
drops::clear_slot(&mut m, slot);
}
if get("baby", "0") == "1" && drops::can_be_baby(m.species()) {
m.extra.age = -24000;
}
if get("fire", "0") == "1" {
m.fire_ticks = 100;
}
// A cube made with EntityType.create(LOAD) was never sized.
if matches!(m.species(), Species::Slime | Species::MagmaCube) {
m.extra.size = 0;
}
if let Some(size) = c.get("size") {
let size: i32 = size.parse()?;
if m.species() == Species::SulfurCube {
sulfur_cube::set_size(&mut m, size, true);
} else {
cube::set_size(&mut m, size);
}
}
if let Some(color) = c.get("color") {
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
}
m.extra.sheared = get("sheared", "0") == "1";
if get("leader", "0") == "1" {
raider::state_mut(&mut m).patrol_leader = true;
}
if get("captain", "0") == "1" {
m.extra.head = Some(Arc::from(raider::BANNER));
}
let mut chances = [drops::DEFAULT_DROP_CHANCE; 8];
for (i, slot) in drops::SLOTS.iter().enumerate() {
if let Some(spec) = c.get(slot) {
let s = stack(spec);
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
let index = match *slot {
"mainhand" => {
m.extra.main_hand = item;
Some(gear::MAIN)
}
"offhand" => {
m.extra.off_hand = item;
Some(gear::OFF)
}
"head" => {
m.extra.head = item;
Some(gear::HEAD)
}
"body" if drops::head_is_body(m.species()) => {
m.extra.head = item;
None
}
"saddle" => {
m.set_saddle(Some(horizon_client::items::ItemStack::new(&s.item, 1)));
None
}
"chest" => {
m.extra.chest = item;
Some(gear::CHEST)
}
"legs" => {
m.extra.legs = item;
Some(gear::LEGS)
}
"feet" => {
m.extra.feet = item;
Some(gear::FEET)
}
_ => None,
};
if let Some(index) = index
&& !s.enchantments.is_empty()
{
let levels = s
.enchantments
.iter()
.map(|(e, l)| (Arc::from(e.as_str()), *l as u32))
.collect();
gear::set(&mut m, index, levels);
}
}
if let Some(chance) = c.get(format!("chance_{slot}").as_str()) {
chances[i] = chance.parse()?;
}
}
m.extra.drop_chances = Some(Box::new(chances));
m.random = LegacyRandom::new(rand + k);
// The killer, the last player to hurt the victim and the damage source.
let killer_id = get("killer", "none");
let weapon = c.get("weapon").map(|w| stack(w));
let armed = |kind: &str| {
let mut e = LootEntity::new(kind);
if let Some(w) = &weapon {
e.equipment.push(("mainhand".into(), w.clone()));
}
e
};
let player = (killer_id == "player" || get("pkill", "0") == "1").then(|| {
if killer_id == "player" {
armed("minecraft:player")
} else {
LootEntity::new("minecraft:player")
}
});
let killer = match killer_id.as_str() {
"player" => player.clone(),
"none" => None,
id => {
let mut e = armed(id);
e.frog_variant = c.get("frog").map(|f| f.to_string());
e.powered = get("charged", "0") == "1";
Some(e)
}
};
let pkill = get("pkill", if killer_id == "player" { "1" } else { "0" }) == "1";
let projectile = |kind: &str| LootEntity {
kind: kind.to_owned(),
..LootEntity::default()
};
let damage =
|kind: &str, source: Option<LootEntity>, direct: Option<LootEntity>| LootDamage {
kind: kind.to_owned(),
source,
direct,
};
let source = match get("source", "attack").as_str() {
"attack" => match &killer {
None => damage("minecraft:generic", None, None),
Some(k) if k.kind == "minecraft:player" => {
damage("minecraft:player_attack", Some(k.clone()), Some(k.clone()))
}
Some(k) => damage("minecraft:mob_attack", Some(k.clone()), Some(k.clone())),
},
"arrow" => damage(
"minecraft:arrow",
killer.clone(),
Some(projectile("minecraft:arrow")),
),
"fireball" => match &killer {
Some(k) => damage(
"minecraft:fireball",
Some(k.clone()),
Some(projectile("minecraft:fireball")),
),
None => damage(
"minecraft:unattributed_fireball",
Some(projectile("minecraft:fireball")),
Some(projectile("minecraft:fireball")),
),
},
"explosion" => match &killer {
Some(k) => damage(
"minecraft:player_explosion",
Some(k.clone()),
Some(k.clone()),
),
None => damage("minecraft:explosion", None, None),
},
"lightning" => damage("minecraft:lightning_bolt", None, None),
"fire" => damage("minecraft:on_fire", None, None),
_ => damage("minecraft:generic", None, None),
};
let death = drops::Death {
loot: &loot,
damage: Some(source),
player: player.filter(|_| pkill),
luck: get("luck", "0").parse()?,
vehicle: c.get("vehicle").map(|v| LootEntity::new(v)),
mob_drops: true,
world_seed,
};
let (items, _) = drops::die(&mut m, &death, &mut sequences);
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
};
}
}
}
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -0,0 +1,107 @@
#![forbid(unsafe_code)]
//! Runs the JSON-lines scenarios of tools/test_frame_limiter_parity.py through
//! the Rust `FramerateLimiter` with the same scripted virtual clock.
use horizon_client::{
Result,
window::{FramerateLimiter, LimiterClock, end_frame},
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
/// The scripted clock of the case: every query costs `call` ns, parking
/// sleeps the request plus the next overshoot (at least 1 ns), spinning
/// costs `spin` ns and interruptions follow their script.
struct Script {
now: i64,
call: i64,
spin: i64,
overshoot: Vec<i64>,
next_overshoot: usize,
interrupts: Vec<bool>,
next_interrupt: usize,
parks: Vec<i64>,
spins: u64,
queries: u64,
}
impl LimiterClock for Script {
fn nanos(&mut self) -> i64 {
self.queries += 1;
let t = self.now;
self.now = self.now.wrapping_add(self.call);
t
}
fn park(&mut self, nanos: i64) {
self.parks.push(nanos);
let o = self.overshoot[self.next_overshoot % self.overshoot.len()];
self.next_overshoot += 1;
self.now = self.now.wrapping_add(nanos.max(0).wrapping_add(o).max(1));
}
fn spin(&mut self) {
self.spins += 1;
self.now = self.now.wrapping_add(self.spin);
}
fn interrupted(&mut self) -> bool {
let i = self.interrupts[self.next_interrupt % self.interrupts.len()];
self.next_interrupt += 1;
i
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let i = |k: &str| c[k].as_i64().unwrap();
let mut clock = Script {
now: i("now"),
call: i("call"),
spin: i("spin"),
overshoot: c["overshoot"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_i64().unwrap())
.collect(),
next_overshoot: 0,
interrupts: c["interrupts"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_bool().unwrap())
.collect(),
next_interrupt: 0,
parks: Vec::new(),
spins: 0,
queries: 0,
};
let mut limiter =
FramerateLimiter::with_state(i("last"), i("average"), i("last_limit") as i32);
for frame in c["frames"].as_array().unwrap() {
clock.now = clock.now.wrapping_add(frame[0].as_i64().unwrap());
clock.parks.clear();
clock.spins = 0;
clock.queries = 0;
end_frame(&mut limiter, frame[1].as_i64().unwrap() as i32, &mut clock);
let parks: Vec<String> = clock.parks.iter().map(i64::to_string).collect();
writeln!(
out,
"frame {} {} {} {} queries {} spins {} parks [{}]",
clock.now,
limiter.last_frame_time(),
limiter.average_overshoot_ns(),
limiter.last_framerate_limit(),
clock.queries,
clock.spins,
parks.join(",")
)?;
}
}
out.flush()?;
Ok(())
}
@@ -0,0 +1,270 @@
//! Headless transport for the HUD parity gate: replays each case through
//! `horizon_client::hud_layout` and writes the draw calls in vanilla's
//! GuiGraphicsExtractor terms. Case kinds: `decorations` (crosshair,
//! hotbar, health, mount hearts, experience bar and level) and the
//! overlays handled in `kinds`.
#![forbid(unsafe_code)]
use horizon_client::{
hud::{HudAtlas, HudBatch},
hud_layout::{self, GuiDraw, HealthHud, HealthInputs, HeartKind, HotbarInputs, IDENTITY, Pose},
lang,
text::Text,
};
use serde_json::{Value, json};
use std::{
fs,
io::{BufWriter, Write},
path::PathBuf,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[path = "hud_fixture/kinds.rs"]
mod kinds;
fn main() -> Result<()> {
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let [root, inputs, output] = args.as_slice() else {
return Err("usage: hud_fixture RESOURCES INPUTS OUTPUT".into());
};
let atlas = HudAtlas::build(root)?;
lang::set(lang::Language::load(&[root], lang::DEFAULT));
let mut out = BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(inputs)?
.lines()
.filter(|l| !l.is_empty())
{
let c: Value = serde_json::from_str(line)?;
// State the case reads back besides the draws (e.g. `scopeScale`).
let mut state = serde_json::Map::new();
let draws = match c["kind"].as_str().unwrap_or("decorations") {
"decorations" => decorations(&c, &atlas),
kind => kinds::run(kind, &c, &atlas, &mut state),
};
state.insert("draws".into(), json!(encode(&draws)));
writeln!(out, "{}", Value::Object(state))?;
}
Ok(())
}
/// Style runs of styled text: `[text, rgb|null, bold, italic]` per run of
/// equally styled characters, or none for plain text.
fn runs(t: &Text) -> Option<Value> {
let mut out: Vec<(String, Option<u32>, bool, bool)> = Vec::new();
let mut styled = false;
for (ch, s) in t.chars() {
let key = (s.color, s.is_bold(), s.is_italic());
styled |= key != (None, false, false);
match out.last_mut() {
Some(last) if (last.1, last.2, last.3) == key => last.0.push(ch),
_ => out.push((ch.to_string(), key.0, key.1, key.2)),
}
}
styled.then(|| {
json!(
out.into_iter()
.map(|r| json!([r.0, r.1, r.2, r.3]))
.collect::<Vec<_>>()
)
})
}
fn encode(draws: &[GuiDraw]) -> Vec<Value> {
let mut stack: Vec<Pose> = vec![IDENTITY];
let mut out = Vec::new();
for d in draws {
let pose = *stack.last().unwrap();
let mut v = match d {
GuiDraw::Push(ops) => {
let mut m = pose;
for op in ops {
hud_layout::pose_apply(&mut m, *op);
}
stack.push(m);
continue;
}
GuiDraw::Pop => {
stack.pop();
continue;
}
GuiDraw::Sprite {
sprite,
x,
y,
w,
h,
region,
color,
} => {
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region, "color": *color as i32})
}
GuiDraw::AtlasSprite {
sprite,
x,
y,
w,
h,
color,
} => {
json!({"type": "atlas", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "color": *color as i32})
}
GuiDraw::Blit {
texture,
blend,
x,
y,
w,
h,
uv,
src,
tex,
color,
} => json!({
"type": "blit", "texture": format!("textures/{texture}.png"), "pipeline": blend.pipeline(),
"x": x, "y": y, "w": w, "h": h, "uv": uv, "src": src, "tex": tex, "color": *color as i32}),
GuiDraw::Fill {
x0,
y0,
x1,
y1,
color,
} => {
json!({"type": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1, "color": *color as i32})
}
GuiDraw::Text {
text,
x,
y,
color,
shadow,
} => {
let mut v = json!({"type": "text", "text": text.plain(), "x": x, "y": y, "color": *color as i32, "shadow": shadow});
if let Some(r) = runs(text) {
v["runs"] = r;
}
v
}
GuiDraw::Item {
slot,
x,
y,
seed,
scale,
pivot,
} => {
// translate(pivot) * scale * translate(-pivot) as a 3x2 matrix.
let m = [
scale[0],
0.0,
0.0,
scale[1],
pivot[0] - pivot[0] * scale[0],
pivot[1] - pivot[1] * scale[1],
];
out.push(json!({"type": "item", "slot": slot, "x": x, "y": y, "seed": seed, "matrix": m}));
continue;
}
};
if pose != IDENTITY {
v["matrix"] = json!(pose);
}
out.push(v);
}
out
}
fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
let int = |k: &str, d: i64| c[k].as_i64().unwrap_or(d) as i32;
let float = |k: &str, d: f64| c[k].as_f64().unwrap_or(d) as f32;
let (w, h) = (int("width", 0), int("height", 0));
let mode = c["gameMode"].as_str().unwrap_or("survival");
let survival = mode == "survival" || mode == "adventure";
let indicator = c["attackIndicator"].as_str().unwrap_or("OFF");
let partial = float("partial", 0.0);
let effects: Vec<&str> = c["effects"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
let has = |e: &str| effects.contains(&e);
let mut draws = Vec::new();
// Spectators only see it over a spectatable target (none here).
if c["crosshair"].as_bool().unwrap_or(false) && mode != "spectator" {
let attack = (indicator == "CROSSHAIR").then(|| float("attackScale", 1.0));
draws.extend(hud_layout::crosshair(w, h, attack, false));
}
if mode != "spectator" {
let pop = |v: &Value| v.as_f64().map(|p| p as f32 - partial);
let slots: [Option<f32>; 9] = std::array::from_fn(|i| pop(&c["slots"][i]));
let inputs = HotbarInputs {
selected: int("selected", 0),
right_handed: c["rightHanded"].as_bool().unwrap_or(true),
offhand: !c["offhand"].is_null(),
slots,
offhand_pop: pop(&c["offhand"]).unwrap_or(0.0),
attack: (indicator == "HOTBAR").then(|| float("attackScale", 1.0)),
};
draws.extend(hud_layout::hotbar(w, h, &inputs));
}
let vehicle_hearts = if c.get("vehicleMaxHealth").is_some() {
hud_layout::vehicle_max_hearts(float("vehicleMaxHealth", 0.0))
} else {
0
};
if survival {
let millis = 1_000_000_000i64;
let mut hud = HealthHud {
last_health: int("lastHealth", 0),
display_health: int("displayHealth", 0),
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
health_blink_time: i64::from(int("healthBlinkTime", 0)),
};
let kind = if has("minecraft:poison") {
HeartKind::Poisoned
} else if has("minecraft:wither") {
HeartKind::Withered
} else if c["frozen"].as_bool().unwrap_or(false) {
HeartKind::Frozen
} else {
HeartKind::Normal
};
let inputs = HealthInputs {
tick: int("tick", 0),
millis,
health: float("health", 20.0),
max_health: float("maxHealth", 20.0),
absorption: float("absorption", 0.0),
armor: int("armor", 0),
food: int("food", 20),
saturation: float("saturation", 5.0),
kind,
regeneration: has("minecraft:regeneration"),
hunger: has("minecraft:hunger"),
hardcore: c["hardcore"].as_bool().unwrap_or(false),
damage_cooldown: int("damageCooldown", 0) > 0,
air: int("air", 300),
max_air: int("maxAir", 300),
under_water: c["underWater"].as_bool().unwrap_or(false),
vehicle_hearts,
};
draws.extend(hud.layout(&inputs, w, h));
}
if vehicle_hearts > 0 {
draws.extend(hud_layout::vehicle_health(
w,
h,
vehicle_hearts,
float("vehicleHealth", 0.0),
));
}
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
let level = if survival { int("xpLevel", 0) } else { 0 };
draws.extend(hud_layout::experience(
w,
h,
float("xpProgress", 0.0),
level,
c["experienceBar"].as_bool().unwrap_or(false),
&width,
));
draws
}
@@ -0,0 +1,326 @@
//! The overlay case kinds of the HUD parity fixture.
use horizon_client::{
brewing,
chat::{Chat, ChatOptions},
component,
hud::HudAtlas,
hud_layout::{self, GuiDraw},
hud_overlays::{
self, BossBar, CameraOverlays, EffectIcon, HeldName, ItemDecorations, Titles, ToolHighlight,
},
scoreboard::{NumberFormat, RenderType, Scoreboard},
text::{self, Text},
};
use serde_json::Value;
fn int(c: &Value, k: &str, d: i64) -> i32 {
c[k].as_i64().unwrap_or(d) as i32
}
fn float(c: &Value, k: &str, d: f64) -> f32 {
c[k].as_f64().unwrap_or(d) as f32
}
/// A text component in its JSON form.
fn comp(v: &Value) -> Text {
component::to_text(v, &mut |s| Text::literal(s))
}
/// `Options.getBackgroundColor(0.0F)` from the case's text background options.
fn background(c: &Value) -> u32 {
hud_layout::background_color(
c["chatOnly"].as_bool().unwrap_or(true),
c["textOpacity"].as_f64().unwrap_or(0.5),
0.0,
)
}
pub fn run(
kind: &str,
c: &Value,
atlas: &HudAtlas,
state: &mut serde_json::Map<String, Value>,
) -> Vec<GuiDraw> {
let (w, h) = (int(c, "width", 0), int(c, "height", 0));
match kind {
"effects" => effects(c, w),
"title" => title(c, w, h, atlas),
"selected" => selected(c, w, h, atlas),
"boss" => boss(c, w, h, atlas),
"jump" => hud_overlays::jump_bar(w, h, int(c, "cooldown", 0), float(c, "jumpScale", 0.0)),
"sidebar" => sidebar(c, w, h, atlas),
"decor" => decor(c, atlas),
"overlays" => overlays(c, w, h, state),
"chat" => chat(c, h, atlas),
_ => Vec::new(),
}
}
fn effects(c: &Value, w: i32) -> Vec<GuiDraw> {
let icons: Vec<EffectIcon> = c["active"]
.as_array()
.into_iter()
.flatten()
.map(|e| {
let id = e["id"]
.as_str()
.unwrap_or("")
.trim_start_matches("minecraft:");
let def = brewing::effect(id).expect("effect");
EffectIcon {
id: id.to_owned(),
beneficial: def.category == brewing::Category::Beneficial,
color: def.color as i32,
ambient: e["ambient"].as_bool().unwrap_or(false),
duration: e["duration"].as_i64().unwrap_or(0) as i32,
show_icon: e["icon"].as_bool().unwrap_or(true),
}
})
.collect();
hud_overlays::effects(w, c["demo"].as_bool().unwrap_or(false), &icons)
}
/// Replays `Hud.setTitle` / `setSubtitle` / `setTimes` / `clearTitles` /
/// `resetTitleTimes` / `setOverlayMessage` and ticks, then extracts the
/// action bar and the title.
fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
let mut t = Titles::default();
for op in c["ops"].as_array().into_iter().flatten() {
let n = |i: usize| op[i].as_i64().unwrap_or(0) as i32;
match op[0].as_str().unwrap_or("") {
"title" => t.set_title(comp(&op[1])),
"subtitle" => t.set_subtitle(comp(&op[1])),
"times" => t.set_times(n(1), n(2), n(3)),
"clear" => t.clear(),
"reset" => t.reset_times(),
"overlay" => t.set_overlay(comp(&op[1]), op[2].as_bool().unwrap_or(false)),
"tick" => {
for _ in 0..n(1) {
t.tick();
}
}
other => panic!("unknown title op {other}"),
}
}
let partial = float(c, "partial", 0.0);
let bg = background(c);
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
out.extend(t.title(w, h, partial, &atlas.font, bg));
out
}
/// Ticks `toolHighlightTimer` through the selected stacks, then extracts the name.
fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
let mut highlight = ToolHighlight::default();
let time = c["displayTime"].as_f64().unwrap_or(1.0);
for step in c["steps"].as_array().into_iter().flatten() {
let held = (!step.is_null()).then(|| {
let custom = &step["custom"];
HeldName {
item: step["item"].as_str().unwrap_or("").to_owned(),
hover_name: if custom.is_null() {
Text::translate(step["key"].as_str().unwrap_or(""), &[])
} else {
comp(custom)
},
rarity_color: text::named_color(step["rarity"].as_str().unwrap_or("white"))
.unwrap_or(0xFFFFFF),
custom_name: !custom.is_null(),
}
});
highlight.tick(held, time);
}
let mode = c["gameMode"].as_str().unwrap_or("survival");
let can_hurt = mode == "survival" || mode == "adventure";
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
}
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
let bars: Vec<BossBar> = c["bars"]
.as_array()
.into_iter()
.flatten()
.map(|b| BossBar {
name: comp(&b["name"]),
progress: b["progress"].as_f64().unwrap_or(0.0) as f32,
color: b["color"].as_u64().unwrap_or(0) as usize,
overlay: b["overlay"].as_u64().unwrap_or(0) as usize,
})
.collect();
hud_overlays::boss_overlay(w, h, &bars, &atlas.font)
}
/// A `NumberFormat` from the case: null, `{type: blank}`, `{type: fixed,
/// value}` or `{type: styled, style}`.
fn number_format(v: &Value) -> Option<NumberFormat> {
match v["type"].as_str()? {
"blank" => Some(NumberFormat::Blank),
"fixed" => Some(NumberFormat::Fixed(v["value"].clone())),
"styled" => Some(NumberFormat::Styled(v["style"].clone())),
other => panic!("unknown number format {other}"),
}
}
fn sidebar(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
let mut board = Scoreboard::default();
let o = &c["objective"];
board.add_objective(
"sidebar",
"dummy",
o["display"].clone(),
RenderType::Integer,
false,
number_format(&o["format"]),
);
for s in c["scores"].as_array().into_iter().flatten() {
let owner = s["owner"].as_str().unwrap_or("");
let score = board.score_mut(owner, "sidebar");
score.value = s["value"].as_i64().unwrap_or(0) as i32;
score.display = (!s["display"].is_null()).then(|| s["display"].clone());
score.number_format = number_format(&s["format"]);
}
for t in c["teams"].as_array().into_iter().flatten() {
let name = t["name"].as_str().unwrap_or("");
let team = board.add_team(name);
team.prefix = t["prefix"].clone();
team.suffix = t["suffix"].clone();
team.color = t["color"].as_str().map(str::to_owned);
for m in t["members"].as_array().into_iter().flatten() {
board.add_to_team(m.as_str().unwrap_or(""), name);
}
}
let opacity = c["textOpacity"].as_f64().unwrap_or(0.5);
let chat_only = c["chatOnly"].as_bool().unwrap_or(true);
let objective = board.objective("sidebar").expect("objective").clone();
hud_overlays::sidebar(
w,
h,
&board,
&objective,
&atlas.font,
hud_layout::background_color(chat_only, opacity, 0.3),
hud_layout::background_color(chat_only, opacity, 0.4),
)
}
/// `itemDecorations` for a stack with a count, damage and an item cooldown
/// `elapsed` ticks into `duration` (ItemCooldowns.getCooldownPercent).
fn decor(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
let count = int(c, "count", 1);
if count <= 0 {
return Vec::new();
}
let cd = &c["cooldown"];
let cooldown = if cd.is_null() {
0.0
} else {
let (duration, elapsed) = (int(cd, "duration", 0), int(cd, "elapsed", 0));
if duration <= elapsed {
0.0 // ItemCooldowns.tick dropped it
} else {
let remaining = duration as f32 - (elapsed as f32 + float(c, "partial", 0.0));
(remaining / duration as f32).clamp(0.0, 1.0)
}
};
let max_damage = if c["unbreakable"].as_bool().unwrap_or(false) {
0
} else {
int(c, "maxDamage", 0)
};
let d = ItemDecorations {
count: count as u32,
count_text: c["countText"].as_str().map(str::to_owned),
bar: hud_overlays::damage_bar(int(c, "damage", 0), max_damage),
cooldown,
};
hud_overlays::item_decorations(int(c, "x", 0), int(c, "y", 0), &d, &atlas.font)
}
/// `Hud.extractCameraOverlays` then `extractSleepOverlay`; the border is
/// static, so only its warning blocks matter.
fn overlays(c: &Value, w: i32, h: i32, state: &mut serde_json::Map<String, Value>) -> Vec<GuiDraw> {
let partial = float(c, "partial", 0.0);
let warning = match &c["border"] {
Value::Null => 0.0,
b => {
// WorldBorder.getDistanceToBorder with the static extent's clamped box.
let max = 29_999_984.0;
let half = b["size"].as_f64().unwrap_or(0.0) / 2.0;
let (cx, cz) = (
b["centerX"].as_f64().unwrap_or(0.0),
b["centerZ"].as_f64().unwrap_or(0.0),
);
let (min_x, max_x) = ((cx - half).clamp(-max, max), (cx + half).clamp(-max, max));
let (min_z, max_z) = ((cz - half).clamp(-max, max), (cz + half).clamp(-max, max));
let (x, z) = (
f64::from(float(c, "camX", 0.0)),
f64::from(float(c, "camZ", 0.0)),
);
let distance = (x - min_x).min(max_x - x).min(z - min_z).min(max_z - z);
hud_overlays::border_warning(
distance as f32,
int(b, "warningBlocks", 5),
int(b, "warningTime", 15),
0.0,
0.0,
)
}
};
let ticks_frozen = int(c, "ticksFrozen", 0);
let inputs = CameraOverlays {
vignette: c["vignette"]
.as_bool()
.unwrap_or(false)
.then(|| (float(c, "vignetteBrightness", 1.0), warning)),
first_person: c["firstPerson"].as_bool().unwrap_or(false),
scoping: c["scoping"].as_bool().unwrap_or(false),
game_time_delta: partial,
equipment: match c["head"].as_str() {
Some("carved_pumpkin") => vec!["misc/pumpkinblur".to_owned()],
_ => Vec::new(),
},
ticks_frozen,
percent_frozen: ticks_frozen.min(140) as f32 / 140.0,
partial,
portal: [
c["portal"][0].as_f64().unwrap_or(0.0) as f32,
c["portal"][1].as_f64().unwrap_or(0.0) as f32,
],
nausea: float(c, "nausea", 0.0),
screen_effect_scale: float(c, "screenEffectScale", 1.0),
};
let mut scope = float(c, "scopeScale", 0.5);
let mut out = hud_overlays::camera_overlays(w, h, &inputs, &mut scope);
out.extend(hud_overlays::sleep_overlay(w, h, int(c, "sleepTimer", 0)));
state.insert("scopeScale".into(), serde_json::json!(scope));
out
}
/// Messages added at their ticks (`Hud.tickCount`), then the chat in
/// background mode at tick `now`.
fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
let option = |k: &str| c[k].as_f64().unwrap_or(0.0);
let mut chat = Chat::default();
chat.options = ChatOptions {
opacity: option("chatOpacity"),
background_opacity: option("textOpacity"),
scale: option("chatScale"),
width: option("chatWidth"),
height_focused: option("chatHeightFocused"),
height_unfocused: option("chatHeightUnfocused"),
line_spacing: option("chatLineSpacing"),
};
for m in c["messages"].as_array().into_iter().flatten() {
// GuiMessageTag indicator colours: systemSinglePlayer and chatError.
let indicator = match m["tag"].as_str() {
Some("system") => Some(13684944),
Some("error") => Some(16733525),
_ => None,
};
chat.add(
atlas,
comp(&m["text"]),
indicator,
m["at"].as_u64().unwrap_or(0),
false,
);
}
chat.layout(h, c["now"].as_u64().unwrap_or(0), false)
}
@@ -0,0 +1,46 @@
//! Horizon's LAN world discovery for tools/test_lan_parity.py: the cases of
//! VanillaLanOracle (announcements sent, datagrams heard, list resets) run
//! through `lan::discovery`, printing the same lines.
use horizon_client::lan::discovery::{
LanGame, add_server, parse_address, parse_motd, ping_string_text,
};
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len() / 2)
.map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).unwrap())
.collect()
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let cases = std::fs::read_to_string(&args[1]).unwrap();
let mut out = String::new();
let mut games: Vec<LanGame> = Vec::new();
for line in cases.lines() {
let f: Vec<&str> = line.split('\t').collect();
match f[0] {
"ping" => out += &format!("ping {}\n", hex(ping_string_text(f[1], f[2]).as_bytes())),
"heard" => {
let received = String::from_utf8_lossy(&unhex(f[2])).into_owned();
out += &format!(
"heard motd={} address={}\n",
parse_motd(&received),
parse_address(&received).unwrap_or_else(|| "null".into())
);
if add_server(&mut games, &received, f[1]) {
out += "list";
for g in &games {
out += &format!(" [{} @ {}]", g.motd, g.address);
}
out += "\n";
}
}
"reset" => games.clear(),
other => panic!("{other}"),
}
}
std::fs::write(&args[2], out).unwrap();
}
@@ -35,10 +35,12 @@ fn main() -> horizon_client::Result<()> {
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let banners = horizon_client::banner::Banners::load(root);
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
};
let mut out = String::new();
for line in input.lines() {
@@ -50,6 +52,7 @@ fn main() -> horizon_client::Result<()> {
menu.set_slot(&env, i, parse_stack(slot.as_str().unwrap_or("-"))?);
}
menu.carried = parse_stack(case["carried"].as_str().unwrap_or("-"))?;
menu.settle(&env);
for (i, v) in case["data"].as_array().ok_or("data")?.iter().enumerate() {
menu.data[i] = v.as_i64().unwrap_or(0) as i32;
}
@@ -58,6 +61,23 @@ fn main() -> horizon_client::Result<()> {
for op in case["ops"].as_array().ok_or("ops")? {
let slot = op[0].as_i64().unwrap_or(0) as i32;
let button = op[1].as_i64().unwrap_or(0) as i32;
match op[2].as_str().unwrap_or_default() {
// ServerboundContainerButtonClickPacket: clickMenuButton.
"BUTTON" => {
menu.button(&env, slot);
out += &format!("{}\n", state(&menu));
continue;
}
// ServerboundContainerSlotStateChangedPacket.
"SLOT_STATE" => {
if (0..9).contains(&slot) {
menu.set_slot_state(slot as usize, button == 1);
}
out += &format!("{}\n", state(&menu));
continue;
}
_ => {}
}
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
match menu.click(&env, slot, button, kind) {
Ok(()) => out += &format!("{}\n", state(&menu)),
@@ -0,0 +1,868 @@
//! Replays `tools/test_mob_ai_parity.py` scenarios on Horizon's mob AI and
//! prints each mob's AI-visible state after every tick in the format of
//! `VanillaMobAiOracle`.
//!
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, HEIGHT, MIN_Y},
collisions,
containers::{BlockEntity, CreakingHeart},
items::Items,
light::{LightTables, light_chunk},
mobs::{
Damage, MobType, creaking,
creaking_heart::{self, HeartEvent, HeartHost},
},
mobs::{
Mob, MobData, MobEvent, MobTypes, MobWorld, PlayerView, Seen, Species, Who,
control::Operation, spawning::LocalDifficulty,
},
world::World,
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::LegacyRandom,
};
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
struct MobSpec {
kind: String,
pos: [f64; 3],
yaw: f32,
seed: i64,
baby: bool,
flags: Vec<String>,
}
#[derive(Default)]
struct Scenario {
name: String,
ticks: i32,
time: i64,
rain: bool,
thunder: bool,
origin: [i32; 2],
blocks: Vec<Vec<String>>,
player: Option<[f64; 3]>,
creative: bool,
/// The tick from which the player faces another yaw.
turn: Option<(i32, f32)>,
/// Creaking hearts placed when the scenario starts.
hearts: Vec<Vec<String>>,
/// `ServerLevel.isSpawningMonsters`.
spawning: bool,
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
hurts: Vec<(i32, usize)>,
hand: Option<String>,
mobs: Vec<MobSpec>,
}
fn parse(text: &str) -> Vec<Scenario> {
let mut out = Vec::new();
let mut cur: Option<Scenario> = None;
for line in text.lines() {
let f: Vec<&str> = line.split_whitespace().collect();
if f.is_empty() || f[0].starts_with('#') {
continue;
}
let s = cur.get_or_insert_with(Scenario::default);
match f[0] {
"scenario" => {
s.name = f[1].to_owned();
s.time = 6000;
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(),
"rain" => s.rain = true,
"thunder" => s.thunder = true,
"levelseed" => {}
"spawning" => s.spawning = true,
_ => panic!("unknown scenario option {kv}"),
}
}
}
"origin" => s.origin = [f[1].parse().unwrap(), f[2].parse().unwrap()],
"fill" | "block" => s.blocks.push(f.iter().map(|x| (*x).to_owned()).collect()),
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
"player" => {
s.player = Some([
f[1].parse().unwrap(),
f[2].parse().unwrap(),
f[3].parse().unwrap(),
]);
for opt in &f[4..] {
if *opt == "creative" {
s.creative = true;
} else if let Some(t) = opt.strip_prefix("turn=") {
let (tick, yaw) = t.split_once(',').unwrap();
s.turn = Some((tick.parse().unwrap(), yaw.parse().unwrap()));
} else if let Some(item) = opt.strip_prefix("hand=") {
s.hand = Some(item.to_owned());
} else {
panic!("unknown player option {opt}");
}
}
}
"mob" => s.mobs.push(MobSpec {
kind: f[1].to_owned(),
pos: [
f[2].parse().unwrap(),
f[3].parse().unwrap(),
f[4].parse().unwrap(),
],
yaw: f[5].parse().unwrap(),
seed: f[6].parse().unwrap(),
baby: f[7..].contains(&"baby"),
flags: f[7..]
.iter()
.filter(|x| **x != "baby")
.map(|x| (*x).to_owned())
.collect(),
}),
"end" => out.push(cur.take().unwrap()),
other => panic!("unknown scenario line {other}"),
}
}
out
}
/// `minecraft:name[key=value,...]`
fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
let (name, props) = match text.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (text, ""),
};
let mut properties = BTreeMap::new();
for kv in props.split(',').filter(|s| !s.is_empty()) {
let (k, v) = kv.split_once('=').expect("property");
properties.insert(k.to_owned(), v.to_owned());
}
registry
.resolve(&BlockState {
name: name.to_owned(),
properties,
})
.unwrap_or_else(|e| panic!("{text}: {e}"))
}
fn d(v: f64) -> String {
format!("{:x}", v.to_bits())
}
fn f(v: f32) -> String {
format!("{:x}", v.to_bits())
}
fn who(w: Option<Who>) -> String {
match w {
None => "none".into(),
Some(Who::Player) => "player".into(),
Some(Who::Mob(id)) => format!("e{id}"),
}
}
/// Vanilla's goal class for a Horizon goal (most share the name).
fn goal_name(m: &Mob, name: &str) -> String {
let vanilla = match name {
"Float" => "FloatGoal",
"Stroll" => "WaterAvoidingRandomStrollGoal",
"RandomStroll" => "RandomStrollGoal",
"RandomSwimming" => "RandomSwimmingGoal",
"LookAtPlayer" => "LookAtPlayerGoal",
"RandomLookAround" => "RandomLookAroundGoal",
"HurtByTarget" => "HurtByTargetGoal",
"NearestTarget" => "NearestAttackableTargetGoal",
"Panic" => "PanicGoal",
"Breed" => "BreedGoal",
"Tempt" => "TemptGoal",
"FollowParent" => "FollowParentGoal",
"MeleeAttack" => match m.species() {
Species::Zombie | Species::Husk | Species::ZombieVillager => "ZombieAttackGoal",
_ => "MeleeAttackGoal",
},
"MoveThroughVillage" => "MoveThroughVillageGoal",
"RestrictSun" => "RestrictSunGoal",
"FleeSun" => "FleeSunGoal",
"AvoidEntity" => "AvoidEntityGoal",
"BowAttack" => "RangedBowAttackGoal",
"Swell" => "SwellGoal",
"LeapAtTarget" => "LeapAtTargetGoal",
"EatBlock" => "EatBlockGoal",
"MoveTowardsRestriction" => "MoveTowardsRestrictionGoal",
"TradeWithPlayer" => "TradeWithPlayerGoal",
"LookAtTradingPlayer" => "LookAtTradingPlayerGoal",
"WanderToPosition" => "WanderToPositionGoal",
"DrinkItem" => "UseItemGoal",
"BreathAir" => "BreathAirGoal",
"TryFindWater" => "TryFindWaterGoal",
"RangedAttack" => "RangedAttackGoal",
"MoveTowardsTarget" => "MoveTowardsTargetGoal",
"DrownedGoToWater" => "DrownedGoToWaterGoal",
"TakeBlock" => "EndermanTakeBlockGoal",
"LeaveBlock" => "EndermanLeaveBlockGoal",
"FreezeWhenLookedAt" => "EndermanFreezeWhenLookedAt",
"LookForPlayer" => "EndermanLookForPlayerGoal",
"HealRaiders" => "NearestHealableRaiderTargetGoal",
other => return other.to_owned(),
};
vanilla.to_owned()
}
fn goals(m: &Mob, names: Vec<&'static str>) -> String {
if names.is_empty() {
return "-".into();
}
names
.iter()
.map(|n| goal_name(m, n))
.collect::<Vec<_>>()
.join("|")
}
fn seen(m: &Mob) -> Seen {
Seen {
id: m.id,
species: m.species(),
position: m.position,
bounds: m.bounds(),
eye_y: m.eye_y(),
alive: m.alive(),
baby: m.is_baby(),
in_love: if m.species() == Species::Villager {
horizon_client::mobs::villager::can_breed(m)
} else {
m.extra.in_love > 0
},
tame: m.extra.tame,
hostile: m.hostile(),
target: m.ai.target,
health: m.health,
variant: m.extra.variant,
color: m.extra.color,
job: (m.species() == Species::Villager)
.then_some(m.extra.home)
.flatten(),
bed: (m.species() == Species::Villager)
.then_some(m.extra.bed)
.flatten(),
yaw: m.yaw,
velocity: m.velocity,
leash: m.extra.leash,
caravan: horizon_client::mobs::caravan::head_of(m),
delay: m.extra.ints[5],
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
active_raid: horizon_client::mobs::raider::has_active_raid(m),
player_rep: 0,
pushable: m.pushable(),
category: m.category(),
potential_job: horizon_client::mobs::villager_brain::potential_job_site(m),
meeting: (m.species() == Species::Villager)
.then_some(m.extra.meeting)
.flatten(),
profession: m.extra.variant2,
xp: horizon_client::mobs::villager::xp(m),
stats: [
f64::from(m.attrs.max_health),
m.extra.vector[0],
m.attrs.speed,
],
}
}
/// The probe's mobs as a creaking heart sees them: creakings it creates take
/// the n-th seed from 1000, as the oracle's do.
struct Host<'a> {
mobs: &'a mut Vec<Mob>,
added: Vec<Mob>,
types: &'a MobTypes,
created: &'a mut i64,
spawning: bool,
}
impl HeartHost for Host<'_> {
fn mob(&self, id: u32) -> Option<&Mob> {
self.mobs
.iter()
.chain(self.added.iter())
.find(|m| m.id == id)
}
fn mob_mut(&mut self, id: u32) -> Option<&mut Mob> {
self.mobs
.iter_mut()
.chain(self.added.iter_mut())
.find(|m| m.id == id)
}
fn mob_for_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
self.mob(uuid[3] as u32)
.filter(|m| !m.removed)
.map(|m| m.id)
}
fn uuid_for(&mut self, id: u32) -> [i32; 4] {
[0, 0, 0, id as i32]
}
fn creaking_type(&self) -> Option<Arc<MobType>> {
self.types
.types
.get("minecraft:creaking")
.map(|t| Arc::new(t.clone()))
}
fn create_mob(&mut self, kind: Arc<MobType>, at: Vec3) -> Mob {
let seed = 1000 + *self.created;
*self.created += 1;
let mut m = Mob::new(200_000 + seed as u32, kind, at, 0.0);
m.random = LegacyRandom::new(seed);
horizon_client::mobs::monster_brain::init(&mut m, seed);
m
}
fn add_mob(&mut self, m: Mob) {
self.added.push(m);
}
fn spawning_monsters(&self) -> bool {
self.spawning
}
}
/// The heart's state, block entity and the level random, as the oracle
/// prints them.
fn print_heart(
out: &mut String,
t: i32,
registry: &BlockRegistry,
world: &World,
at: [i32; 3],
heart: &CreakingHeart,
lr: &LegacyRandom,
) {
let state = registry
.state(world.block(at[0], at[1], at[2]))
.and_then(|s| s.properties.get("creaking_heart_state").cloned())
.unwrap_or_default();
let _ = writeln!(
out,
"{t} heart {},{},{}\t{state}\t{} {} {} {} {}\tlrng {:x}",
at[0],
at[1],
at[2],
heart.ticker,
heart.emitter,
heart.output_signal,
heart.ticks_existed,
if heart.creaking.is_some() {
"linked"
} else {
"none"
},
lr.state()
);
}
/// Whether an event hurts the scenario's player (survival, normal difficulty,
/// no armour, made hurtable again every tick by the oracle).
fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
let Some(p) = player.filter(|p| !p.creative) else {
return false;
};
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius) => {
let d = Vec3::new(at[0], at[1], at[2])
.distance_squared(p.position)
.sqrt();
let r = 2.0 * f64::from(*radius);
d / r <= 1.0 && d != 0.0
}
_ => false,
}
}
fn print(out: &mut String, t: i32, i: usize, m: &Mob) {
let _ = write!(out, "{t} {i}");
if m.removed {
// Entity.RemovalReason: the end of the death animation, else a discard.
let reason = if m.alive() { "DISCARDED" } else { "KILLED" };
let _ = writeln!(out, " removed {reason}");
return;
}
let p = m.position;
let v = m.velocity;
let _ = write!(
out,
"\tp {},{},{}\tv {},{},{}",
d(p.x),
d(p.y),
d(p.z),
d(v.x),
d(v.y),
d(v.z)
);
let _ = write!(
out,
"\tr {},{},{},{}",
f(m.yaw),
f(m.head_pitch),
f(m.head_yaw),
f(m.body_yaw)
);
let _ = write!(
out,
"\tg {}{}\thp {}",
u8::from(m.on_ground),
u8::from(m.horizontal_collision),
f(m.health)
);
let _ = write!(
out,
"\tgoals {}\ttargets {}\ttarget {}",
goals(m, m.ai.goals.running_names()),
goals(m, m.ai.targets.running_names()),
who(m.ai.target)
);
let _ = write!(out, "\tnav ");
match &m.ai.nav.path {
None => out.push_str("none"),
Some(path) => {
let _ = write!(
out,
"{},{},{} {}/{}{}",
path.target[0],
path.target[1],
path.target[2],
path.next,
path.nodes.len(),
if path.next >= path.nodes.len() {
" done"
} else {
""
}
);
}
}
let mv = &m.ai.mov;
let op = match mv.operation {
Operation::Wait => "WAIT",
Operation::MoveTo => "MOVE_TO",
Operation::Strafe => "STRAFE",
Operation::Jumping => "JUMPING",
};
let _ = write!(
out,
"\tmove {op} {},{},{} {}",
d(mv.wanted[0]),
d(mv.wanted[1]),
d(mv.wanted[2]),
d(mv.speed_modifier)
);
let lk = &m.ai.look;
let _ = write!(
out,
"\tlook {} {},{},{}",
lk.cooldown,
d(lk.wanted[0]),
d(lk.wanted[1]),
d(lk.wanted[2])
);
let _ = write!(
out,
"\tin {},{},{},{}",
f(m.ai.xxa),
f(m.ai.zza),
f(m.ai.speed),
u8::from(m.ai.jumping)
);
let _ = write!(out, "\trng {:x}", m.random.state());
let brain = horizon_client::mobs::villager_brain::describe(m)
.or_else(|| horizon_client::mobs::monster_brain::describe(m))
.or_else(|| horizon_client::mobs::animal_brain::describe(m));
if let Some((acts, mem)) = brain {
let _ = write!(out, "\tact {acts}\tmem {mem}");
}
out.push('\n');
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() != 3 {
return Err("usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT".into());
}
let root = Path::new(&args[0]);
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let types = MobTypes::load(root)?;
let biomes = Biomes::load(root)?;
let items = Items::load(root, registry, &facts)?;
let tables = LightTables::load(root, registry, &facts)?;
let collision = collisions::load(root, registry)?;
let air = registry.default_state("minecraft:air").ok_or("no air")?;
let layers = [
state(registry, "minecraft:bedrock"),
state(registry, "minecraft:dirt"),
state(registry, "minecraft:dirt"),
state(registry, "minecraft:grass_block"),
];
let plains = biomes.id("minecraft:plains");
let mut out = String::new();
for s in parse(&fs::read_to_string(&args[1])?) {
let _ = writeln!(out, "== {}", s.name);
let mut world = World::new(air, 0);
let (ocx, ocz) = (s.origin[0] >> 4, s.origin[1] >> 4);
const R: i32 = 4;
for cz in ocz - R..=ocz + R {
for cx in ocx - R..=ocx + R {
let mut chunk = Chunk::empty(cx, cz);
for qy in 0..HEIGHT / 4 {
for qz in 0..4 {
for qx in 0..4 {
chunk.set_biome_cell(qx, qy, qz, plains);
}
}
}
world.chunks.insert((cx, cz), Arc::new(chunk));
for z in 0..16 {
for x in 0..16 {
for (y, layer) in layers.iter().enumerate() {
world.set(cx * 16 + x, MIN_Y + y as i32, cz * 16 + z, *layer)?;
}
}
}
}
}
for b in &s.blocks {
let n = |i: usize| b[i].parse::<i32>().unwrap();
if b[0] == "block" {
world.set(n(1), n(2), n(3), state(registry, &b[4]))?;
} else {
let st = state(registry, &b[7]);
for x in n(1).min(n(4))..=n(1).max(n(4)) {
for y in n(2).min(n(5))..=n(2).max(n(5)) {
for z in n(3).min(n(6))..=n(3).max(n(6)) {
world.set(x, y, z, st)?;
}
}
}
}
}
let mut hearts: Vec<([i32; 3], CreakingHeart)> = Vec::new();
for h in &s.hearts {
let at = [
h[1].parse::<i32>().unwrap(),
h[2].parse::<i32>().unwrap(),
h[3].parse::<i32>().unwrap(),
];
world.set(at[0], at[1], at[2], state(registry, &h[4]))?;
hearts.push((at, CreakingHeart::default()));
}
let mut created = 0_i64;
let keys: Vec<(i32, i32)> = world.chunks.keys().copied().collect();
let lit: Vec<((i32, i32), Chunk)> = keys
.iter()
.map(|&(x, z)| {
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
world
.chunks
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
.map(|c| &**c)
})
});
let mut c = (*world.chunks[&(x, z)]).clone();
c.sections = light_chunk(&region, &tables, true);
c.lit = true;
((x, z), c)
})
.collect();
for (k, c) in lit {
world.chunks.insert(k, Arc::new(c));
}
world.day_time = s.time;
world.game_time = 1000;
let mut mobs: Vec<Mob> = Vec::new();
for (i, spec) in s.mobs.iter().enumerate() {
let kind = types
.types
.get(&spec.kind)
.ok_or_else(|| format!("unknown mob {}", spec.kind))?;
let mut m = Mob::new(
100_000 + i as u32,
Arc::new(kind.clone()),
Vec3::new(spec.pos[0], spec.pos[1], spec.pos[2]),
spec.yaw,
);
if spec.baby {
m.extra.age = -24000;
}
for flag in &spec.flags {
match flag.as_str() {
"persistent" => m.persistent = true,
"noai" => m.no_ai = true,
"inlove" => m.extra.in_love = 600,
"tame" => m.extra.tame = true,
x if x.starts_with("age=") => m.extra.age = x[4..].parse().unwrap(),
x if x.starts_with("strength=") => m.extra.variant2 = x[9..].parse().unwrap(),
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
x if x.starts_with("leashto=") => {
let n: u32 = x[8..].parse().unwrap();
m.extra.leash = Some(horizon_client::mobs::leash::Holder::Mob(100_000 + n));
}
other => panic!("unknown mob flag {other}"),
}
}
m.random = LegacyRandom::new(spec.seed);
if horizon_client::mobs::monster_brain::has_brain(m.species()) {
// The oracle delays the sensors again with the seeded random
// and seeds RunOne's shuffles with the mob's seed.
horizon_client::mobs::monster_brain::init(&mut m, spec.seed);
}
if m.species() == Species::Villager {
// The oracle seeds the random after the brain is built, then
// delays the sensors again and seeds the gates' shuffles.
horizon_client::mobs::villager_brain::init(
&mut m,
world.game_time,
s.time,
spec.seed,
);
}
// The same for the sniffer's and the happy ghast's brains.
if horizon_client::mobs::animal_brain::kind_of(&m).is_some() {
horizon_client::mobs::animal_brain::init(&mut m, spec.seed);
}
mobs.push(m);
}
let mut player = s.player.map(|p| {
let position = Vec3::new(p[0], p[1], p[2]);
PlayerView {
position,
bounds: Aabb::new(
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
),
eye: [p[0], p[1] + f64::from(1.62_f32), p[2]],
alive: true,
creative: s.creative,
sneaking: false,
look: [0.0, 0.0, 1.0],
main_hand: s.hand.as_deref().map(Into::into),
off_hand: None,
head: None,
armor_cover: 0.0,
spectator: false,
gold_armor: false,
hurt_by: None,
last_hurt: None,
velocity: Vec3::ZERO,
yaw: 0.0,
hero: false,
uuid: [0, 0x1234, 0, 0x5678],
allies: Default::default(),
}
});
let mut scratch = LegacyRandom::new(0);
for (i, m) in mobs.iter().enumerate() {
print(&mut out, 0, i, m);
}
// The MobWorld every step sees, over a snapshot of the mobs.
macro_rules! mob_world {
($snapshot:expr) => {
MobWorld {
teams: Default::default(),
world: &world,
registry,
facts: &facts,
collision: &collision,
data: &data,
player: player.clone(),
others: $snapshot,
daylight: (s.time.rem_euclid(24000)) < 12000,
game_time: world.game_time,
day_time: world.day_time,
raining: s.rain,
thundering: s.thunder,
nether: false,
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
// FlatLevelSource.getSeaLevel.
sea_level: -63,
}
};
}
for t in 1..=s.ticks {
world.game_time += 1;
let mut heart_events_later: Vec<HeartEvent> = Vec::new();
// Creaking.tick reads the heart's block entity.
for (at, h) in &hearts {
world.block_entities.insert(
(at[0], at[1], at[2]),
BlockEntity::CreakingHeart(Box::new(h.clone())),
);
}
if let (Some(p), Some((tick, yaw))) = (player.as_mut(), s.turn)
&& t >= tick
{
// Entity.calculateViewVector(0, yaw).
let r = -yaw * horizon_core::geometry::trig::DEG_TO_RAD;
let (cos, sin) = (
horizon_core::geometry::trig::cos(f64::from(r)),
horizon_core::geometry::trig::sin(f64::from(r)),
);
p.look = [f64::from(sin), -0.0, f64::from(cos)];
p.yaw = yaw;
}
let mut snapshot: Vec<Seen> = mobs.iter().filter(|m| !m.removed).map(seen).collect();
let w = mob_world!(&snapshot);
let mut events = Vec::new();
let mut heart_events = Vec::new();
let mut added = Vec::new();
for &(tick, i) in &s.hurts {
if tick != t || i >= mobs.len() || mobs[i].removed {
continue;
}
let at = player
.as_ref()
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
mobs[i].damage(1.0, Damage::player(at));
// Creaking.hurtServer: the heart's reaction, then the sway.
if let Some((home, by_player)) = creaking::take_hurt(&mut mobs[i], &w) {
if by_player && let Some((_, h)) = hearts.iter_mut().find(|(at, _)| *at == home)
{
let mut host = Host {
mobs: &mut mobs,
added: Vec::new(),
types: &types,
created: &mut created,
spawning: s.spawning,
};
creaking_heart::creaking_hurt(
h,
home,
&w,
&mut scratch,
&mut host,
&mut heart_events,
);
added.append(&mut host.added);
}
for e in heart_events.drain(..) {
match e {
HeartEvent::Sound(name, _, volume, pitch) => {
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch))
}
HeartEvent::SetBlock(..) => heart_events_later.push(e),
HeartEvent::Mob(ev) => events.push(ev),
_ => {}
}
}
creaking::sway(&mut mobs[i], &mut events);
}
events.append(&mut mobs[i].outbox);
}
#[allow(unused_mut)] // filled only with the rng-trace feature
let mut traces: Vec<String> = Vec::new();
let mut pushes = Vec::new();
for i in 0..mobs.len() {
if mobs[i].removed {
continue;
}
let w = mob_world!(&snapshot);
let m = &mut mobs[i];
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
let from = events.len();
#[cfg(feature = "rng-trace")]
horizon_core::random::trace::set(true);
let alive = m.tick(&w, &mut scratch, &mut events);
#[cfg(feature = "rng-trace")]
{
horizon_core::random::trace::set(false);
for line in horizon_core::random::trace::take() {
traces.push(format!("{t} trace {i} {line}"));
}
}
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
if !alive {
m.removed = true;
}
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
// ServerLevel ticks entities one after another: the ones
// after this mob see it as it is now.
if let Some(e) = snapshot.iter_mut().find(|e| e.id == mobs[i].id) {
*e = seen(&mobs[i]);
e.alive &= !mobs[i].removed;
}
}
for m in &mut mobs {
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
}
let w = mob_world!(&snapshot);
// ServerLevel.tickBlockEntities: the hearts (none ticks once uprooted).
heart_events.append(&mut heart_events_later);
for (at, h) in &mut hearts {
let uprooted = registry
.state(w.world.block(at[0], at[1], at[2]))
.and_then(|s| s.properties.get("creaking_heart_state").cloned())
.is_some_and(|v| v == "uprooted");
if uprooted {
continue;
}
let mut host = Host {
mobs: &mut mobs,
added: Vec::new(),
types: &types,
created: &mut created,
spawning: s.spawning,
};
creaking_heart::server_tick(h, *at, &w, &mut scratch, &mut host, &mut heart_events);
added.append(&mut host.added);
}
drop(w);
mobs.append(&mut added);
for e in &heart_events {
match e {
HeartEvent::SetBlock(at, st) => world.set(at[0], at[1], at[2], *st)?,
HeartEvent::Sound(name, _, volume, pitch) => {
events.push(MobEvent::Sound(name, [0.0; 3], *volume, *pitch))
}
HeartEvent::Mob(ev) => events.push(ev.clone()),
_ => {}
}
}
for (i, m) in mobs.iter().enumerate() {
print(&mut out, t, i, m);
}
for e in &events {
if let MobEvent::Sound(name, _, volume, pitch) = e {
let _ = writeln!(
out,
"{t} sound minecraft:{name} {} {}",
f(*volume),
f(*pitch)
);
} else if let MobEvent::SoundId(name, _, volume, pitch) = e {
let _ = writeln!(out, "{t} sound {name} {} {}", f(*volume), f(*pitch));
} else if player_hurt(player.as_ref(), e) {
// The oracle's survival player, healed and made hurtable every tick,
// takes the damage (LivingEntity.playHurtSound).
let _ = writeln!(
out,
"{t} sound minecraft:entity.player.hurt 3f800000 3f800000"
);
}
}
for (at, h) in &hearts {
print_heart(&mut out, t, registry, &world, *at, h, &scratch);
}
for line in traces {
let _ = writeln!(out, "{line}");
}
}
}
fs::write(&args[2], out)?;
Ok(())
}
@@ -0,0 +1,108 @@
//! Replays `tools/test_music_parity.py` scripts through Horizon's
//! `MusicManager` over its `SoundEngine`: `music_fixture ROOT INPUT OUTPUT`.
#[path = "audio_common/mod.rs"]
mod common;
use common::{Fake, describe, f, flush};
use horizon_client::music::{Music, MusicFrequency, MusicManager};
use horizon_client::sound_engine::{Bound, MUSIC, SoundEngine, TickContext};
use horizon_client::sound_events::SoundRegistry;
use std::io::Write;
fn music(spec: &str) -> Option<Music> {
if spec == "-" {
return None;
}
let p: Vec<&str> = spec.split(',').collect();
Some(Music::new(
&horizon_client::sound_events::normalize(p[0]),
p[1].parse().expect("min"),
p[2].parse().expect("max"),
p[3] == "1",
))
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
engine.listening = true;
let mut backend = Fake::default();
let mut manager = MusicManager::new(0);
let (mut situation, mut volume) = (None::<Music>, 1.0f32);
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
writeln!(out, "{line}").expect("write");
let a: Vec<&str> = line.split(' ').collect();
match a[0] {
"start" => {
if let Some(id) = manager.current_instance() {
engine.release(id);
}
manager = MusicManager::new(a[1].parse().expect("seed"));
manager.frequency = MusicFrequency::parse(a[2]);
engine.stop_all(&mut backend);
}
"situation" => {
situation = music(a[1]);
volume = f(a[2]);
}
"tick" => {
let mut sounds = Bound {
engine: &mut engine,
backend: &mut backend,
};
manager.tick(situation.as_ref(), volume, a[1] == "1", &mut sounds);
engine.tick(a[2] == "1", &TickContext::default(), &mut backend);
}
"finish" => {
if let Some(c) = manager.current_instance().and_then(|id| engine.channel(id)) {
horizon_client::sound_engine::Backend::stop(&mut backend, c);
}
}
"stop" => manager.stop_playing(
situation.as_ref(),
&mut Bound {
engine: &mut engine,
backend: &mut backend,
},
),
"stopmusic" => {
let m = music(a[1]).expect("music");
manager.stop_playing_music(
&m,
situation.as_ref(),
&mut Bound {
engine: &mut engine,
backend: &mut backend,
},
);
}
"freq" => manager.set_frequency(MusicFrequency::parse(a[1]), situation.as_ref()),
"toast" => manager.show_now_playing_toast_if_needed(),
"volume" => {
let source: usize = a[1].parse().expect("source");
engine.source_volumes[source] = f(a[2]);
engine.refresh_category_volume(source, &mut backend);
}
"stopall" => engine.stop_all(&mut backend),
_ => panic!("{line}"),
}
writeln!(
out,
" current={} delay={} gain={:08x} toastShown={} musicGain={:08x}",
manager.current(&engine).unwrap_or("-"),
manager.next_song_delay(),
manager.current_gain().to_bits(),
manager.toast_shown(),
engine.category_gain(MUSIC).to_bits()
)
.expect("write");
for show in std::mem::take(&mut manager.toast_requests) {
writeln!(out, " toast {}", if show { "show" } else { "hide" }).expect("write");
}
if let Some(id) = manager.current_instance() {
writeln!(out, "{}", describe(&engine, &backend, "music", id)).expect("write");
}
flush(&mut engine, &mut out);
}
}
@@ -0,0 +1,688 @@
//! Natural spawning decisions for differential tests against vanilla
//! (tools/test_natural_spawn_parity.py): reads the box-world cases of
//! `VanillaNaturalSpawnOracle` and prints what `mobs::spawn_selection` does in
//! the same order and format.
//!
//! The oracle runs `finalizeSpawn` with a fixed random (nextInt 0, nextFloat
//! 0.75); `neutral_box` is the box that leaves a mob with: polar bears,
//! ocelots and rabbits after the first of their group are babies (chance
//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon
//! are small, slimes and magma cubes keep size 1, tropical fish school.
//!
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
assets::Assets,
biome::{Biomes, SpawnEntry},
blocks::BlockFacts,
collisions::{self, LookupCollisions, no_collision_empty},
items::Items,
mobs::{
MobTypes, Species,
data::MobData,
spawn_rules::{Dimension, SpawnLevel},
spawn_selection::{
self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost,
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
spawn_box,
},
species::{self, Baby},
},
};
use horizon_core::{
geometry::Aabb,
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
block_collision::BlockCollisionRegistry, chunk::BlockState, registry::BlockStateId,
};
use serde_json::Value;
use std::{
cell::RefCell,
collections::{BTreeMap, HashMap, HashSet},
fmt::Write as _,
fs,
path::Path,
};
#[derive(Clone, Copy)]
struct Bx([i32; 6]);
impl Bx {
fn parse(s: &str) -> Self {
let v: Vec<i32> = s.split(',').map(|n| n.parse().unwrap_or(0)).collect();
Self([v[0], v[1], v[2], v[3], v[4], v[5]])
}
fn has(&self, x: i32, y: i32, z: i32) -> bool {
let b = self.0;
x >= b[0] && x <= b[3] && y >= b[1] && y <= b[4] && z >= b[2] && z <= b[5]
}
}
struct Start {
id: String,
pieces: Vec<Bx>,
/// The start's bounding box (pieces encapsulated, inflated by 12 for
/// terrain adaptation).
bounds: Bx,
}
struct Shared<'a> {
facts: &'a BlockFacts,
data: &'a MobData,
block_tags: &'a HashMap<String, HashSet<String>>,
types: &'a MobTypes,
biomes: &'a Biomes,
structures: &'a StructureSpawns,
collision: &'a BlockCollisionRegistry<'a>,
air: BlockStateId,
}
struct World<'a> {
s: &'a Shared<'a>,
blocks: Vec<(Bx, BlockStateId)>,
lights: Vec<(Bx, i32, i32)>,
biomes: Vec<(Bx, String)>,
biome: String,
sky: i32,
light: i32,
darken: i32,
thunder: bool,
sea: i32,
seed: i64,
peaceful: bool,
dimension: Dimension,
dimension_id: String,
slime: f32,
starts: Vec<Start>,
players: Vec<([f64; 3], String)>,
respawn: Option<[i32; 3]>,
unloaded: HashSet<(i32, i32)>,
spawn_mobs: bool,
/// Boxes of living entities (case entities, then spawned mobs).
entities: RefCell<Vec<Aabb>>,
}
impl World<'_> {
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
self.biomes
.iter()
.rev()
.find(|(b, _)| b.has(x, y, z))
.map_or(&self.biome, |(_, n)| n)
}
fn biome_info(&self, x: i32, y: i32, z: i32) -> Option<&horizon_client::biome::BiomeInfo> {
let i = *self.s.biomes.index.get(self.biome_at(x, y, z))?;
Some(self.s.biomes.get(i))
}
fn lights_at(&self, x: i32, y: i32, z: i32) -> (i32, i32) {
self.lights
.iter()
.rev()
.find(|(b, ..)| b.has(x, y, z))
.map_or((self.sky, self.light), |(_, s, b)| (*s, *b))
}
}
impl SpawnLevel for World<'_> {
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
self.blocks
.iter()
.rev()
.find(|(b, _)| b.has(x, y, z))
.map_or(self.s.air, |(_, s)| *s)
}
fn facts(&self) -> &BlockFacts {
self.s.facts
}
fn mob_data(&self) -> &MobData {
self.s.data
}
fn block_tag(&self, tag: &str, block: &str) -> bool {
self.s
.block_tags
.get(tag)
.is_some_and(|s| s.contains(block))
}
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
self.s
.data
.biome_tags
.get(tag)
.is_some_and(|s| s.contains(self.biome_at(x, y, z)))
}
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
self.lights_at(x, y, z).0
}
fn block_light(&self, x: i32, y: i32, z: i32) -> i32 {
self.lights_at(x, y, z).1
}
fn sky_darken(&self) -> i32 {
self.darken
}
fn thundering(&self) -> bool {
self.thunder
}
fn sea_level(&self) -> i32 {
self.sea
}
fn world_surface(&self, x: i32, z: i32) -> i32 {
self.heightmap(Heightmap::WorldSurface, x, z)
}
fn peaceful(&self) -> bool {
self.peaceful
}
fn dimension(&self) -> Dimension {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
self.slime
}
fn world_seed(&self) -> i64 {
self.seed
}
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
self.players.iter().any(|(p, mode)| {
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
(mode == "survival" || mode == "adventure") && d < distance * distance
})
}
}
impl NaturalLevel for World<'_> {
fn min_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
-64
} else {
0
}
}
fn max_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
319
} else {
255
}
}
fn ambient_light(&self) -> f32 {
match self.dimension {
Dimension::Overworld => 0.0,
Dimension::Nether => 0.1,
Dimension::End => 0.25,
}
}
fn has_ceiling(&self) -> bool {
self.dimension == Dimension::Nether
}
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
let t = self.s.types.types.get(entity)?;
Some((
t.species,
t.width as f32,
t.height as f32,
Category::from_name(&t.category),
))
}
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
self.biome_info(x, y, z).map(|b| &b.spawns)
}
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
self.biome_info(x, y, z)?.spawn_costs.get(entity).copied()
}
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
self.biome_info(x, y, z)
.map_or(0.1, |b| b.creature_probability)
}
fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec<StructureAt> {
let (cx, cz) = (x >> 4, z >> 4);
let mut out: Vec<StructureAt> = Vec::new();
for start in &self.starts {
let b = start.bounds.0;
// Referenced: the start's box reaches the chunk's columns.
if b[3] < cx * 16 || b[0] > cx * 16 + 15 || b[5] < cz * 16 || b[2] > cz * 16 + 15 {
continue;
}
let in_piece = start.pieces.iter().any(|p| p.has(x, y, z));
let in_bounds = start.bounds.has(x, y, z);
match out.iter_mut().find(|s| s.id == start.id) {
Some(s) => {
s.in_piece |= in_piece;
s.in_bounds |= in_bounds;
}
None => out.push(StructureAt {
id: start.id.clone(),
in_piece,
in_bounds,
}),
}
}
out
}
fn structure_spawns(&self) -> &StructureSpawns {
self.s.structures
}
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
let mut best: Option<f64> = None;
for (p, mode) in &self.players {
if mode == "spectator" {
continue;
}
let d = [p[0] - x, p[1] - y, p[2] - z];
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if best.is_none_or(|b| d < b) {
best = Some(d);
}
}
best
}
fn respawn(&self) -> Option<[i32; 3]> {
self.respawn
}
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
!self.unloaded.contains(&(cx, cz))
}
fn no_collision(&self, bounds: Aabb) -> bool {
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
let mut world = LookupCollisions {
block: &lookup,
binding: self.s.collision,
};
no_collision_empty(&mut world, bounds)
}
fn unobstructed(&self, bounds: Aabb) -> bool {
!self.entities.borrow().iter().any(|b| b.intersects(bounds))
}
fn spawn_mobs(&self) -> bool {
self.spawn_mobs
}
fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 {
(self.min_y()..=self.max_y())
.rev()
.find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z))
.map_or(self.min_y(), |y| y + 1)
}
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
self.s.data.state(self.state(x, y, z)).flags & horizon_client::mobs::data::LAND != 0
}
}
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb {
let t = &l.s.types.types[r.entity];
let (mut w, mut h) = (t.width as f32, t.height as f32);
let baby = match r.species {
Species::PolarBear | Species::Ocelot | Species::Rabbit => r.group_index > 0,
Species::Fox | Species::Axolotl => r.group_index >= 2,
_ => false,
};
if baby {
match species::def(r.species).baby {
Baby::Abs(bw, bh, _) => (w, h) = (bw as f32, bh as f32),
Baby::Scale(s, _) => (w, h) = (w * s as f32, h * s as f32),
Baby::None => {}
}
}
match r.species {
Species::SulfurCube => (w, h) = (w * 2.0, h * 2.0),
Species::Salmon => (w, h) = (w * 0.5, h * 0.5),
_ => {}
}
let [x, y, z] = r.position;
spawn_box(x, y, z, w, h)
}
fn parse_state(spec: &str) -> BlockState {
let (name, props) = match spec.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (spec, ""),
};
BlockState {
name: name.to_owned(),
properties: props
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect(),
}
}
fn bits(v: f64) -> String {
format!("{:x}", v.to_bits())
}
/// The yaw as the oracle prints it: a cube mob grown by `finalizeSpawn`
/// (sulfur cubes, size 2 under the fixed random) takes its constructor's
/// head rotation from the entity's unseeded random.
fn yaw_text(entity: &str, v: f32) -> String {
if entity == "minecraft:sulfur_cube" {
"head".to_owned()
} else {
format!("{:x}", v.to_bits())
}
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(
args.get(1)
.ok_or("usage: natural_spawn_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let types = MobTypes::load(root)?;
let biomes = Biomes::load(root)?;
let structures = StructureSpawns::load(root);
let collision = collisions::load(root, registry)?;
let shared = Shared {
facts: &facts,
data: &data,
block_tags: &items.block_tags,
types: &types,
biomes: &biomes,
structures: &structures,
collision: &collision,
air: facts.air,
};
let mut adaptation: HashMap<String, bool> = HashMap::new();
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() {
continue;
}
let mut c = BTreeMap::new();
let mut name = "";
for token in raw.split_whitespace() {
match token.split_once('=') {
Some((k, v)) => {
c.insert(k, v);
}
None => name = token,
}
}
let get =
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
let indexed = |prefix: &str| -> Vec<&str> {
(0..)
.map_while(|i| c.get(format!("{prefix}{i}").as_str()).copied())
.collect()
};
let mut blocks = Vec::new();
for k in indexed("k") {
let (b, s) = k.split_once('/').ok_or("bad block box")?;
blocks.push((Bx::parse(b), registry.resolve(&parse_state(s))?));
}
let lights = indexed("l")
.into_iter()
.filter_map(|l| {
let (b, v) = l.split_once('/')?;
let (s, k) = v.split_once(',')?;
Some((Bx::parse(b), s.parse().ok()?, k.parse().ok()?))
})
.collect();
let biome_boxes = indexed("b")
.into_iter()
.filter_map(|l| {
let (b, n) = l.split_once('/')?;
Some((Bx::parse(b), n.to_owned()))
})
.collect();
let mut starts = Vec::new();
for s in indexed("s") {
let (id, pieces) = s.split_once('/').ok_or("bad structure")?;
let pieces: Vec<Bx> = pieces.split(';').map(Bx::parse).collect();
let mut b = pieces[0].0;
for p in &pieces[1..] {
for i in 0..3 {
b[i] = b[i].min(p.0[i]);
b[i + 3] = b[i + 3].max(p.0[i + 3]);
}
}
let adapts = *adaptation.entry(id.to_owned()).or_insert_with(|| {
let path = root.join(format!(
"data/minecraft/worldgen/structure/{}.json",
id.trim_start_matches("minecraft:")
));
fs::read(path)
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v["terrain_adaptation"].as_str().map(|a| a != "none"))
.unwrap_or(false)
});
if adapts {
for i in 0..3 {
b[i] -= 12;
b[i + 3] += 12;
}
}
starts.push(Start {
id: id.to_owned(),
pieces,
bounds: Bx(b),
});
}
let players = indexed("p")
.into_iter()
.map(|p| {
let mut parts = p.split('/');
let v: Vec<f64> = parts
.next()
.unwrap_or("")
.split(',')
.map(|n| n.parse().unwrap_or(0.0))
.collect();
(
[v[0], v[1], v[2]],
parts.next().unwrap_or("survival").to_owned(),
)
})
.collect::<Vec<_>>();
let dimension_id = get("dim", "minecraft:overworld");
let dimension = match dimension_id.as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
};
let respawn = {
let r = get("respawn", "0,-1000,0/minecraft:overworld");
let (p, d) = r.split_once('/').ok_or("bad respawn")?;
let v: Vec<i32> = p.split(',').map(|n| n.parse().unwrap_or(0)).collect();
(d == dimension_id).then_some([v[0], v[1], v[2]])
};
let unloaded = c
.get("unloaded")
.map(|u| {
u.split(';')
.filter_map(|p| {
let (x, z) = p.split_once(',')?;
Some((x.parse().ok()?, z.parse().ok()?))
})
.collect()
})
.unwrap_or_default();
// Case entities: (type, position, persistent).
let mut case_entities = Vec::new();
for e in indexed("e") {
let parts: Vec<&str> = e.split('/').collect();
let v: Vec<f64> = parts[1]
.split(',')
.map(|n| n.parse().unwrap_or(0.0))
.collect();
// EntityType.create makes no monsters in peaceful.
if get("difficulty", "normal") == "peaceful"
&& types.types.get(parts[0]).is_some_and(|t| {
!horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
})
{
continue;
}
case_entities.push((
parts[0].to_owned(),
[v[0], v[1], v[2]],
parts.get(2) == Some(&"p"),
));
}
let world = World {
s: &shared,
blocks,
lights,
biomes: biome_boxes,
biome: get("biome", "minecraft:plains"),
sky: get("sky", "15").parse()?,
light: get("light", "0").parse()?,
darken: get("darken", "0").parse()?,
thunder: get("thunder", "0") == "1",
sea: get("sea", "63").parse()?,
seed: get("wseed", "0").parse()?,
peaceful: get("difficulty", "normal") == "peaceful",
dimension,
dimension_id: dimension_id.clone(),
slime: get("slime", "0.0").parse()?,
starts,
players,
respawn,
unloaded,
spawn_mobs: get("rules", "1") != "0",
entities: RefCell::new(
case_entities
.iter()
.filter_map(|(t, p, _)| {
let t = types.types.get(t)?;
Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32))
})
.collect(),
),
};
let _ = &world.dimension_id;
let chunk = {
let v: Vec<i32> = get("chunk", "0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
(v[0], v[1])
};
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
let mut log: Vec<String> = Vec::new();
let mut spawn = |l: &World, r: &SpawnRequest| -> Option<Spawned> {
let bounds = neutral_box(l, r);
l.entities.borrow_mut().push(bounds);
Some(Spawned {
bounds,
school: true,
})
};
let mode = get("mode", "chunk");
if mode == "gen" {
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
// The level random of the spawn predicates: no creature's predicate
// draws from it (the oracle's answers fixed values).
let mut rules = LegacyRandom::new(0);
let mut spawns = Vec::new();
let source = [chunk.0 * 16, world.max_y(), chunk.1 * 16];
spawn_selection::spawn_mobs_for_chunk_generation(
&world,
source,
chunk,
&mut gen_random,
&mut rules,
&mut spawn,
&mut |e| match e {
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
"spawn {t} {} {} {} {}",
bits(p[0]),
bits(p[1]),
bits(p[2]),
yaw_text(&t, yaw)
)),
},
);
log.extend(spawns);
log.push(format!(
"next {}",
gen_random.next_int_bound(1000).unwrap_or(0)
));
} else {
let counted: Vec<Counted> = case_entities
.iter()
.filter_map(|(t, p, persistent)| {
let kind = types.types.get(t)?;
Some(Counted {
entity: t,
category: Category::from_name(&kind.category),
block: [
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
],
is_mob: true,
persistent: *persistent,
})
})
.collect();
let players = world
.players
.iter()
.map(|(p, mode)| SpawnPlayer {
position: *p,
spectator: mode == "spectator",
})
.collect();
let mut state = SpawnState::new(
&world,
get("chunkcount", "289").parse()?,
players,
&counted,
|cx, cz| !world.unloaded.contains(&(cx, cz)),
);
let mut trace = |e: Event| {
log.push(match e {
Event::Categories(list) => format!(
"cats {}",
list.iter().map(|c| c.name()).collect::<Vec<_>>().join(",")
),
Event::Local(c, ok) => format!("local {} {ok}", c.name()),
Event::Start(c, p) => format!("start {} {} {} {}", c.name(), p[0], p[1], p[2]),
Event::Try(x, z) => format!("try {x} {z}"),
Event::Test(t, p, ok) => format!("test {t} {} {} {} {ok}", p[0], p[1], p[2]),
Event::Spawn(t, p, yaw) => format!(
"spawn {t} {} {} {} {}",
bits(p[0]),
bits(p[1]),
bits(p[2]),
yaw_text(&t, yaw)
),
})
};
if mode == "pos" {
let category = Category::from_name(&get("cat", "monster"));
let v: Vec<i32> = get("start", "0,0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
spawn_selection::spawn_category_for_position(
&world,
&mut random,
&mut state,
category,
chunk,
[v[0], v[1], v[2]],
&mut spawn,
&mut trace,
);
} else {
let categories = state
.filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1");
trace(Event::Categories(categories.clone()));
spawn_selection::spawn_for_chunk(
&world,
&mut random,
&mut state,
&categories,
chunk,
&mut spawn,
&mut trace,
);
}
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
}
let _ = writeln!(out, "{name} {}", log.join(" ; "));
}
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -0,0 +1,89 @@
//! Recipe book placements for tools/test_place_recipe_parity.py: reads one
//! JSON case per line (menu, inventory, grid, recipe, max, creative,
//! selected) and the oracle's order of the counted items, runs
//! `Menu::place_recipe` and prints the oracle's line.
//!
//! usage: place_recipe_fixture RESOURCES CASES ORDERS OUTPUT
use horizon_client::{
crafting::Recipes,
item_data::{canon_stack, parse_stack},
items::Items,
menu::{Env, Kind, Menu, place::PostPlace},
};
use serde_json::Value;
use std::{fs, path::Path};
fn state(menu: &Menu) -> String {
let slots: Vec<String> = (0..menu.slot_count())
.map(|i| canon_stack(menu.slot(i)))
.collect();
let data: String = menu.data.iter().map(|d| format!(" {d}")).collect();
let inv: Vec<String> = (0..41).map(|i| canon_stack(menu.player_item(i))).collect();
format!(
"{} | {} |{data} | {} | inv {}",
slots.join(" "),
canon_stack(menu.carried.as_ref()),
menu.drops.join(";"),
inv.join(" ")
)
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let usage = "usage: place_recipe_fixture RESOURCES CASES ORDERS OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let orders = fs::read_to_string(args.get(3).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let banners = horizon_client::banner::Banners::default();
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
};
let mut out = String::new();
for (line, order) in cases.lines().zip(orders.lines()) {
let case: Value = serde_json::from_str(line)?;
let kind = Kind::parse(case["menu"].as_str().unwrap_or_default()).ok_or("menu kind")?;
let mut menu = Menu::new(kind);
menu.creative = case["creative"].as_bool().unwrap_or(false);
menu.selected = case["selected"].as_u64().unwrap_or(0) as usize;
for pair in case["inventory"].as_array().ok_or("inventory")? {
menu.set_player_item(
pair[0].as_u64().unwrap_or(0) as usize,
parse_stack(pair[1].as_str().unwrap_or("-"))?,
);
}
for pair in case["grid"].as_array().ok_or("grid")? {
menu.set_slot(
&env,
pair[0].as_u64().unwrap_or(0) as usize,
parse_stack(pair[1].as_str().unwrap_or("-"))?,
);
}
menu.drops.clear();
let order: Vec<String> = order.split_whitespace().map(str::to_owned).collect();
let recipe = case["recipe"].as_str().unwrap_or_default();
let impossible = recipes.placement(recipe).is_none_or(|p| p.impossible());
let post = if impossible {
"-"
} else {
match menu.place_recipe(
&env,
recipe,
case["max"].as_bool().unwrap_or(false),
menu.creative,
Some(&order),
) {
PostPlace::Nothing => "NOTHING",
PostPlace::Ghost => "PLACE_GHOST_RECIPE",
}
};
out += &format!("post {post} | {}\n", state(&menu));
}
fs::write(args.get(4).ok_or(usage)?, out)?;
Ok(())
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,143 @@
//! The player's recipe book for tools/test_recipe_book_parity.py: reads one
//! JSON case per line (a list of give / take / seen / set / save / load ops)
//! and prints the oracle's line after each op, from
//! `horizon_client::recipe_book::{Settings, RecipeIndex}`.
//!
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::BTreeSet, fs, path::Path};
fn fnv<'a>(ids: impl IntoIterator<Item = &'a String>) -> u64 {
let mut sorted: Vec<&String> = ids.into_iter().collect();
sorted.sort();
let joined = sorted
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(",");
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in joined.bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
fn ids(set: &BTreeSet<String>) -> String {
format!("{}:{:x}", set.len(), fnv(set))
}
/// The saved compound as the oracle prints it: keys sorted, lists hashed.
fn compound(tag: &Tag) -> String {
let Tag::Compound(c) = tag else {
return String::new();
};
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
keys.sort();
let mut out = String::new();
for k in keys {
out.push(' ');
out.push_str(&k);
out.push('=');
match tag.get(&k) {
Some(Tag::List(list)) => {
let v: Vec<String> = list
.iter()
.map(|t| match t {
Tag::String(s) => s.to_string_lossy(),
_ => "?".into(),
})
.collect();
out.push_str(&format!("{}:{:x}", v.len(), fnv(&v)));
}
Some(Tag::Byte(b)) => out.push_str(&format!("{b}b")),
_ => out.push('?'),
}
}
out
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let usage = "usage: recipe_book_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let index = RecipeIndex::load(root);
let mut out = String::new();
for line in cases.lines() {
let case: Value = serde_json::from_str(line)?;
let mut book = Settings::default();
let mut saved: Option<Tag> = None;
for op in case["ops"].as_array().ok_or("ops")? {
let kind = op["op"].as_str().unwrap_or_default();
let list = || -> Vec<String> {
op["ids"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str().map(str::to_owned))
.filter(|id| index.contains(id))
.collect()
};
let mut count = 0;
let mut extra = String::new();
match kind {
"give" => count = book.add_recipes(&index, &list()).0,
"take" => count = book.remove_recipes(&index, &list()),
"seen" => book.remove_highlight(op["id"].as_str().unwrap_or_default()),
"set" => {
let i = match op["type"].as_str().unwrap_or_default() {
"CRAFTING" => BookType::Crafting,
"FURNACE" => BookType::Furnace,
"BLAST_FURNACE" => BookType::BlastFurnace,
_ => BookType::Smoker,
}
.index();
book.open[i] = op["open"].as_bool().unwrap_or(false);
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
}
"save" => {
let tag = book.write();
extra = compound(&tag);
saved = Some(tag);
}
"load" => {
let mut tag = saved
.clone()
.unwrap_or_else(|| Tag::Compound(Default::default()));
if let Tag::Compound(c) = &mut tag {
for name in ["recipes", "toBeDisplayed"] {
if let Some(Tag::List(l)) = c.get_mut(&name.into()) {
l.push(Tag::String("minecraft:no_such_recipe".into()));
l.push(Tag::String("stick".into()));
}
}
if let Some(drop) = op["drop"].as_str() {
c.remove(&drop.into());
}
}
book = Settings::read_valid(Some(&tag), &|id| index.contains(id));
}
other => return Err(format!("op {other}").into()),
}
let settings: String = BookType::ALL
.iter()
.map(|b| {
format!(
"{}{}",
u8::from(book.open[b.index()]),
u8::from(book.filtering[b.index()])
)
})
.collect();
out += &format!(
"{kind} {count} known={} highlight={} settings={settings}{extra}\n",
ids(&book.recipes),
ids(&book.to_be_displayed)
);
}
out += "end\n";
}
fs::write(args.get(3).ok_or(usage)?, out)?;
Ok(())
}
@@ -0,0 +1,263 @@
//! The recipe book's drawing, for tools/test_recipe_book_render_parity.py:
//! reads one JSON case per line (book type, GUI size, tabs, search box,
//! filter, recipe collections and page, overlay, ghost slots, cycling index,
//! mouse) and writes the calls `horizon_client::recipe_book_screen` makes,
//! in the oracle's JSON form.
//!
//! usage: recipe_book_render_fixture RESOURCES CASES OUTPUT
use horizon_client::{
crafting::Recipes,
hud::{HudAtlas, HudBatch},
items::{ItemStack, Items},
lang,
recipe_book::{Book, BookType, Entry},
recipe_book_screen::{
ButtonView, Call, GhostView, ItemCall, OverlayRecipe, OverlayView, Run, Screen, Search,
TabView, draw, overlay_cells, overlay_origin,
},
};
use serde_json::{Value, json};
use std::{fmt::Write as _, fs, path::Path};
fn runs(r: &[Run]) -> Value {
Value::Array(
r.iter()
.map(|r| json!([r.text, r.color, r.italic]))
.collect(),
)
}
fn round(v: f32) -> f32 {
(v * 1e4).round() / 1e4
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let usage = "usage: recipe_book_render_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let book = Book::build(&recipes, &items);
let atlas = HudAtlas::build(root)?;
let root_buf = root.to_path_buf();
lang::set(lang::Language::load(&[&root_buf], lang::DEFAULT));
let translate = |key: &str| lang::get(key);
let text_width = |s: &str| HudBatch::text_width(&atlas, s) as i32;
let mut out = String::new();
for line in cases.lines() {
let case: Value = serde_json::from_str(line)?;
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
let kind = match case["type"].as_str().unwrap_or_default() {
"crafting" => BookType::Crafting,
"furnace" => BookType::Furnace,
"blast_furnace" => BookType::BlastFurnace,
_ => BookType::Smoker,
};
let filtering = case["filtering"].as_bool().unwrap_or(false);
let tab_list = kind.tabs();
let tabs: Vec<TabView> = case["tabs"]
.as_array()
.into_iter()
.flatten()
.map(|t| {
let (a, b) = tab_list[t["tab"].as_u64().unwrap_or(0) as usize].icons();
TabView {
icons: (a.to_owned(), b.map(str::to_owned)),
selected: t["selected"].as_bool().unwrap_or(false),
anim: t["anim"].as_f64().unwrap_or(0.0) as f32,
}
})
.collect();
let s = &case["search"];
let search = Search {
value: s["value"].as_str().unwrap_or_default().to_owned(),
cursor: s["cursor"].as_u64().unwrap_or(0) as usize,
highlight: s["highlight"].as_u64().unwrap_or(0) as usize,
display: s["display"].as_u64().unwrap_or(0) as usize,
focused: s["focused"].as_bool().unwrap_or(false),
blink_ms: s["blink"].as_u64().unwrap_or(0),
};
// Each collection's recipes and craftable flags.
let collections: Vec<Vec<(&Entry, bool)>> = case["collections"]
.as_array()
.into_iter()
.flatten()
.map(|c| {
c["recipes"]
.as_array()
.into_iter()
.flatten()
.filter_map(|r| Some((book.entry(r[0].as_str()?)?, r[1].as_bool()?)))
.collect()
})
.collect();
let page = case["page"].as_u64().unwrap_or(0) as usize;
let pages = collections.len().div_ceil(20);
let anims = case["buttonAnims"].as_array().cloned().unwrap_or_default();
let shown = &collections[(20 * page).min(collections.len())..];
let buttons: Vec<ButtonView> = shown
.iter()
.take(20)
.enumerate()
.map(|(i, c)| ButtonView {
has_craftable: c.iter().any(|e| e.1),
entries: c
.iter()
.filter(|e| e.1 || !filtering)
.map(|e| vec![e.0.result.clone()])
.collect(),
anim: anims.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32,
})
.collect();
let (xo, yo) = Screen {
book: kind,
width: int(&case["width"]),
height: int(&case["height"]),
narrow: case["narrow"].as_bool().unwrap_or(false),
mouse: (0, 0),
index: 0,
filtering,
search: &search,
tabs: &[],
buttons: &[],
page,
pages,
overlay: None,
ghost: None,
translate: &translate,
text_width: &text_width,
}
.origin();
let overlay = case["overlay"].as_u64().map(|i| {
let i = i as usize;
let c = &shown[i];
let mut recipes: Vec<OverlayRecipe> = c
.iter()
.filter(|e| e.1)
.map(|e| OverlayRecipe {
craftable: true,
cells: overlay_cells(&book, e.0),
})
.collect();
if !filtering {
recipes.extend(c.iter().filter(|e| !e.1).map(|e| OverlayRecipe {
craftable: false,
cells: overlay_cells(&book, e.0),
}));
}
let (x, y) = overlay_origin(
xo + 11 + 25 * (i as i32 % 5),
yo + 31 + 25 * (i as i32 / 5),
recipes.len(),
xo,
yo,
);
OverlayView { x, y, recipes }
});
let ghosts: Vec<GhostView> = case["ghost"]
.as_array()
.into_iter()
.flatten()
.map(|g| GhostView {
x: int(&g["x"]),
y: int(&g["y"]),
result: g["result"].as_bool().unwrap_or(false),
items: g["items"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(|i| ItemStack::new(i, 1))
.collect(),
})
.collect();
let screen = Screen {
book: kind,
width: int(&case["width"]),
height: int(&case["height"]),
narrow: case["narrow"].as_bool().unwrap_or(false),
mouse: (int(&case["mouse"][0]), int(&case["mouse"][1])),
index: case["index"].as_u64().unwrap_or(0) as usize,
filtering,
search: &search,
tabs: &tabs,
buttons: &buttons,
page,
pages,
overlay: overlay.as_ref(),
ghost: case.get("ghost").map(|_| {
(
int(&case["left"]),
int(&case["top"]),
case["big"].as_bool().unwrap_or(false),
ghosts.as_slice(),
)
}),
translate: &translate,
text_width: &text_width,
};
let draws: Vec<Value> = draw(&screen)
.into_iter()
.filter_map(|d| {
let m: Vec<f32> = d.pose.iter().map(|v| round(*v)).collect();
let mut o = match d.call {
Call::Blit {
texture,
x,
y,
w,
h,
u,
v,
tw,
th,
} => json!({"t": "blit", "texture": texture, "x": x, "y": y, "w": w, "h": h,
"u": u, "v": v, "tw": tw, "th": th}),
Call::Sprite { id, x, y, w, h } => {
json!({"t": "sprite", "id": id, "x": x, "y": y, "w": w, "h": h})
}
Call::Fill {
x0,
y0,
x1,
y1,
color,
} => json!({"t": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1, "color": color}),
Call::Highlight { x0, y0, x1, y1 } => {
json!({"t": "highlight", "x0": x0, "y0": y0, "x1": x1, "y1": y1})
}
Call::Text {
runs: r,
x,
y,
color,
shadow,
} => json!({"t": "text", "runs": runs(&r), "x": x, "y": y, "color": color, "shadow": shadow}),
Call::Item { call, stack, x, y } => {
if stack.count == 0 || &*stack.item == "minecraft:air" {
return None;
}
let t = match call {
ItemCall::Item => "item",
ItemCall::Fake => "fake",
ItemCall::Decorations => "decorations",
};
json!({"t": t, "id": &*stack.item, "count": stack.count, "x": x, "y": y})
}
Call::Tooltip(lines) => {
json!({"t": "tooltip", "lines": lines.iter().map(|l| runs(l)).collect::<Vec<_>>()})
}
Call::ItemTooltip { stack, more } => {
json!({"t": "item_tooltip", "id": &*stack.item, "more": more})
}
Call::Stratum => json!({"t": "stratum"}),
};
o["m"] = json!(m);
Some(o)
})
.collect();
let _ = writeln!(out, "{}", json!({ "draws": draws }));
}
fs::write(args.get(3).ok_or(usage)?, out)?;
Ok(())
}
@@ -0,0 +1,121 @@
//! What the recipe book lists, for tools/test_recipe_book_view_parity.py:
//! reads one JSON case per line (the history of displays the client was
//! sent, the book type and grid, the inventory and menu slots, the filter)
//! and prints every tab's visibility, page count and buttons from
//! `horizon_client::recipe_book::ClientBook`.
//!
//! usage: recipe_book_view_fixture RESOURCES CASES OUTPUT
use horizon_client::{
crafting::Recipes,
item_data::parse_stack,
items::Items,
menu::place::Stacked,
recipe_book::{Book, BookType, ClientBook, Display, RecipeIndex, Tab},
};
use serde_json::Value;
use std::{fmt::Write as _, fs, path::Path};
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let usage = "usage: recipe_book_view_fixture RESOURCES CASES OUTPUT";
let root = Path::new(args.get(1).ok_or(usage)?);
let cases = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let index = RecipeIndex::load(root);
let mut book = Book::build(&recipes, &items);
book.set_display_ids(&index);
let max = |s: &horizon_client::items::ItemStack| items.get(&s.item).map_or(64, |i| i.max_stack);
let mut out = String::new();
for line in cases.lines() {
let case: Value = serde_json::from_str(line)?;
let mut client = ClientBook::default();
for batch in case["history"].as_array().ok_or("history")? {
let op = batch["op"].as_str().unwrap_or_default();
if op == "replace" {
client.clear();
}
for id in batch["ids"].as_array().into_iter().flatten() {
for d in index.display_ids(id.as_str().unwrap_or_default()) {
if op == "remove" {
client.remove(d);
} else {
client.add(d);
}
}
}
}
let kind = case["type"].as_str().unwrap_or_default();
let grid = case["grid"].as_u64().unwrap_or(3) as usize;
let mut stock = Stacked::default();
for s in case["inventory"].as_array().into_iter().flatten() {
if let Some(stack) = parse_stack(s.as_str().unwrap_or("-"))? {
stock.account_simple(&stack, max(&stack));
}
}
for s in case["slots"].as_array().into_iter().flatten() {
if let Some(stack) = parse_stack(s.as_str().unwrap_or("-"))? {
if kind == "crafting" {
stock.account_simple(&stack, max(&stack));
} else {
stock.account_stack(&stack, max(&stack));
}
}
}
let book_type = match kind {
"crafting" => BookType::Crafting,
"furnace" => BookType::Furnace,
"blast_furnace" => BookType::BlastFurnace,
_ => BookType::Smoker,
};
let filtering = case["filtering"].as_bool().unwrap_or(false);
for tab in book_type.tabs() {
let collections = client.collections(&book, book_type, tab);
let selected = |i: usize| match (&book.entries[i].display, book_type) {
(Display::Furnace(_), BookType::Crafting) => false,
(Display::Furnace(_), _) => true,
(_, BookType::Crafting) => book.entries[i].fits(grid, grid),
_ => false,
};
let craftable = |i: usize| selected(i) && book.can_craft(&book.entries[i], &stock);
let visible = tab == Tab::Search
|| collections
.iter()
.any(|c| c.entries.iter().any(|&i| selected(i)));
let shown: Vec<_> = collections
.iter()
.filter(|c| c.entries.iter().any(|&i| selected(i)))
.filter(|c| !filtering || c.entries.iter().any(|&i| craftable(i)))
.collect();
let name = match tab {
Tab::Search => "search".to_owned(),
Tab::Category(c) => c.name().to_owned(),
};
let _ = write!(
out,
"{name} {} pages={}",
if visible { "shown" } else { "hidden" },
shown.len().div_ceil(20)
);
for c in shown {
let parts: Vec<String> = c
.entries
.iter()
.map(|&i| {
format!(
"{}{}{}",
book.entries[i].display_id,
if selected(i) { "s" } else { "" },
if craftable(i) { "c" } else { "" }
)
})
.collect();
let _ = write!(out, " [{}]", parts.join(","));
}
out.push_str(" | ");
}
out.push('\n');
}
fs::write(args.get(3).ok_or(usage)?, out)?;
Ok(())
}
@@ -0,0 +1,430 @@
//! The scoreboard model for differential tests against vanilla's `Scoreboard`
//! (tools/test_scoreboard_parity.py): runs the same scripted operations as
//! VanillaScoreboardOracle on `horizon_client::scoreboard::Scoreboard` and
//! prints the same JSON lines (results, dumps, the packed save data before
//! and after reloading it). With a vanilla output file it also reloads each
//! case's vanilla NBT and packs it again (`vreload`).
//!
//! usage: scoreboard_fixture REGISTRIES.json INPUTS OUTPUT [VANILLA_OUTPUT]
use horizon_client::{
command::Registries,
scoreboard::{
self as sb, CollisionRule, Comp, NumberFormat, RenderType, Scoreboard, Visibility,
canonical_component, criteria_by_name, criteria_read_only, criteria_render_type,
},
};
use horizon_world::nbt::{self, NamedTag, Tag};
use serde_json::{Map, Value, json};
use std::{collections::BTreeMap, fs, io::Write};
fn canon(tag: &Tag) -> Value {
match tag {
Tag::Compound(c) => {
let sorted: BTreeMap<String, &Tag> =
c.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
Value::Object(sorted.into_iter().map(|(k, v)| (k, canon(v))).collect())
}
Tag::List(l) => Value::Array(l.iter().map(canon).collect()),
Tag::String(s) => Value::String(format!("'{}", s.to_string_lossy())),
Tag::Byte(v) => Value::String(format!("{v}b")),
Tag::Short(v) => Value::String(format!("{v}s")),
Tag::Int(v) => Value::String(format!("{v}i")),
Tag::Long(v) => Value::String(format!("{v}L")),
Tag::Float(v) => Value::String(format!("f:{v:x}")),
Tag::Double(v) => Value::String(format!("d:{v:x}")),
Tag::IntArray(v) => json!({ "I;": v }),
other => Value::String(format!("?{other:?}")),
}
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex(text: &str) -> Vec<u8> {
(0..text.len())
.step_by(2)
.map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap_or(0))
.collect()
}
fn encode(tag: &Tag) -> Vec<u8> {
nbt::write_named(
&NamedTag {
name: "".into(),
tag: tag.clone(),
},
Default::default(),
)
.expect("nbt")
}
fn decode(bytes: &[u8]) -> Tag {
nbt::read_named(bytes, Default::default())
.expect("nbt")
.0
.tag
}
fn comp(v: Option<&Value>) -> Option<Comp> {
v.filter(|v| !v.is_null()).cloned()
}
fn comp_json(c: Option<&Comp>) -> Value {
c.map_or(Value::Null, canonical_component)
}
fn fmt_json(f: Option<&NumberFormat>) -> Value {
f.map_or(Value::Null, NumberFormat::to_json)
}
fn fmt(v: Option<&Value>) -> Option<NumberFormat> {
v.filter(|v| !v.is_null()).and_then(NumberFormat::from_json)
}
fn s<'a>(op: &'a Value, k: &str) -> &'a str {
op[k].as_str().unwrap_or_default()
}
struct Fixture {
reg: Registries,
}
impl Fixture {
fn criteria(&self, name: &str) -> Value {
match criteria_by_name(name, &self.reg) {
Ok(Some(c)) => Value::String(c),
Ok(None) => Value::Null,
Err(_) => Value::String("!invalid".into()),
}
}
fn dump(&self, b: &Scoreboard, probes: &[Value]) -> Value {
let objectives: Vec<Value> = b
.objectives
.iter()
.map(|o| {
json!({
"name": o.name, "criteria": o.criteria, "display": comp_json(Some(&o.display_name)),
"render": o.render_type.id(), "auto": o.display_auto_update,
"format": fmt_json(o.number_format.as_ref()),
})
})
.collect();
let stream: Vec<Value> = b
.objectives_streamed()
.iter()
.map(|o| Value::String(o.name.clone()))
.collect();
let tracked = b.tracked();
let mut lists = Map::new();
for o in &b.objectives {
let rows: Vec<Value> = b
.list_scores(&o.name)
.iter()
.map(|e| {
json!([
e.owner,
e.value,
comp_json(e.display.as_ref()),
fmt_json(e.number_format.as_ref())
])
})
.collect();
lists.insert(o.name.clone(), Value::Array(rows));
}
let mut holders = Map::new();
for h in &tracked {
let mut per = Map::new();
for o in &b.objectives {
if let Some(sc) = b.score(h, &o.name) {
per.insert(o.name.clone(), score_json(sc));
}
}
holders.insert(h.clone(), Value::Object(per));
}
let teams: Vec<Value> = b
.teams
.iter()
.map(|t| {
json!({
"name": t.name, "display": comp_json(Some(&t.display_name)), "color": t.color,
"ff": t.allow_friendly_fire, "si": t.see_friendly_invisibles,
"prefix": comp_json(Some(&t.prefix)), "suffix": comp_json(Some(&t.suffix)),
"nametag": t.nametag_visibility.name(), "death": t.death_message_visibility.name(),
"collision": t.collision_rule.name(),
"players": t.players.iter().collect::<Vec<_>>(),
})
})
.collect();
let teams_stream: Vec<Value> = b
.teams_streamed()
.iter()
.map(|t| Value::String(t.name.clone()))
.collect();
let mut slots = Map::new();
for slot in sb::display_slots() {
if let Some(o) = b.display_objective(&slot) {
slots.insert(slot, Value::String(o.name.clone()));
}
}
let mut team_of = Map::new();
for p in probes {
let p = p.as_str().unwrap_or_default();
team_of.insert(
p.to_owned(),
b.players_team(p)
.map_or(Value::Null, |t| Value::String(t.name.clone())),
);
}
json!({
"objectives": objectives, "objectives_stream": stream, "tracked": tracked,
"lists": lists, "holders": holders, "teams": teams, "teams_stream": teams_stream,
"slots": slots, "team_of": team_of,
})
}
fn apply(&self, b: &mut Scoreboard, op: &Value, probes: &[Value]) -> Value {
let kind = s(op, "op");
let holder = s(op, "holder");
let dname = comp(op.get("dname"));
match kind {
"crit" => {
let name = self.criteria(s(op, "name"));
let mut o = json!({ "name": name });
if let Some(n) = name.as_str().filter(|n| !n.starts_with('!')) {
o["readonly"] = Value::Bool(criteria_read_only(n));
o["render"] = Value::String(criteria_render_type(n).id().into());
}
o
}
"obj_add" => {
let Some(criteria) = self
.criteria(s(op, "criteria"))
.as_str()
.filter(|c| !c.starts_with('!'))
.map(str::to_owned)
else {
return json!("bad");
};
if b.objective(s(op, "name")).is_some() {
return json!("exists");
}
b.add_objective(
s(op, "name"),
&criteria,
comp(op.get("display")).unwrap_or(Value::Null),
RenderType::by_id(s(op, "render")),
op["auto"].as_bool().unwrap_or(false),
fmt(op.get("fmt")),
);
json!("ok")
}
"obj_rm" => {
if b.objective(s(op, "name")).is_none() {
return json!("missing");
}
b.remove_objective(s(op, "name"));
json!("ok")
}
"obj_mod" => {
let Some(o) = b.objective_mut(s(op, "name")) else {
return json!("missing");
};
let v = &op["value"];
match s(op, "field") {
"display" => o.display_name = v.clone(),
"render" => o.render_type = RenderType::by_id(v.as_str().unwrap_or_default()),
"auto" => o.display_auto_update = v.as_bool().unwrap_or(false),
"fmt" => o.number_format = fmt(Some(v)),
other => panic!("field {other}"),
}
json!("ok")
}
"slot" => {
let name = op["name"].as_str();
if let Some(n) = name
&& b.objective(n).is_none()
{
return json!("missing");
}
b.set_display_objective(s(op, "slot"), name);
json!("ok")
}
"set" | "add" | "get" | "lock" | "unlock" | "sdisplay" | "sfmt" => {
let name = s(op, "name");
let Some(criteria) = b.objective(name).map(|o| o.criteria.clone()) else {
return json!("missing");
};
let force = op["force"].as_bool().unwrap_or(false);
let writable = force || !criteria_read_only(&criteria);
let current = b.get(holder, name);
match kind {
"set" | "add" => {
if !writable {
return json!("readonly");
}
let value = op["value"].as_i64().unwrap_or(0) as i32;
let new = if kind == "add" {
current.wrapping_add(value)
} else {
value
};
b.set_with(holder, name, new, dname.as_ref());
if kind == "add" {
return json!(new);
}
}
"get" => return json!(current),
"lock" => b.score_mut(holder, name).locked = true,
"unlock" => b.score_mut(holder, name).locked = false,
"sdisplay" => b.score_mut(holder, name).display = comp(op.get("value")),
"sfmt" => b.score_mut(holder, name).number_format = fmt(op.get("value")),
_ => unreachable!(),
}
json!("ok")
}
"info" => {
let name = s(op, "name");
if b.objective(name).is_none() {
return json!("missing");
}
b.score(holder, name).map_or(Value::Null, score_json)
}
"reset" => {
match op["name"].as_str() {
None => b.reset_all(holder),
Some(name) => {
if b.objective(name).is_none() {
return json!("missing");
}
b.reset_one(holder, name);
}
}
json!("ok")
}
"all" => {
let criteria = self.criteria(s(op, "criteria"));
let criteria = criteria.as_str().unwrap_or_default();
let value = op["value"].as_i64().unwrap_or(0) as i32;
let k = s(op, "kind");
b.for_all_objectives_with(criteria, holder, dname.as_ref(), |v| match k {
"inc" => v.wrapping_add(1),
"add" => v.wrapping_add(value),
"set" => value,
_ => 0,
});
json!("ok")
}
"team_add" => {
if b.team(s(op, "name")).is_some() {
return json!("exists");
}
b.add_team(s(op, "name"));
json!("ok")
}
"team_rm" => {
if b.team(s(op, "name")).is_none() {
return json!("missing");
}
b.remove_team(s(op, "name"));
json!("ok")
}
"join" => {
if b.team(s(op, "team")).is_none() {
return json!("missing");
}
json!(b.add_to_team(holder, s(op, "team")))
}
"leave" => json!(b.remove_from_team(holder)),
"team_set" => {
let Some(t) = b.team_mut(s(op, "name")) else {
return json!("missing");
};
let v = &op["value"];
let name = |v: &Value| v.as_str().unwrap_or_default().to_owned();
match s(op, "field") {
"display" => t.display_name = v.clone(),
"color" => t.color = v.as_str().map(str::to_owned),
"ff" => t.allow_friendly_fire = v.as_bool().unwrap_or(true),
"si" => t.see_friendly_invisibles = v.as_bool().unwrap_or(true),
"prefix" => t.prefix = comp(Some(v)).unwrap_or_else(|| sb::literal("")),
"suffix" => t.suffix = comp(Some(v)).unwrap_or_else(|| sb::literal("")),
"nametag" => {
t.nametag_visibility = Visibility::by_name(&name(v)).expect("visibility")
}
"death" => {
t.death_message_visibility =
Visibility::by_name(&name(v)).expect("visibility")
}
"collision" => {
t.collision_rule = CollisionRule::by_name(&name(v)).expect("collision")
}
other => panic!("field {other}"),
}
json!("ok")
}
"removed" => {
b.entity_removed(holder);
json!("ok")
}
"allied" => json!(b.allied(s(op, "a"), s(op, "b"))),
"dump" => self.dump(b, probes),
other => panic!("op {other}"),
}
}
}
fn score_json(sc: &sb::Score) -> Value {
json!({
"value": sc.value, "locked": sc.locked, "display": comp_json(sc.display.as_ref()),
"format": fmt_json(sc.number_format.as_ref()),
})
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let registries: Value =
serde_json::from_str(&fs::read_to_string(&args[1]).expect("registries")).expect("json");
let list = |k: &str| -> Vec<String> {
registries[k]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
};
let reg = Registries {
items: list("items"),
blocks: list("blocks")
.into_iter()
.map(|b| (b, Vec::new()))
.collect(),
entity_types: list("entity_types"),
..Registries::default()
};
let fixture = Fixture { reg };
let vanilla: BTreeMap<i64, String> = args
.get(4)
.and_then(|p| fs::read_to_string(p).ok())
.map(|text| {
text.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.filter_map(|v| Some((v["case"].as_i64()?, v["nbt"].as_str()?.to_owned())))
.collect()
})
.unwrap_or_default();
let mut out = std::io::BufWriter::new(fs::File::create(&args[3]).expect("output"));
for line in fs::read_to_string(&args[2]).expect("inputs").lines() {
let case: Value = serde_json::from_str(line).expect("case");
let probes = case["probes"].as_array().cloned().unwrap_or_default();
let mut board = Scoreboard::default();
let outs: Vec<Value> = case["ops"]
.as_array()
.into_iter()
.flatten()
.map(|op| fixture.apply(&mut board, op, &probes))
.collect();
let packed = board.pack();
let bytes = encode(&packed);
let reloaded = Scoreboard::unpack(&decode(&bytes));
let mut result = json!({
"case": case["case"], "out": outs, "pack": canon(&packed), "nbt": hex(&bytes),
"reload": canon(&reloaded.pack()), "reload_dump": fixture.dump(&reloaded, &probes),
});
if let Some(v) = case["case"].as_i64().and_then(|c| vanilla.get(&c)) {
result["vreload"] = canon(&Scoreboard::unpack(&decode(&unhex(v))).pack());
}
writeln!(out, "{result}").expect("write");
}
}
@@ -0,0 +1,138 @@
//! Loom and crafter screen drawing for tools/test_screen_layout_parity.py:
//! reads one JSON case per line and prints the `horizon_client::screen_layout`
//! calls in the format of `VanillaLoomScreenOracle`.
//!
//! usage: screen_layout_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
banner::{BannerLayer, Banners, banner_color},
screen_layout::{self, CrafterView, LoomView, ScreenDraw},
};
use serde_json::{Value, json};
use std::{fs, path::Path, sync::Arc};
fn draw_json(d: &ScreenDraw) -> Value {
let round = |v: f32| ((v * 1e6).round() / 1e6) as f64;
match d {
ScreenDraw::Gradient { x1, y1 } => json!({"t": "gradient", "x1": x1, "y1": y1}),
ScreenDraw::Blit {
texture,
x,
y,
w,
h,
tw,
th,
} => {
json!({"t": "blit", "texture": texture, "x": x, "y": y, "w": w, "h": h, "u": 0.0, "v": 0.0, "tw": tw, "th": th})
}
ScreenDraw::Sprite { id, x, y, w, h } => {
json!({"t": "sprite", "id": id, "x": x, "y": y, "w": w, "h": h})
}
ScreenDraw::Banner {
base,
layers,
x0,
y0,
x1,
y1,
} => {
let layers: Vec<Value> = layers
.iter()
.map(|l| json!([&*l.pattern, &*l.color]))
.collect();
json!({"t": "banner", "base": base, "layers": layers, "x0": x0, "y0": y0, "x1": x1, "y1": y1})
}
ScreenDraw::Tooltip { key } => json!({"t": "tooltip", "key": key}),
ScreenDraw::SpriteOf { id } => {
json!({"t": "sprite_of", "id": id.trim_start_matches("minecraft:")})
}
ScreenDraw::Fill {
x0,
y0,
x1,
y1,
color,
} => json!({"t": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1, "color": color}),
ScreenDraw::Atlas { x0, y0, x1, y1, uv } => json!({
"t": "atlas", "x0": x0, "y0": y0, "x1": x1, "y1": y1,
"u0": round(uv[0]), "u1": round(uv[1]), "v0": round(uv[2]), "v1": round(uv[3])
}),
}
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let usage = "usage: screen_layout_fixture RESOURCES INPUT OUTPUT";
let banners = Banners::load(Path::new(args.get(1).ok_or(usage)?));
let input = fs::read_to_string(args.get(2).ok_or(usage)?)?;
let mut out = String::new();
for line in input.lines().filter(|l| !l.trim().is_empty()) {
let c: Value = serde_json::from_str(line)?;
let int = |k: &str| c[k].as_i64().unwrap_or(0) as i32;
let draws = if c["screen"] == "loom" {
let patterns: Vec<Arc<str>> = c["patterns"]
.as_array()
.into_iter()
.flatten()
.filter_map(|p| p.as_str().map(Arc::from))
.collect();
let layers: Vec<BannerLayer> = c["resultLayers"]
.as_array()
.into_iter()
.flatten()
.map(|l| BannerLayer::new(l[0].as_str().unwrap_or(""), l[1].as_str().unwrap_or("")))
.collect();
let base = c["result"]
.as_str()
.and_then(banner_color)
.unwrap_or("white");
let dye = c["dyeStack"]
.as_str()
.and_then(|d| d.strip_prefix("minecraft:")?.strip_suffix("_dye"))
.unwrap_or("white");
let view = LoomView {
width: int("width"),
height: int("height"),
left: int("left"),
top: int("top"),
has_banner: c["banner"].is_string(),
has_dye: c["dye"].is_string(),
has_pattern: c["pattern"].is_string(),
patterns: &patterns,
selected: int("selected"),
display: c["display"].as_bool().unwrap_or(false),
has_max: c["hasMax"].as_bool().unwrap_or(false),
scroll: c["scroll"].as_f64().unwrap_or(0.0) as f32,
start_row: int("startRow") as usize,
result: c.get("resultLayers").map(|_| (base, layers.as_slice())),
dye,
mouse: (int("mouseX") as f32, int("mouseY") as f32),
};
screen_layout::loom(&view, &banners)
} else {
let mut disabled = [false; 9];
for (i, d) in c["disabled"]
.as_array()
.into_iter()
.flatten()
.enumerate()
.take(9)
{
disabled[i] = d.as_bool().unwrap_or(false);
}
screen_layout::crafter(&CrafterView {
width: int("width"),
height: int("height"),
left: int("left"),
top: int("top"),
disabled,
powered: c["powered"].as_bool().unwrap_or(false),
})
};
let list: Vec<Value> = draws.iter().map(draw_json).collect();
out += &json!({ "draws": list }).to_string();
out.push('\n');
}
fs::write(args.get(3).ok_or(usage)?, out)?;
Ok(())
}
@@ -0,0 +1,367 @@
//! The Rust side of tools/test_server_files_parity.py: reads each case
//! directory the way VanillaServerFilesOracle does (server.properties,
//! eula.txt, the four user lists and scripted list changes) and prints the
//! same report. `server_files_probe CASES OUTPUT`.
use horizon_client::server::{
eula,
java_properties::now_millis,
properties::{DIFFICULTIES, GAME_MODES, ServerProperties, SystemEntropy},
user_lists::{BanEntry, BanList, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry, WhiteList},
};
use horizon_protocol::crypto::uuid_string;
use std::{fmt::Write as _, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let root = std::path::PathBuf::from(args.next().ok_or("CASES OUTPUT")?);
let output = args.next().ok_or("OUTPUT")?;
let mut cases: Vec<_> = std::fs::read_dir(&root)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.is_dir())
.collect();
cases.sort();
let mut out = String::new();
for dir in cases {
writeln!(out, "== {}", dir.file_name().unwrap().to_string_lossy())?;
run_case(&dir, &mut out)?;
}
std::fs::write(output, out)?;
Ok(())
}
/// Files.readAllLines: split on \n, \r or \r\n.
fn lines(text: &str) -> Vec<String> {
let mut v: Vec<String> = text
.replace("\r\n", "\n")
.replace('\r', "\n")
.split('\n')
.map(str::to_owned)
.collect();
if v.last().is_some_and(String::is_empty) {
v.pop();
}
v
}
fn latin1(bytes: &[u8]) -> String {
bytes.iter().map(|&b| char::from(b)).collect()
}
fn has_key(text: &str, key: &str) -> bool {
text.split('\n')
.any(|l| l.trim_matches(|c: char| c <= ' ').starts_with(key))
}
fn work(dir: &Path) -> std::io::Result<std::path::PathBuf> {
let w = std::env::temp_dir().join(format!(
"server-files-{}-{}",
std::process::id(),
dir.file_name().unwrap().to_string_lossy()
));
let _ = std::fs::remove_dir_all(&w);
std::fs::create_dir_all(&w)?;
for e in std::fs::read_dir(dir)? {
let e = e?;
std::fs::copy(e.path(), w.join(e.file_name()))?;
}
Ok(w)
}
fn run_case(dir: &Path, out: &mut String) -> Result<(), Box<dyn std::error::Error>> {
let w = work(dir)?;
let props = w.join("server.properties");
if dir.join("server.properties").exists() || dir.join("properties.default").exists() {
let before = std::fs::read(&props)
.map(|b| latin1(&b))
.unwrap_or_default();
match ServerProperties::from_file(&props, &mut SystemEntropy::default()) {
Ok(p) => {
print_properties(&p, &before, out)?;
p.store(&props)?;
writeln!(out, "stored:")?;
for (i, line) in lines(&std::fs::read_to_string(&props)?)
.into_iter()
.enumerate()
{
let line = if i == 1 && line.starts_with('#') {
"#DATE".to_owned()
} else if line.starts_with("management-server-secret=")
&& !has_key(&before, "management-server-secret")
{
"management-server-secret=<random>".to_owned()
} else {
line
};
writeln!(out, "{line}")?;
}
}
Err(e) => {
let class = e.split(':').next().unwrap_or("");
writeln!(out, "ERROR {class}")?;
}
}
}
if dir.join("eula.txt").exists() || dir.join("eula.missing").exists() {
let path = w.join("eula.txt");
let agreed = eula::agreed(&path).unwrap_or(false);
writeln!(out, "eula agreed={agreed}")?;
if let Ok(bytes) = std::fs::read(&path) {
for (i, line) in lines(&latin1(&bytes)).into_iter().enumerate() {
if i == 1 && line.starts_with('#') {
writeln!(out, "#DATE")?;
} else {
writeln!(out, "{line}")?;
}
}
}
}
let mut ops = OpList::new(w.join("ops.json"));
let mut white = WhiteList::new(w.join("whitelist.json"));
let mut bans = BanList::new(w.join("banned-players.json"));
let mut ips = IpBanList::new(w.join("banned-ips.json"));
let mut any = false;
macro_rules! load {
($list:expr, $name:literal) => {
if $list.file.exists() {
any = true;
if let Err(_) = $list.load() {
writeln!(
out,
"{} load ERROR com.google.gson.JsonSyntaxException",
$name
)?;
}
}
};
}
load!(ops, "ops");
load!(white, "whitelist");
load!(bans, "banned-players");
load!(ips, "banned-ips");
if let Ok(actions) = std::fs::read_to_string(dir.join("actions.txt")) {
any = true;
for line in actions.lines().filter(|l| !l.trim().is_empty()) {
let a: Vec<&str> = line.split('\t').collect();
let user = |n: &str, u: &str| NameAndId {
id: horizon_client::server::properties::parse_uuid(u).unwrap(),
name: n.to_owned(),
};
fn ban<T>(
user: T,
created: &str,
source: &str,
expires: &str,
reason: &str,
) -> BanEntry<T> {
let date = |s: &str| (s != "-").then(|| s.parse::<i64>().unwrap());
BanEntry {
user,
created: date(created).unwrap_or_else(now_millis),
source: if source != "-" {
source.to_owned()
} else {
"(Unknown)".into()
},
expires: date(expires),
reason: (reason != "-").then(|| reason.to_owned()),
}
}
let r = match a[0] {
"op" => ops.add(OpEntry {
user: user(a[1], a[2]),
level: a[3].parse::<i32>()?.clamp(0, 4),
bypasses_player_limit: a[4].eq_ignore_ascii_case("true"),
}),
"deop" => ops.remove(&user(a[1], a[2])),
"white" => white.add(WhiteEntry {
user: user(a[1], a[2]),
}),
"unwhite" => white.remove(&user(a[1], a[2])),
"ban" => bans.add(ban(user(a[1], a[2]), a[3], a[4], a[5], a[6])),
"pardon" => bans.remove(&user(a[1], a[2])),
"banip" => ips.add(ban(a[1].to_owned(), a[2], a[3], a[4], a[5])),
"pardonip" => ips.remove(&a[1].to_owned()),
"isbanned" => bans.contains(&user(a[1], a[2])),
"isop" => ops.get(&user(a[1], a[2])).is_some(),
other => return Err(format!("unknown action {other}").into()),
};
writeln!(out, "action {} {r}", a[0])?;
}
}
if any {
for (name, json) in [
("ops", ops.to_json()),
("whitelist", white.to_json()),
("banned-players", bans.to_json()),
("banned-ips", ips.to_json()),
] {
writeln!(out, "{name}:\n{json}")?;
}
let mut names: Vec<String> = ops.entries().iter().map(|e| e.user.name.clone()).collect();
names.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
writeln!(out, "op users {}", names.join(","))?;
let mut names: Vec<String> = white
.entries()
.iter()
.map(|e| e.user.name.clone())
.collect();
names.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
writeln!(out, "white users {}", names.join(","))?;
}
let _ = std::fs::remove_dir_all(&w);
Ok(())
}
fn print_properties(p: &ServerProperties, before: &str, out: &mut String) -> std::fmt::Result {
let secret = if has_key(before, "management-server-secret") {
p.management_server_secret.clone()
} else {
"<random>".into()
};
let pack = p.resource_pack.as_ref().map_or_else(
|| "none".to_owned(),
|i| {
format!(
"{} {} {} {} {}",
uuid_string(i.id),
i.url,
i.hash,
i.required,
i.prompt.is_some()
)
},
);
let opt_bool = |b: Option<bool>| b.map_or_else(|| "null".to_owned(), |b| b.to_string());
let rows: Vec<(&str, String)> = vec![
("online-mode", p.online_mode.to_string()),
(
"prevent-proxy-connections",
p.prevent_proxy_connections.to_string(),
),
("server-ip", p.server_ip.clone()),
("allow-flight", p.allow_flight.to_string()),
("motd", p.motd.clone()),
("enable-code-of-conduct", p.code_of_conduct.to_string()),
("bug-report-link", p.bug_report_link.clone()),
("force-gamemode", p.force_game_mode.to_string()),
("enforce-whitelist", p.enforce_whitelist.to_string()),
("difficulty", DIFFICULTIES[p.difficulty as usize].to_owned()),
("gamemode", GAME_MODES[p.game_mode as usize].to_owned()),
("level-name", p.level_name.clone()),
("server-port", p.server_port.to_string()),
(
"management-server-enabled",
p.management_server_enabled.to_string(),
),
("management-server-host", p.management_server_host.clone()),
(
"management-server-port",
p.management_server_port.to_string(),
),
("management-server-secret", secret),
(
"management-server-tls-enabled",
p.management_server_tls_enabled.to_string(),
),
(
"management-server-tls-keystore",
p.management_server_tls_keystore.clone(),
),
(
"management-server-tls-keystore-password",
p.management_server_tls_keystore_password.clone(),
),
(
"management-server-allowed-origins",
p.management_server_allowed_origins.clone(),
),
(
"announce-player-achievements",
opt_bool(p.announce_player_achievements),
),
("enable-query", p.enable_query.to_string()),
("query.port", p.query_port.to_string()),
("enable-rcon", p.enable_rcon.to_string()),
("rcon.port", p.rcon_port.to_string()),
("rcon.password", p.rcon_password.clone()),
("hardcore", p.hardcore.to_string()),
("use-native-transport", p.use_native_transport.to_string()),
("spawn-protection", p.spawn_protection.to_string()),
("op-permission-level", p.op_permission_level.to_string()),
(
"function-permission-level",
p.function_permission_level.to_string(),
),
("max-tick-time", p.max_tick_time.to_string()),
(
"max-chained-neighbor-updates",
p.max_chained_neighbor_updates.to_string(),
),
("rate-limit", p.rate_limit.to_string()),
(
"command-spam-threshold-seconds",
p.command_spam_threshold_seconds.to_string(),
),
(
"chat-spam-threshold-seconds",
p.chat_spam_threshold_seconds.to_string(),
),
("view-distance", p.view_distance.to_string()),
("simulation-distance", p.simulation_distance.to_string()),
("max-players", p.max_players.to_string()),
(
"network-compression-threshold",
p.network_compression_threshold.to_string(),
),
("broadcast-rcon-to-ops", p.broadcast_rcon_to_ops.to_string()),
(
"broadcast-console-to-ops",
p.broadcast_console_to_ops.to_string(),
),
("max-world-size", p.max_world_size.to_string()),
("sync-chunk-writes", p.sync_chunk_writes.to_string()),
("region-file-compression", p.region_file_compression.clone()),
("enable-jmx-monitoring", p.enable_jmx_monitoring.to_string()),
("enable-status", p.enable_status.to_string()),
("hide-online-players", p.hide_online_players.to_string()),
(
"entity-broadcast-range-percentage",
p.entity_broadcast_range_percentage.to_string(),
),
("text-filtering-config", p.text_filtering_config.clone()),
(
"text-filtering-version",
p.text_filtering_version.to_string(),
),
("player-idle-timeout", p.player_idle_timeout.to_string()),
(
"status-heartbeat-interval",
p.status_heartbeat_interval.to_string(),
),
("white-list", p.white_list.to_string()),
(
"enforce-secure-profile",
p.enforce_secure_profile.to_string(),
),
("log-ips", p.log_ips.to_string()),
(
"pause-when-empty-seconds",
p.pause_when_empty_seconds.to_string(),
),
("accepts-transfers", p.accepts_transfers.to_string()),
(
"seed",
if p.random_seed {
"<random>".into()
} else {
p.seed.to_string()
},
),
("generate-structures", p.generate_structures.to_string()),
("resource-pack", pack),
("initial-enabled-packs", p.initial_enabled_packs.join("|")),
("initial-disabled-packs", p.initial_disabled_packs.join("|")),
];
for (k, v) in rows {
writeln!(out, "{k}={v}")?;
}
Ok(())
}
@@ -0,0 +1,47 @@
//! `getSituationalMusic`/`getMusicVolume` through Horizon's `Situation` for
//! `tools/test_situational_music_parity.py`:
//! `situational_music_fixture ROOT INPUT OUTPUT`.
use horizon_client::music::{BackgroundMusic, Music, MusicTables, Situation};
use horizon_client::sound_events::normalize;
use std::io::Write;
fn main() {
let args: Vec<String> = std::env::args().collect();
let tables = MusicTables::load(std::path::Path::new(&args[1]));
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
let f: Vec<&str> = line.split(' ').collect();
let in_world = f[1] == "1";
let (background, probe_volume) = if in_world {
(tables.background(f[2], f[3]), tables.volume(f[2], f[3]))
} else {
(BackgroundMusic::default(), 1.0)
};
let situation = Situation {
screen_music: (f[0] == "win").then(Music::credits),
in_world,
in_end: f[2] == "minecraft:the_end",
boss_music: f[4].contains("music"),
background: &background,
probe_volume,
creative: f[5] == "1" && f[6] == "1",
underwater: f[7] == "1",
};
let music = situation.music().map_or("-".to_owned(), |m| {
format!(
"{},{},{},{}",
normalize(&m.sound),
m.min_delay,
m.max_delay,
m.replace_current_music
)
});
writeln!(
out,
"{line} music={music} volume={:08x}",
situation.volume().to_bits()
)
.expect("write");
}
}
@@ -0,0 +1,108 @@
//! Replays `tools/test_sound_engine_parity.py` scripts through Horizon's
//! `SoundEngine` with a backend recording what an OpenAL source would hold:
//! `sound_engine_fixture ROOT INPUT OUTPUT`.
#[path = "audio_common/mod.rs"]
mod common;
use common::{Fake, d, describe, f, flush, result_name};
use horizon_client::sound_engine::{
Attenuation, Backend, InstanceId, SoundEngine, SoundInstance, TickContext,
};
use horizon_client::sound_events::SoundRegistry;
use horizon_core::random::LegacyRandom;
use std::collections::BTreeMap;
use std::io::Write;
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
engine.listening = true;
let mut backend = Fake::default();
let mut ctx = TickContext::default();
let mut instances: BTreeMap<String, InstanceId> = BTreeMap::new();
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
writeln!(out, "{line}").expect("write");
let a: Vec<&str> = line.split(' ').collect();
let source = |i: usize| a[i].parse::<usize>().expect("source");
match a[0] {
"play" => {
let event = horizon_client::sound_events::normalize(a[3]);
let random = LegacyRandom::new(a[14].parse().expect("seed"));
let instance = match a[2] {
"simple" => SoundInstance::new(
&event,
source(4),
f(a[5]),
f(a[6]),
random,
a[10] == "1",
a[11].parse().expect("delay"),
if a[12] == "linear" {
Attenuation::Linear
} else {
Attenuation::None
},
[d(a[7]), d(a[8]), d(a[9])],
a[13] == "1",
),
"loop" => SoundInstance::biome_loop(&event, random),
"uloop" => SoundInstance::under_liquid_loop(&event, random),
"usub" => SoundInstance::under_liquid_sub(&event, random),
_ => panic!("{line}"),
};
if let Some(old) = instances.remove(a[1]) {
engine.release(old);
}
let (id, result) = engine.play_new(instance, true, &mut backend);
instances.insert(a[1].to_owned(), id);
writeln!(out, " result {}", result_name(result)).expect("write");
}
"replay" => {
let result = engine.play(instances[a[1]], &mut backend);
writeln!(out, " result {}", result_name(result)).expect("write");
}
"queue" => engine.queue_ticking(instances[a[1]]),
"delayed" => engine.play_delayed(instances[a[1]], a[2].parse().expect("delay")),
"fade" => {
let i = engine.instance_mut(instances[a[1]]).expect("instance");
if a[2] == "in" {
i.fade_in();
} else {
i.fade_out();
}
}
"stop" => engine.stop(instances[a[1]], &mut backend),
"stopmatch" => {
let sound = (a[1] != "-").then(|| horizon_client::sound_events::normalize(a[1]));
let src = (a[2] != "-").then(|| source(2));
engine.stop_matching(sound.as_deref(), src, &mut backend);
}
"finish" => {
if let Some(c) = engine.channel(instances[a[1]]) {
backend.stop(c);
}
}
"gain" => engine.update_category_volume(source(1), f(a[2]), &mut backend),
"volume" => engine.source_volumes[source(1)] = f(a[2]),
"refresh" => engine.refresh_category_volume(source(1), &mut backend),
"pause" => engine.pause_all_except(&[1, 10], &mut backend),
"resume" => engine.resume(&mut backend),
"player" => {
ctx.player_underwater = a[1] == "1";
ctx.player_removed = a[2] == "1";
}
"tick" => engine.tick(a[1] == "1", &ctx, &mut backend),
"forget" => {
if let Some(id) = instances.remove(a[1]) {
engine.release(id);
}
}
_ => panic!("{line}"),
}
for (name, id) in &instances {
writeln!(out, "{}", describe(&engine, &backend, name, *id)).expect("write");
}
flush(&mut engine, &mut out);
}
}
@@ -0,0 +1,78 @@
//! Resolves sound instances through Horizon's sound event registry for
//! `tools/test_sound_event_parity.py`: `sound_event_fixture ROOT INPUT OUTPUT [NAMES]`.
use horizon_client::sound_engine::{Attenuation, MASTER, SoundEngine, SoundInstance};
use horizon_client::sound_events::{Sentinel, SoundRegistry};
use horizon_core::random::{LegacyRandom, RandomSource};
use std::io::Write;
fn bits(v: f32) -> String {
format!("{:08x}", v.to_bits())
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let registry = SoundRegistry::load(std::path::Path::new(&args[1]));
let mut engine = SoundEngine::new(registry);
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
let mut names: Vec<String> = engine.registry.names().cloned().collect();
names.sort();
for name in names {
writeln!(out, "registered {name}").expect("write");
}
for line in input.lines() {
let f: Vec<&str> = line.split(' ').collect();
let seed: i64 = f[1].parse().expect("seed");
let volume = f32::from_bits(u32::from_str_radix(f[2], 16).expect("volume"));
let pitch = f32::from_bits(u32::from_str_radix(f[3], 16).expect("pitch"));
let event = horizon_client::sound_events::normalize(f[0]);
let instance = SoundInstance::new(
&event,
MASTER,
volume,
pitch,
LegacyRandom::new(seed),
false,
0,
Attenuation::Linear,
[0.0; 3],
false,
);
let id = engine.add(instance, true);
engine.resolve(id);
let weight = engine.registry.weight(&event);
let i = engine.instance_mut(id).expect("instance");
let mut s = line.to_owned();
match &i.event {
None => s.push_str(" event=none"),
Some(subtitle) => s.push_str(&format!(
" event weight={weight} subtitle={}",
subtitle.as_deref().unwrap_or("-")
)),
}
let v = i.get_volume();
let p = i.get_pitch();
let sound = i.sound.clone().expect("resolved");
let (namespace, path) = sound.path();
let sentinel = match sound.sentinel {
Sentinel::None => "-",
Sentinel::Empty => "empty",
Sentinel::IntentionallyEmpty => "intentionally_empty",
};
s.push_str(&format!(
" sound={} path={namespace}:{path} sentinel={sentinel} weight={} type={} stream={} preload={} attenuation={} volume={} pitch={} range={} next={}",
sound.location,
sound.weight,
if sound.event { "SOUND_EVENT" } else { "FILE" },
sound.stream,
sound.preload,
sound.attenuation_distance,
bits(v),
bits(p),
bits(sound.attenuation_distance(v)),
i.random.next_long()
));
writeln!(out, "{s}").expect("write");
engine.release(id);
}
}
@@ -0,0 +1,233 @@
//! Spawn placement and spawn predicate answers for differential tests against
//! vanilla (tools/test_spawn_rules_parity.py): reads the column-world cases of
//! `VanillaSpawnRulesOracle` and prints `mobs::spawn_rules`' answers.
//!
//! usage: spawn_rules_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
assets::Assets,
blocks::BlockFacts,
items::Items,
mobs::{
MobTypes,
data::MobData,
spawn_rules::{self, Dimension, Reason, SpawnLevel},
},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::{chunk::BlockState, registry::BlockStateId};
use std::{
collections::{BTreeMap, HashMap, HashSet},
fmt::Write as _,
fs,
path::Path,
};
struct Column<'a> {
facts: &'a BlockFacts,
data: &'a MobData,
block_tags: &'a HashMap<String, HashSet<String>>,
air: BlockStateId,
blocks: HashMap<i32, BlockStateId>,
x0: i32,
z0: i32,
sky: i32,
light: i32,
darken: i32,
thunder: bool,
sea: i32,
surface: i32,
seed: i64,
peaceful: bool,
dimension: Dimension,
biome: String,
slime: f32,
/// A player position and whether it is survival or adventure.
player: Option<([f64; 3], bool)>,
}
impl SpawnLevel for Column<'_> {
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
if x != self.x0 || z != self.z0 {
return self.air;
}
self.blocks.get(&y).copied().unwrap_or(self.air)
}
fn facts(&self) -> &BlockFacts {
self.facts
}
fn mob_data(&self) -> &MobData {
self.data
}
fn block_tag(&self, tag: &str, block: &str) -> bool {
self.block_tags.get(tag).is_some_and(|s| s.contains(block))
}
fn biome_tag(&self, tag: &str, _: i32, _: i32, _: i32) -> bool {
self.data
.biome_tags
.get(tag)
.is_some_and(|s| s.contains(&self.biome))
}
fn sky_light(&self, _: i32, _: i32, _: i32) -> i32 {
self.sky
}
fn block_light(&self, _: i32, _: i32, _: i32) -> i32 {
self.light
}
fn sky_darken(&self) -> i32 {
self.darken
}
fn thundering(&self) -> bool {
self.thunder
}
fn sea_level(&self) -> i32 {
self.sea
}
fn world_surface(&self, _: i32, _: i32) -> i32 {
self.surface
}
fn peaceful(&self) -> bool {
self.peaceful
}
fn dimension(&self) -> Dimension {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
self.slime
}
fn world_seed(&self) -> i64 {
self.seed
}
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
self.player.is_some_and(|(p, targetable)| {
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
targetable && d < distance * distance
})
}
}
/// `name[key=value,...]`.
fn parse_state(spec: &str) -> BlockState {
let (name, props) = match spec.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (spec, ""),
};
let properties: BTreeMap<String, String> = props
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
BlockState {
name: name.to_owned(),
properties,
}
}
fn reason(name: &str) -> Reason {
match name {
"chunk_generation" => Reason::ChunkGeneration,
"spawner" => Reason::Spawner,
"trial_spawner" => Reason::TrialSpawner,
"reinforcement" => Reason::Reinforcement,
"patrol" => Reason::Patrol,
"structure" => Reason::Structure,
"event" => Reason::Event,
"command" => Reason::Command,
_ => Reason::Natural,
}
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(
args.get(1)
.ok_or("usage: spawn_rules_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let types = MobTypes::load(root)?;
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() {
continue;
}
let mut c = BTreeMap::new();
let mut name = "";
for token in raw.split_whitespace() {
match token.split_once('=') {
Some((k, v)) => {
c.insert(k, v);
}
None => name = token,
}
}
let get =
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
let Some(kind) = types.types.get(c["type"]) else {
let _ = writeln!(out, "{name} error unsupported {}", c["type"]);
continue;
};
let y: i32 = get("y", "64").parse()?;
let (x0, z0): (i32, i32) = (get("x", "8").parse()?, get("z", "8").parse()?);
let mut blocks = HashMap::new();
for (k, v) in &c {
if let Some(offset) = k.strip_prefix("at") {
let offset: i32 = offset.parse()?;
blocks.insert(y + offset, registry.resolve(&parse_state(v))?);
}
}
let player = c.get("player").map(|p| {
let f: Vec<&str> = p.split(',').collect();
let at = |i: usize, base: i32| f64::from(base) + f[i].parse::<f64>().unwrap_or(0.0);
let mode = f.get(3).copied().unwrap_or("survival");
(
[at(0, x0), at(1, y), at(2, z0)],
mode == "survival" || mode == "adventure",
)
});
let level = Column {
facts: &facts,
data: &data,
block_tags: &items.block_tags,
air: facts.air,
blocks,
x0,
z0,
sky: get("sky", "15").parse()?,
light: get("light", "0").parse()?,
darken: get("darken", "0").parse()?,
thunder: get("thunder", "0") == "1",
sea: get("sea", "63").parse()?,
surface: get("surface", "0").parse()?,
seed: get("wseed", "0").parse()?,
peaceful: get("difficulty", "normal") == "peaceful",
dimension: match get("dim", "minecraft:overworld").as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
},
biome: get("biome", "minecraft:plains"),
slime: get("slime", "0.0").parse()?,
player,
};
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
let s = kind.species;
let ok = spawn_rules::is_spawn_position_ok(s, &level, x0, y, z0);
let rules = spawn_rules::check_spawn_rules(
s,
&level,
reason(&get("reason", "natural")),
&mut random,
x0,
y,
z0,
);
let next = random.next_int_bound(1000).unwrap_or(0);
let _ = writeln!(out, "{name} {ok} {rules} {next}");
}
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -0,0 +1,121 @@
//! The subtitle overlay for differential tests against vanilla's
//! `SubtitleOverlay` (tools/test_subtitle_parity.py): replays each case's
//! sound plays, clock changes and frames through
//! `horizon_client::subtitles` and prints every frame's fills and texts with
//! their pose, in VanillaSubtitleOracle's JSON lines.
//!
//! usage: subtitle_fixture RESOURCES INPUTS OUTPUT
use horizon_client::{
hud::HudAtlas,
hud_layout::{GuiDraw, IDENTITY, background_color, pose_apply},
hud_overlays::{SubtitleFrame, SubtitleOverlay},
lang,
};
use serde_json::{Value, json};
use std::{fs, io::Write, path::PathBuf};
fn vec3(v: &Value) -> [f64; 3] {
std::array::from_fn(|i| v[i].as_f64().unwrap_or(0.0))
}
fn pose(m: [f32; 6]) -> Value {
json!(m)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let [root, inputs, output] = args.as_slice() else {
return Err("usage: subtitle_fixture RESOURCES INPUTS OUTPUT".into());
};
lang::set(lang::Language::load(&[root], lang::DEFAULT));
let atlas = HudAtlas::build(root)?;
let mut out = std::io::BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(inputs)?
.lines()
.filter(|l| !l.is_empty())
{
let c: Value = serde_json::from_str(line)?;
let mut overlay = SubtitleOverlay::default();
let mut now = 0i64;
let mut frames = Vec::new();
for op in c["ops"].as_array().into_iter().flatten() {
match op["op"].as_str().unwrap_or_default() {
// Util.getMillis: nanoseconds / 1_000_000.
"time" => now = op["ms"].as_i64().unwrap_or(0),
"play" => {
if overlay.listening() {
let range = op["range"].as_f64().map_or(f32::INFINITY, |r| r as f32);
overlay.on_play(
op["key"].as_str(),
vec3(&op["at"]),
range,
op["looping"].as_bool().unwrap_or(false),
now,
);
}
}
"frame" => {
let frame = SubtitleFrame {
show: op["show"].as_bool().unwrap_or(false),
position: vec3(&op["pos"]),
forward: vec3(&op["forward"]),
up: vec3(&op["up"]),
multiplier: op["mult"].as_f64().unwrap_or(1.0),
background: background_color(
op["chat_only"].as_bool().unwrap_or(true),
op["opacity"].as_f64().unwrap_or(0.5),
0.8,
),
width: op["w"].as_i64().unwrap_or(0) as i32,
height: op["h"].as_i64().unwrap_or(0) as i32,
};
let mut stack = vec![IDENTITY];
let mut draws = Vec::new();
let calls = overlay.extract(&frame, &atlas.font, now);
if !calls.is_empty() {
draws.push(json!({"type": "stratum"}));
}
for d in calls {
let m = *stack.last().unwrap_or(&IDENTITY);
match d {
GuiDraw::Push(ops) => {
let mut top = m;
for op in ops {
pose_apply(&mut top, op);
}
stack.push(top);
}
GuiDraw::Pop => {
stack.pop();
}
GuiDraw::Fill {
x0,
y0,
x1,
y1,
color,
} => draws.push(json!({
"type": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1,
"color": format!("{color:x}"), "pose": pose(m),
})),
GuiDraw::Text {
text,
x,
y,
color,
shadow,
} => draws.push(json!({
"type": "text", "text": text.plain(), "x": x, "y": y,
"color": format!("{color:x}"), "shadow": shadow, "pose": pose(m),
})),
other => return Err(format!("unexpected draw {other:?}").into()),
}
}
frames.push(Value::Array(draws));
}
other => return Err(format!("op {other}").into()),
}
}
writeln!(out, "{}", json!({"case": c["case"], "frames": frames}))?;
}
Ok(())
}
@@ -0,0 +1,172 @@
//! Headless transport for the player list parity gate: builds each case's
//! scoreboard (teams, `list` objective, scores), listed players, header and
//! footer, replays its frames through `horizon_client::tab_list` and writes
//! the draw calls in vanilla's GuiGraphicsExtractor terms (text as styled
//! runs, faces as skin blits).
#![forbid(unsafe_code)]
use horizon_client::{
component,
hud::HudAtlas,
hud_layout::GuiDraw,
lang,
scoreboard::{NumberFormat, RenderType, Scoreboard},
tab_list::{self, TabInputs, TabList, TabPlayer},
text::Text,
};
use serde_json::{Value, json};
use std::{
fs,
io::{BufWriter, Write},
path::PathBuf,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn text(v: &Value) -> Option<Text> {
(!v.is_null()).then(|| component::to_text(v, &mut |_| Text::empty()))
}
fn format(v: &Value) -> Option<NumberFormat> {
match v["type"].as_str()? {
"blank" => Some(NumberFormat::Blank),
"styled" => Some(NumberFormat::Styled(v["style"].clone())),
"fixed" => Some(NumberFormat::Fixed(v["value"].clone())),
_ => None,
}
}
fn runs(t: &Text) -> Value {
let mut out: Vec<(String, Value)> = Vec::new();
for (c, s) in t.chars() {
let key = json!([
s.color.map_or(-1, i64::from),
s.is_bold(),
s.is_italic(),
s.underlined == Some(true),
s.strikethrough == Some(true),
s.obfuscated == Some(true)
]);
match out.last_mut() {
Some((run, k)) if *k == key => run.push(c),
_ => out.push((c.to_string(), key)),
}
}
out.into_iter().map(|(r, k)| json!([r, k])).collect()
}
fn main() -> Result<()> {
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let [root, inputs, output] = args.as_slice() else {
return Err("usage: tab_list_fixture RESOURCES INPUTS OUTPUT".into());
};
let atlas = HudAtlas::build(root)?;
lang::set(lang::Language::load(&[root], "en_us"));
let font = &atlas.font;
let mut out = BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(inputs)?
.lines()
.filter(|l| !l.is_empty())
{
let c: Value = serde_json::from_str(line)?;
let mut board = Scoreboard::default();
for t in c["teams"].as_array().into_iter().flatten() {
let name = t["name"].as_str().unwrap_or("");
let team = board.add_team(name);
team.color = t["color"].as_str().map(str::to_owned);
team.prefix = t["prefix"].clone();
team.suffix = t["suffix"].clone();
for m in t["members"].as_array().into_iter().flatten() {
board.add_to_team(m.as_str().unwrap_or(""), name);
}
}
let objective_name = c["objective"]["name"].as_str().map(str::to_owned);
if let Some(name) = &objective_name {
let hearts = c["objective"]["renderType"] == "hearts";
board.add_objective(
name,
"dummy",
Value::String("x".into()),
if hearts {
RenderType::Hearts
} else {
RenderType::Integer
},
false,
format(&c["objective"]["numberFormat"]),
);
}
let mut players = Vec::new();
let mut upside = Vec::new();
for p in c["players"].as_array().into_iter().flatten() {
let uuid =
u128::from_str_radix(&p["uuid"].as_str().unwrap_or("0").replace('-', ""), 16)?;
let name = p["name"].as_str().unwrap_or("").to_owned();
if p["inLevel"].as_bool().unwrap_or(false) && (name == "Dinnerbone" || name == "Grumm")
{
upside.push(uuid);
}
let mut player = TabPlayer::new(uuid, name);
player.game_mode = p["gameMode"].as_i64().unwrap_or(0) as i32;
player.latency = p["latency"].as_i64().unwrap_or(0) as i32;
player.display_name = (!p["displayName"].is_null()).then(|| p["displayName"].clone());
player.list_order = p["listOrder"].as_i64().unwrap_or(0) as i32;
player.show_hat = p["showHat"].as_bool().unwrap_or(true);
players.push(player);
}
let mut list = TabList::default();
list.header = text(&c["header"]);
list.footer = text(&c["footer"]);
let background = tab_list::row_background(
c["backgroundForChatOnly"].as_bool().unwrap_or(true),
c["opacity"].as_f64().unwrap_or(0.5),
);
let mut frames = Vec::new();
for f in c["frames"].as_array().into_iter().flatten() {
if let Some(obj) = &objective_name {
for (holder, v) in f["scores"].as_object().into_iter().flatten() {
let s = board.score_mut(holder, obj);
s.value = v["value"].as_i64().unwrap_or(0) as i32;
s.number_format = format(&v["format"]);
}
for r in f["reset"].as_array().into_iter().flatten() {
board.reset_one(r.as_str().unwrap_or(""), obj);
}
}
let visible = f["visible"].as_bool().unwrap_or(false);
list.set_visible(visible);
let mut draws = Vec::new();
if visible {
let up = |id: u128| upside.contains(&id);
let inputs = TabInputs {
players: &players,
board: &board,
objective: objective_name.as_deref().and_then(|n| board.objective(n)),
screen_width: c["width"].as_i64().unwrap_or(0) as i32,
online_mode: c["onlineMode"].as_bool().unwrap_or(false),
gui_ticks: f["tick"].as_i64().unwrap_or(0),
background,
upside_down: &up,
};
for d in list.layout(&inputs, font) {
draws.push(match d {
GuiDraw::Fill { x0, y0, x1, y1, color } => {
json!({"type": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1, "color": color as i32})
}
GuiDraw::Sprite { sprite, x, y, w, h, .. } => {
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h})
}
GuiDraw::Text { text, x, y, color, shadow } => json!({
"type": "text", "runs": runs(&text), "x": x, "y": y, "color": color as i32, "shadow": shadow
}),
GuiDraw::Blit { texture, x, y, w, h, uv, src, tex, color, .. } => {
let a = [x as f64, y as f64, f64::from(uv[0]), f64::from(uv[1]), w as f64, h as f64,
src[0] as f64, src[1] as f64, tex[0] as f64, tex[1] as f64];
json!({"type": "blit", "texture": texture, "args": a, "color": color as i32})
}
other => json!({"type": "unexpected", "draw": format!("{other:?}")}),
});
}
}
frames.push(Value::Array(draws));
}
writeln!(out, "{}", json!({ "frames": frames }))?;
}
Ok(())
}
@@ -0,0 +1,50 @@
//! Team rules in gameplay for differential tests against vanilla
//! (tools/test_team_rules_parity.py): friendly fire, alliances, collision
//! rules, seeing invisible team mates and name tag visibility, from
//! `horizon_client::scoreboard`, in VanillaTeamRulesOracle's JSON lines.
//!
//! usage: team_rules_fixture INPUTS OUTPUT
use horizon_client::scoreboard::{self as sb, CollisionRule, NameView, Scoreboard, Visibility};
use serde_json::{Value, json};
use std::{fs, io::Write};
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut out = std::io::BufWriter::new(fs::File::create(&args[2]).expect("output"));
for line in fs::read_to_string(&args[1]).expect("inputs").lines() {
let c: Value = serde_json::from_str(line).expect("case");
let mut board = Scoreboard::default();
for name in ["A", "B"] {
let o = &c[name];
let t = board.add_team(name);
t.allow_friendly_fire = o["ff"].as_bool().unwrap_or(true);
t.see_friendly_invisibles = o["si"].as_bool().unwrap_or(true);
t.collision_rule =
CollisionRule::by_name(o["collision"].as_str().unwrap_or("")).expect("rule");
t.nametag_visibility =
Visibility::by_name(o["nametag"].as_str().unwrap_or("")).expect("visibility");
}
let team = |k: &str| c[k].as_str().and_then(|n| board.team(n));
let flag = |k: &str| c[k].as_bool().unwrap_or(false);
let (own, other, viewer) = (team("own"), team("other"), team("viewer"));
let invisible =
sb::invisible_to(other, viewer, flag("viewer_spectator"), flag("invisible"));
let view = NameView {
discrete: flag("discrete"),
distance_sqr: c["distance"].as_f64().unwrap_or(0.0),
invisible_to_viewer: invisible,
hud_hidden: flag("hud_hidden"),
is_camera: flag("camera_is_entity"),
is_vehicle: flag("vehicle"),
};
let r = json!({
"case": c["case"],
"damage": sb::teams_allow_damage(own, other),
"allied": sb::teams_allied(own, other),
"pushes": !flag("spectator") && sb::pushable_by(own, other, flag("pushable")),
"invisible": invisible,
"name": sb::name_shown(other, viewer, view),
});
writeln!(out, "{r}").expect("write");
}
}
@@ -0,0 +1,56 @@
#![forbid(unsafe_code)]
//! Runs tools/test_toggle_keys_parity.py's event sequences through
//! `ToggleKeys`, the sneak/sprint/attack/use toggle mappings.
use horizon_client::{
Result,
keymapping::{KeyAction, ToggleKeys},
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let mut keys = ToggleKeys::default();
let mut options = [false; 4];
for op in c["ops"].as_array().unwrap() {
let name = op[0].as_str().unwrap();
let slot = || op[1].as_u64().unwrap() as usize;
match name {
"option" => options[slot()] = op[2].as_bool().unwrap(),
"key" => {
let action = match op[2].as_i64().unwrap() {
0 => KeyAction::Release,
1 => KeyAction::Press,
_ => KeyAction::Repeat,
};
keys.key(slot(), action, op[3].as_bool().unwrap(), options[slot()]);
}
"button" => keys.button(
slot(),
op[2].as_bool().unwrap(),
op[3].as_bool().unwrap(),
options[slot()],
),
"open" => keys.screen_opened(options),
"close" => keys.screen_closed(options),
"respawn" => keys.respawn(),
other => panic!("{other}"),
}
let mut line = name.to_string();
for k in &keys.0 {
line += &format!(" {} {}", k.is_down, k.pending_clicks());
}
writeln!(out, "{line}")?;
}
}
out.flush()?;
Ok(())
}
@@ -0,0 +1,111 @@
//! Headless transport for the world border parity gate: replays each case's
//! border changes, ticks and frames through `horizon_client::worldborder`,
//! `border_render` and `hud_overlays::vignette`, and writes what the
//! vanilla renderer computes (bounds, fade, wall buffer, transforms, walls
//! drawn, vignette colour) with floats as raw bits.
#![forbid(unsafe_code)]
use horizon_client::{
border_render::{self, BorderRenderState, BorderRenderer},
hud_layout::GuiDraw,
hud_overlays,
worldborder::{BorderStatus, WorldBorder},
};
use serde_json::{Value, json};
use std::{
fs,
io::{BufWriter, Write},
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn floats(v: &[f32]) -> Value {
v.iter().map(|f| f.to_bits() as i32).collect()
}
fn doubles(v: &[f64]) -> Value {
v.iter().map(|f| f.to_bits() as i64).collect()
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let [inputs, output] = args.as_slice() else {
return Err("usage: world_border_render_fixture INPUTS OUTPUT".into());
};
let mut out = BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(inputs)?
.lines()
.filter(|l| !l.is_empty())
{
let c: Value = serde_json::from_str(line)?;
let f = |v: &Value| v.as_f64().unwrap_or(0.0);
let mut border = WorldBorder::default();
border.warning_distance = c["warningBlocks"].as_i64().unwrap_or(0) as i32;
border.warning_time = c["warningTime"].as_i64().unwrap_or(0) as i32;
border.set_absolute_max_size(c["absoluteMaxSize"].as_i64().unwrap_or(0) as i32);
border.set_center([f(&c["centerX"]), f(&c["centerZ"])]);
border.set_size(f(&c["size"]));
let mut renderer = BorderRenderer::default();
let mut state = BorderRenderState::default();
let mut frames = Vec::new();
for op in c["ops"].as_array().into_iter().flatten() {
match op["op"].as_str().unwrap_or("") {
"tick" => (0..op["n"].as_i64().unwrap_or(0)).for_each(|_| border.tick()),
"lerp" => border.lerp_between(
f(&op["from"]),
f(&op["to"]),
op["ticks"].as_i64().unwrap_or(0),
),
"size" => border.set_size(f(&op["size"])),
"center" => border.set_center([f(&op["x"]), f(&op["z"])]),
"warning" => {
border.warning_distance = op["blocks"].as_i64().unwrap_or(0) as i32;
border.warning_time = op["time"].as_i64().unwrap_or(0) as i32;
}
"invalidate" => renderer.invalidate(),
"frame" => {
let partial = f(&op["partial"]) as f32;
let p = &op["camera"];
let camera = [f(&p[0]), f(&p[1]), f(&p[2])];
let distance = f(&op["renderDistance"]);
let millis = op["millis"].as_i64().unwrap_or(0);
let status = match border.status() {
BorderStatus::Growing => "GROWING",
BorderStatus::Shrinking => "SHRINKING",
BorderStatus::Stationary => "STATIONARY",
};
let mut frame = json!({
"bounds": doubles(&border.bounds_at(partial)),
"sizes": doubles(&[border.size(), border.target_size(), border.lerp_speed(), border.distance(camera[0], camera[2])]),
"lerpTime": border.remaining_ticks(),
"status": status,
});
border_render::extract(&border, partial, camera, distance, &mut state);
frame["alpha"] = doubles(&[state.alpha]);
frame["tint"] = json!(state.tint as i32);
let drawn =
renderer.prepare(&state, camera, distance, f(&op["depthFar"]) as f32);
frame["drawn"] = json!(drawn);
frame["rebuilt"] = json!(renderer.take_rebuilt());
if drawn {
frame["vertices"] = floats(&renderer.vertices.concat());
let t = renderer.transforms(&state, camera, millis);
frame["color"] = floats(&t.color);
frame["offset"] = floats(&t.model_offset);
frame["texture"] = floats(&[t.texture_offset, t.texture_offset]);
frame["sides"] = json!(BorderRenderer::sides(&state, camera, distance));
}
let warning = border.warning_strength(camera[0], camera[2]);
let vignette =
hud_overlays::vignette(427, 240, f(&op["vignette"]) as f32, warning);
let color = match vignette {
GuiDraw::Blit { color, .. } => color,
_ => 0,
};
frame["vignetteColor"] = json!(color as i32);
frames.push(frame);
}
other => return Err(format!("unknown op {other}").into()),
}
}
writeln!(out, "{}", json!({ "frames": frames }))?;
}
Ok(())
}
+265 -253
View File
@@ -1,7 +1,9 @@
//! The player's ambient sound handlers: `BiomeAmbientSoundsHandler` (biome
//! loops with 40-tick cross-fades, random additions, cave mood) driven by the
//! `minecraft:audio/ambient_sounds` attribute, `UnderwaterAmbientSoundHandler`
//! and the underwater loop (`UnderLiquidAmbientSoundInstance`).
//! and `LocalPlayer.updateIsUnderwater` (enter/exit sounds and the
//! `UnderLiquidAmbientSoundInstance` loop), played through the sound engine.
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
use horizon_core::random::{LegacyRandom, RandomSource};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
@@ -80,87 +82,83 @@ impl AmbientTables {
.unwrap_or_default()
}
}
/// The sound engine side of the handlers.
pub trait AmbientOutput {
/// A relative tickable instance; None when it could not start.
fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64>;
fn set_volume(&mut self, id: u64, volume: f32);
fn stop(&mut self, id: u64);
fn is_active(&self, id: u64) -> bool;
/// A positional one-shot (`SimpleSoundInstance`, linear attenuation).
fn play_at(&mut self, event: &str, at: [f64; 3]);
}
impl AmbientOutput for crate::audio::Audio {
fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64> {
crate::audio::Audio::play_tracked(self, event, volume, looping)
}
fn set_volume(&mut self, id: u64, volume: f32) {
self.set_tracked_volume(id, volume);
}
fn stop(&mut self, id: u64) {
self.stop_tracked(id);
}
fn is_active(&self, id: u64) -> bool {
self.tracked_active(id)
}
fn play_at(&mut self, event: &str, at: [f64; 3]) {
self.play(event, Some(at), 1.0, 1.0);
}
}
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
struct LoopSound {
id: Option<u64>,
fade: i32,
direction: i32,
stopped: bool,
}
impl LoopSound {
fn tick(&mut self) -> f32 {
if self.fade < 0 {
self.stopped = true;
}
self.fade += self.direction;
(self.fade as f32 / 40.0).clamp(0.0, 1.0)
}
fn fade_out(&mut self) {
self.fade = self.fade.min(40);
self.direction = -1;
}
fn fade_in(&mut self) {
self.fade = self.fade.max(0);
self.direction = 1;
}
}
/// What the handlers read about the player and level each tick.
pub struct AmbientContext<'a> {
/// `AMBIENT_SOUNDS` at the player's position.
pub sounds: &'a AmbientSounds,
/// Feet and eye position.
/// The player's position (feet) and eye height.
pub position: [f64; 3],
pub eye_y: f64,
/// `isEyeInFluid(WATER)` this tick (`Entity.updateIsUnderwater`).
pub underwater: bool,
/// Raw (sky, block) light at a block.
pub spectator: bool,
/// `Level.getBrightness` (sky, block) at a block.
pub light: &'a dyn Fn([i32; 3]) -> (u8, u8),
/// The bubble column in the player's box (its `drag` property), from
/// `bubble_column_in`.
pub bubble_column: Option<bool>,
}
/// `BubbleColumnAmbientSoundHandler`'s lookup: the first bubble column
/// (x fastest, then y, then z) in the player's bounding box inflated by
/// (0, -0.4F, 0) and deflated by 1.0E-6, as `drag_down`. `state` gives the
/// bubble column at a block (None for anything else).
pub fn bubble_column_in(bb: [f64; 6], state: impl Fn([i32; 3]) -> Option<bool>) -> Option<bool> {
// AABB keeps min <= max on every construction.
let make = |a: [f64; 6]| {
[
a[0].min(a[3]),
a[1].min(a[4]),
a[2].min(a[5]),
a[0].max(a[3]),
a[1].max(a[4]),
a[2].max(a[5]),
]
};
let inflate = |b: [f64; 6], x: f64, y: f64, z: f64| {
make([b[0] - x, b[1] - y, b[2] - z, b[3] + x, b[4] + y, b[5] + z])
};
let b = inflate(make(bb), 0.0, f64::from(-0.4f32), 0.0);
let b = inflate(b, -1.0E-6, -1.0E-6, -1.0E-6);
let (x0, y0, z0) = (
b[0].floor() as i32,
b[1].floor() as i32,
b[2].floor() as i32,
);
let (x1, y1, z1) = (
b[3].floor() as i32,
b[4].floor() as i32,
b[5].floor() as i32,
);
for z in z0..=z1 {
for y in y0..=y1 {
for x in x0..=x1 {
if let Some(drag) = state([x, y, z]) {
return Some(drag);
}
}
}
}
None
}
/// The local player's ambient sound handlers.
pub struct Ambience {
/// The client level's random.
random: LegacyRandom,
loops: Vec<(String, LoopSound)>,
pub random: LegacyRandom,
/// `BiomeAmbientSoundsHandler.loopSounds` (insertion order).
loops: Vec<(String, InstanceId)>,
previous_loop: Option<String>,
pub moodiness: f32,
/// `UnderwaterAmbientSoundHandler.tickDelay`.
underwater_delay: i32,
/// The underwater loop instance and its fade.
underwater_loop: Option<(u64, i32)>,
/// UnderLiquidSubSound instances (stop when the player leaves the water).
sub_sounds: Vec<u64>,
/// `LocalPlayer.wasUnderwater`.
was_underwater: bool,
/// `BubbleColumnAmbientSoundHandler.wasInBubbleColumn` / `firstTick`.
was_in_bubble_column: bool,
first_tick: bool,
}
impl Default for Ambience {
fn default() -> Self {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
Self::new(seed)
Self::new(crate::sound_engine::unique_seed())
}
}
impl Ambience {
@@ -171,53 +169,95 @@ impl Ambience {
previous_loop: None,
moodiness: 0.0,
underwater_delay: 0,
underwater_loop: None,
sub_sounds: Vec::new(),
was_underwater: false,
was_in_bubble_column: false,
first_tick: true,
}
}
/// Stop everything (leaving the world).
pub fn clear(&mut self, out: &mut impl AmbientOutput) {
for (_, l) in self.loops.drain(..) {
if let Some(id) = l.id {
out.stop(id);
}
}
if let Some((id, _)) = self.underwater_loop.take() {
out.stop(id);
}
for id in self.sub_sounds.drain(..) {
out.stop(id);
/// `LocalPlayer.isUnderWater`.
pub fn was_underwater(&self) -> bool {
self.was_underwater
}
/// `BiomeAmbientSoundsHandler.loopSounds`.
pub fn loops(&self) -> &[(String, InstanceId)] {
&self.loops
}
/// `UnderwaterAmbientSoundHandler.tickDelay`.
pub fn underwater_delay(&self) -> i32 {
self.underwater_delay
}
/// A new player (leaving or changing worlds): fresh handlers.
pub fn clear(&mut self, sounds: &mut impl Sounds) {
for (_, id) in self.loops.drain(..) {
sounds.release(id);
}
self.previous_loop = None;
self.moodiness = 0.0;
self.underwater_delay = 0;
self.was_underwater = false;
self.was_in_bubble_column = false;
self.first_tick = true;
}
/// `LocalPlayer.updateIsUnderwater` enter/exit sounds and loop, then
/// `UnderwaterAmbientSoundHandler`, `BubbleColumn` (absent) and
/// `BiomeAmbientSoundsHandler` ticks, then the tickable instances.
pub fn tick(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
let feet = ctx.position;
if !self.was_underwater && ctx.underwater {
out.play_at("minecraft:ambient.underwater.enter", feet);
if let Some((id, _)) = self.underwater_loop.take() {
out.stop(id);
}
self.underwater_loop = out
.play_tracked("minecraft:ambient.underwater.loop", 0.0, true)
.map(|id| (id, 0));
}
if self.was_underwater && !ctx.underwater {
out.play_at("minecraft:ambient.underwater.exit", feet);
}
/// `ClientLevel.playLocalSound(x, y, z, sound, source, 1, 1, false)`.
fn play_local(&mut self, event: &str, source: usize, at: [f64; 3], sounds: &mut impl Sounds) {
let seed = self.random.next_long();
let instance = SoundInstance::at(event, source, 1.0, 1.0, LegacyRandom::new(seed), at);
sounds.play_instance(instance, false);
}
/// `LocalPlayer.updateIsUnderwater`, then the `ambientSoundHandlers`
/// (underwater, bubble column, biome) as `LocalPlayer.tick` runs them.
pub fn tick(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
let old = self.was_underwater;
self.was_underwater = ctx.underwater;
self.tick_underwater(ctx, out);
self.tick_biome(ctx, out);
self.tick_instances(ctx, out);
if !ctx.spectator {
if !old && ctx.underwater {
self.play_local(
"minecraft:ambient.underwater.enter",
AMBIENT,
ctx.position,
sounds,
);
let instance = SoundInstance::under_liquid_loop(
"minecraft:ambient.underwater.loop",
SoundEngine::unseeded(),
);
sounds.play_instance(instance, false);
}
if old && !ctx.underwater {
self.play_local(
"minecraft:ambient.underwater.exit",
AMBIENT,
ctx.position,
sounds,
);
}
}
self.tick_underwater(sounds);
self.tick_bubble_column(ctx, sounds);
self.tick_biome(ctx, sounds);
}
fn tick_underwater(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
/// `BubbleColumnAmbientSoundHandler.tick`: entering a column plays its
/// inside sound (`LocalPlayer.playSound`, on PLAYERS).
fn tick_bubble_column(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
if let Some(drag_down) = ctx.bubble_column {
if !self.was_in_bubble_column && !self.first_tick && !ctx.spectator {
let event = if drag_down {
"minecraft:block.bubble_column.whirlpool_inside"
} else {
"minecraft:block.bubble_column.upwards_inside"
};
self.play_local(event, PLAYERS, ctx.position, sounds);
}
self.was_in_bubble_column = true;
} else {
self.was_in_bubble_column = false;
}
self.first_tick = false;
}
/// `UnderwaterAmbientSoundHandler.tick`.
fn tick_underwater(&mut self, sounds: &mut impl Sounds) {
self.underwater_delay -= 1;
if self.underwater_delay <= 0 && ctx.underwater {
if self.underwater_delay <= 0 && self.was_underwater {
let rand = self.random.next_float();
let sound = if rand < 1.0E-4 {
Some("minecraft:ambient.underwater.loop.additions.ultra_rare")
@@ -230,147 +270,121 @@ impl Ambience {
};
if let Some(sound) = sound {
self.underwater_delay = 0;
if let Some(id) = out.play_tracked(sound, 1.0, false) {
self.sub_sounds.push(id);
}
let instance = SoundInstance::under_liquid_sub(sound, SoundEngine::unseeded());
sounds.play_instance(instance, false);
}
}
}
fn tick_biome(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
self.loops.retain(|(_, l)| !l.stopped);
let sounds = ctx.sounds;
if sounds.r#loop != self.previous_loop {
self.previous_loop = sounds.r#loop.clone();
for (_, l) in &mut self.loops {
l.fade_out();
/// `BiomeAmbientSoundsHandler.tick`.
fn tick_biome(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
let mut kept = Vec::new();
for (event, id) in std::mem::take(&mut self.loops) {
if sounds.engine().instance(id).is_some_and(|i| i.stopped) {
sounds.release(id);
} else {
kept.push((event, id));
}
if let Some(event) = &sounds.r#loop {
if !self.loops.iter().any(|(e, _)| e == event) {
let id = out.play_tracked(event, 0.0, true);
self.loops.push((
event.clone(),
LoopSound {
id,
fade: 0,
direction: 0,
stopped: false,
},
));
}
self.loops = kept;
let current = ctx
.sounds
.r#loop
.as_deref()
.map(crate::sound_events::normalize);
if current != self.previous_loop {
self.previous_loop = current.clone();
for (_, id) in &self.loops {
if let Some(i) = sounds.engine().instance_mut(*id) {
i.fade_out();
}
if let Some((_, l)) = self.loops.iter_mut().find(|(e, _)| e == event) {
l.fade_in();
}
if let Some(event) = current {
let id = match self.loops.iter().find(|(e, _)| *e == event) {
Some((_, id)) => *id,
None => {
let instance = SoundInstance::biome_loop(&event, SoundEngine::unseeded());
let id = sounds.engine().add(instance, true);
sounds.play_id(id);
self.loops.push((event, id));
id
}
};
if let Some(i) = sounds.engine().instance_mut(id) {
i.fade_in();
}
}
}
for (sound, chance) in &sounds.additions {
if self.random.next_double() < *chance
&& let Some(id) = out.play_tracked(sound, 1.0, false)
{
// forAmbientAddition: a one-shot nobody ticks.
let _ = id;
for (sound, chance) in &ctx.sounds.additions {
if self.random.next_double() < *chance {
let event = crate::sound_events::normalize(sound);
let instance = SoundInstance::for_ambient_addition(&event, SoundEngine::unseeded());
sounds.play_instance(instance, false);
}
}
let Some(mood) = &sounds.mood else { return };
let Some(mood) = &ctx.sounds.mood else { return };
let extent = mood.block_search_extent;
let span = extent * 2 + 1;
let mut offset = || self.random.next_int_bound(span).unwrap_or(0) - extent;
let (dx, dy, dz) = (offset(), offset(), offset());
let sample = [
(ctx.position[0] + f64::from(dx)).floor() as i32,
(ctx.eye_y + f64::from(dy)).floor() as i32,
(ctx.position[2] + f64::from(dz)).floor() as i32,
];
let x = ctx.position[0] + f64::from(self.random.next_int_bound(span).unwrap_or(0))
- f64::from(extent);
let y = ctx.eye_y + f64::from(self.random.next_int_bound(span).unwrap_or(0))
- f64::from(extent);
let z = ctx.position[2] + f64::from(self.random.next_int_bound(span).unwrap_or(0))
- f64::from(extent);
let sample = [x.floor() as i32, y.floor() as i32, z.floor() as i32];
let (sky, block) = (ctx.light)(sample);
if sky > 0 {
self.moodiness -= f32::from(sky) / 15.0 * 0.001;
} else {
self.moodiness -= (f32::from(block) - 1.0) / mood.tick_delay as f32;
self.moodiness -= (i32::from(block) - 1) as f32 / mood.tick_delay as f32;
}
if self.moodiness >= 1.0 {
let d = [
f64::from(sample[0]) + 0.5 - ctx.position[0],
f64::from(sample[1]) + 0.5 - ctx.eye_y,
f64::from(sample[2]) + 0.5 - ctx.position[2],
];
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let sample_x = f64::from(sample[0]) + 0.5;
let sample_y = f64::from(sample[1]) + 0.5;
let sample_z = f64::from(sample[2]) + 0.5;
let dx = sample_x - ctx.position[0];
let dy = sample_y - ctx.eye_y;
let dz = sample_z - ctx.position[2];
let distance = (dx * dx + dy * dy + dz * dz).sqrt();
let source = distance + mood.offset;
out.play_at(
&mood.sound,
[
ctx.position[0] + d[0] / distance * source,
ctx.eye_y + d[1] / distance * source,
ctx.position[2] + d[2] / distance * source,
],
);
let at = [
ctx.position[0] + dx / distance * source,
ctx.eye_y + dy / distance * source,
ctx.position[2] + dz / distance * source,
];
// The mood sound draws from the level random itself.
let event = crate::sound_events::normalize(&mood.sound);
let instance = SoundInstance::for_ambient_mood(&event, self.random.clone(), at);
let id = sounds.engine().add(instance, true);
sounds.play_id(id);
if let Some(i) = sounds.engine().instance(id) {
self.random = i.random.clone();
}
sounds.release(id);
self.moodiness = 0.0;
} else {
self.moodiness = self.moodiness.max(0.0);
}
}
/// The tickable instances' own ticks (SoundEngine.tickInGameSound).
fn tick_instances(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
for (_, l) in &mut self.loops {
let volume = l.tick();
if let Some(id) = l.id {
if l.stopped {
out.stop(id);
} else {
out.set_volume(id, volume);
}
}
}
// UnderLiquidAmbientSoundInstance: +1 per tick under water, -2 out of it.
if let Some((id, fade)) = self.underwater_loop.as_mut() {
if *fade >= 0 {
*fade += if ctx.underwater { 1 } else { -2 };
*fade = (*fade).min(40);
out.set_volume(*id, (*fade as f32 / 40.0).clamp(0.0, 1.0));
} else {
out.stop(*id);
self.underwater_loop = None;
}
}
// UnderLiquidSubSound: stops when the player leaves the water.
self.sub_sounds.retain(|&id| {
let keep = ctx.underwater && out.is_active(id);
if !keep {
out.stop(id);
}
keep
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, test_backend::Null};
use crate::sound_events::SoundRegistry;
#[derive(Default)]
struct Fake {
next: u64,
live: HashMap<u64, (String, f32)>,
at: Vec<String>,
}
impl AmbientOutput for Fake {
fn play_tracked(&mut self, event: &str, volume: f32, _looping: bool) -> Option<u64> {
self.next += 1;
self.live.insert(self.next, (event.to_owned(), volume));
Some(self.next)
}
fn set_volume(&mut self, id: u64, volume: f32) {
if let Some(v) = self.live.get_mut(&id) {
v.1 = volume;
}
}
fn stop(&mut self, id: u64) {
self.live.remove(&id);
}
fn is_active(&self, id: u64) -> bool {
self.live.contains_key(&id)
}
fn play_at(&mut self, event: &str, _at: [f64; 3]) {
self.at.push(event.to_owned());
}
fn engine() -> SoundEngine {
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{"crimson": {"sounds": ["c"]}, "warped": {"sounds": ["w"]},
"ambient.cave": {"sounds": ["a", "b"]},
"ambient.underwater.loop": {"sounds": ["u"]},
"ambient.underwater.enter": {"sounds": ["e"]},
"ambient.underwater.exit": {"sounds": ["x"]}}"#,
&|_, _| true,
);
SoundEngine::new(registry)
}
fn ctx<'a>(
sounds: &'a AmbientSounds,
@@ -382,49 +396,59 @@ mod tests {
position: [0.5, 10.0, 0.5],
eye_y: 11.62,
underwater,
spectator: false,
light,
bubble_column: None,
}
}
#[test]
fn loops_cross_fade_over_forty_ticks() {
let (mut engine, mut backend) = (engine(), Null::default());
let mut a = Ambience::new(1);
let mut out = Fake::default();
let dark = |_: [i32; 3]| (15u8, 0u8);
let lit = |_: [i32; 3]| (15u8, 0u8);
let crimson = AmbientSounds {
r#loop: Some("crimson".into()),
r#loop: Some("minecraft:crimson".into()),
..Default::default()
};
for _ in 0..40 {
a.tick(&ctx(&crimson, false, &dark), &mut out);
}
assert_eq!(out.live.values().map(|v| v.1).collect::<Vec<_>>(), [1.0]);
let warped = AmbientSounds {
r#loop: Some("warped".into()),
r#loop: Some("minecraft:warped".into()),
..Default::default()
};
let tick =
|a: &mut Ambience, s: &AmbientSounds, engine: &mut SoundEngine, backend: &mut Null| {
a.tick(
&ctx(s, false, &lit),
&mut Bound {
engine: &mut *engine,
backend: &mut *backend,
},
);
engine.tick(false, &TickContext::default(), backend);
};
for _ in 0..40 {
tick(&mut a, &crimson, &mut engine, &mut backend);
}
for _ in 0..20 {
a.tick(&ctx(&warped, false, &dark), &mut out);
tick(&mut a, &warped, &mut engine, &mut backend);
}
let mut volumes: Vec<_> = out.live.values().map(|v| (v.0.clone(), v.1)).collect();
volumes.sort_by(|x, y| x.0.cmp(&y.0));
assert_eq!(
volumes,
[("crimson".to_owned(), 0.5), ("warped".to_owned(), 0.5)]
);
let volumes: Vec<f32> = a
.loops
.iter()
.map(|(_, id)| engine.instance(*id).unwrap().volume)
.collect();
assert_eq!(volumes, [0.5, 0.5]);
for _ in 0..30 {
a.tick(&ctx(&warped, false, &dark), &mut out);
tick(&mut a, &warped, &mut engine, &mut backend);
}
assert_eq!(
out.live.values().map(|v| v.0.as_str()).collect::<Vec<_>>(),
["warped"]
);
assert_eq!(a.loops.len(), 1);
}
#[test]
fn mood_builds_in_the_dark_and_plays_a_cave_sound() {
let (mut engine, mut backend) = (engine(), Null::default());
engine.listening = true;
let mut a = Ambience::new(2);
let mut out = Fake::default();
let cave = |_: [i32; 3]| (0u8, 0u8);
let sounds = AmbientSounds {
mood: Some(Mood {
@@ -435,31 +459,19 @@ mod tests {
}),
..Default::default()
};
// Block light 0: +1/6000 per tick (float), so the sound plays around tick 6000.
let mut ticks = 0;
while out.at.is_empty() && ticks < 7000 {
a.tick(&ctx(&sounds, false, &cave), &mut out);
while engine.notifications.is_empty() && ticks < 7000 {
a.tick(
&ctx(&sounds, false, &cave),
&mut Bound {
engine: &mut engine,
backend: &mut backend,
},
);
ticks += 1;
}
assert!((5990..=6010).contains(&ticks), "{ticks}");
assert_eq!(out.at, ["minecraft:ambient.cave"]);
assert_eq!(engine.notifications[0].identifier, "minecraft:ambient.cave");
assert_eq!(a.moodiness, 0.0);
}
#[test]
fn entering_water_plays_enter_and_fades_the_loop_in() {
let mut a = Ambience::new(3);
let mut out = Fake::default();
let lit = |_: [i32; 3]| (15u8, 0u8);
let none = AmbientSounds::default();
a.tick(&ctx(&none, true, &lit), &mut out);
assert_eq!(out.at, ["minecraft:ambient.underwater.enter"]);
let id = a.underwater_loop.unwrap().0;
assert_eq!(out.live[&id].1, 1.0 / 40.0);
a.tick(&ctx(&none, false, &lit), &mut out);
assert_eq!(out.at.last().unwrap(), "minecraft:ambient.underwater.exit");
// Fade 1 -> -1 stops on the next tick.
a.tick(&ctx(&none, false, &lit), &mut out);
assert!(!out.live.contains_key(&id));
}
}
+464
View File
@@ -0,0 +1,464 @@
//! Atlas sprite sources (`SpriteSourceList`): the `atlases/*.json`
//! definitions listing which sprites an atlas holds. `directory`, `single`,
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
//! against the resource tree; a later source replaces an earlier sprite of
//! the same id and a filter removes the matching ones.
use crate::packs::pattern::Pattern;
use horizon_assets::palette::apply_palette;
use serde_json::Value;
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
/// One region of an `unstitch` source, in units of the divisors.
#[derive(Clone, Debug, PartialEq)]
pub struct Region {
pub sprite: String,
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[derive(Clone, Debug)]
pub enum SpriteSource {
Directory {
source: String,
prefix: String,
},
Single {
resource: String,
sprite: Option<String>,
},
Filter {
namespace: Option<Pattern>,
path: Option<Pattern>,
},
Unstitch {
resource: String,
regions: Vec<Region>,
divisor_x: f64,
divisor_y: f64,
},
PalettedPermutations {
textures: Vec<String>,
palette_key: String,
permutations: BTreeMap<String, String>,
separator: String,
},
}
/// Where a listed sprite's pixels come from.
#[derive(Clone, Debug, PartialEq)]
pub enum SpriteOrigin {
/// A whole texture (`textures/<path>.png`), by texture identifier.
File(String),
/// `Unstitcher.RegionInstance`: part of a texture.
Region {
texture: String,
region: Region,
divisor_x: f64,
divisor_y: f64,
},
/// `PalettedSpriteSupplier`: a texture with its colours remapped.
Paletted {
texture: String,
palette_key: String,
palette: String,
},
}
/// `Identifier.parse` default namespace.
pub fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
fn string(v: &Value, key: &str) -> Option<String> {
v.get(key)?.as_str().map(str::to_owned)
}
fn number(v: &Value, key: &str, default: f64) -> Option<f64> {
match v.get(key) {
None => Some(default),
Some(n) => n.as_f64(),
}
}
/// `SpriteSources.FILE_CODEC`: the `sources` list of an atlas definition.
/// A malformed source fails the whole file, as the codec does.
pub fn decode(text: &str) -> Option<Vec<SpriteSource>> {
let json: Value = serde_json::from_str(text).ok()?;
json.get("sources")?
.as_array()?
.iter()
.map(decode_source)
.collect()
}
fn decode_source(s: &Value) -> Option<SpriteSource> {
Some(match qualified(s.get("type")?.as_str()?).as_str() {
"minecraft:directory" => SpriteSource::Directory {
source: string(s, "source")?,
prefix: string(s, "prefix")?,
},
"minecraft:single" => SpriteSource::Single {
resource: qualified(&string(s, "resource")?),
sprite: match s.get("sprite") {
None => None,
Some(v) => Some(qualified(v.as_str()?)),
},
},
"minecraft:filter" => {
let p = s.get("pattern")?;
let pattern = |key: &str| -> Option<Option<Pattern>> {
match p.get(key) {
None => Some(None),
Some(v) => Pattern::new(v.as_str()?).map(Some),
}
};
SpriteSource::Filter {
namespace: pattern("namespace")?,
path: pattern("path")?,
}
}
"minecraft:unstitch" => {
let regions = s
.get("regions")?
.as_array()?
.iter()
.map(|r| {
Some(Region {
sprite: qualified(&string(r, "sprite")?),
x: r.get("x")?.as_f64()?,
y: r.get("y")?.as_f64()?,
width: r.get("width")?.as_f64()?,
height: r.get("height")?.as_f64()?,
})
})
.collect::<Option<Vec<_>>>()?;
if regions.is_empty() {
return None;
}
SpriteSource::Unstitch {
resource: qualified(&string(s, "resource")?),
regions,
divisor_x: number(s, "divisor_x", 1.0)?,
divisor_y: number(s, "divisor_y", 1.0)?,
}
}
"minecraft:paletted_permutations" => SpriteSource::PalettedPermutations {
textures: s
.get("textures")?
.as_array()?
.iter()
.map(|t| t.as_str().map(qualified))
.collect::<Option<_>>()?,
palette_key: qualified(&string(s, "palette_key")?),
permutations: s
.get("permutations")?
.as_object()?
.iter()
.map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
.collect::<Option<_>>()?,
separator: match s.get("separator") {
None => "_".to_owned(),
Some(v) => v.as_str()?.to_owned(),
},
},
_ => return None,
})
}
/// The texture files of a resource tree: `namespace:path` for every
/// `assets/<namespace>/textures/<path>.png`.
pub fn list_textures(root: &Path) -> Vec<String> {
let mut out = Vec::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let base = ns.path().join("textures");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "png")
&& let Ok(rel) = p.strip_prefix(&base)
{
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
out.push(format!("{namespace}:{rel}"));
}
}
}
}
out.sort();
out
}
/// The file of a texture identifier.
pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
root.join(format!("assets/{namespace}/{directory}/{path}.png"))
}
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
/// `textures` are the available texture identifiers ([`list_textures`]).
pub fn list(sources: &[SpriteSource], textures: &[String]) -> BTreeMap<String, SpriteOrigin> {
let mut out = BTreeMap::new();
let exists = |t: &str| textures.binary_search_by(|x| x.as_str().cmp(t)).is_ok();
for source in sources {
match source {
SpriteSource::Directory { source, prefix } => {
// FileToIdConverter("textures/" + source, ".png").
let dir = format!("{source}/");
for t in textures {
let (namespace, path) = t.split_once(':').unwrap_or(("minecraft", t));
if let Some(rest) = path.strip_prefix(&dir) {
out.insert(
format!("{namespace}:{prefix}{rest}"),
SpriteOrigin::File(t.clone()),
);
}
}
}
SpriteSource::Single { resource, sprite } => {
if exists(resource) {
out.insert(
sprite.clone().unwrap_or_else(|| resource.clone()),
SpriteOrigin::File(resource.clone()),
);
}
}
SpriteSource::Filter { namespace, path } => {
out.retain(|id: &String, _| {
let (ns, p) = id.split_once(':').unwrap_or(("minecraft", id));
!(namespace.as_ref().is_none_or(|m| m.is_match(ns))
&& path.as_ref().is_none_or(|m| m.is_match(p)))
});
}
SpriteSource::Unstitch {
resource,
regions,
divisor_x,
divisor_y,
} => {
if exists(resource) {
for region in regions {
out.insert(
region.sprite.clone(),
SpriteOrigin::Region {
texture: resource.clone(),
region: region.clone(),
divisor_x: *divisor_x,
divisor_y: *divisor_y,
},
);
}
}
}
SpriteSource::PalettedPermutations {
textures: bases,
palette_key,
permutations,
separator,
} => {
for base in bases {
if !exists(base) {
continue;
}
for (suffix, palette) in permutations {
out.insert(
format!("{base}{separator}{suffix}"),
SpriteOrigin::Paletted {
texture: base.clone(),
palette_key: palette_key.clone(),
palette: palette.clone(),
},
);
}
}
}
}
}
out
}
/// An RGBA image.
pub type Image = (u32, u32, Vec<u8>);
fn argb(rgba: &[u8]) -> Vec<u32> {
rgba.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from_be_bytes([p[3], p[0], p[1], p[2]]))
.collect()
}
/// The pixels of a generated sprite (`Region` or `Paletted`); `None` for a
/// plain file, which loads as itself.
pub fn generate(
origin: &SpriteOrigin,
load: &dyn Fn(&str, &str) -> Option<Image>,
) -> Option<Image> {
match origin {
SpriteOrigin::File(_) => None,
SpriteOrigin::Region {
texture,
region,
divisor_x,
divisor_y,
} => {
let (w, h, pixels) = load(texture, "textures")?;
let x_scale = f64::from(w) / divisor_x;
let y_scale = f64::from(h) / divisor_y;
let x = (region.x * x_scale).floor() as i64;
let y = (region.y * y_scale).floor() as i64;
let width = (region.width * x_scale).floor() as i64;
let height = (region.height * y_scale).floor() as i64;
if width <= 0 || height <= 0 {
return None;
}
let mut out = vec![0u8; (width * height * 4) as usize];
for row in 0..height {
for col in 0..width {
let (sx, sy) = (x + col, y + row);
if sx < 0 || sy < 0 || sx >= i64::from(w) || sy >= i64::from(h) {
continue;
}
let s = ((sy * i64::from(w) + sx) * 4) as usize;
let d = ((row * width + col) * 4) as usize;
out[d..d + 4].copy_from_slice(&pixels[s..s + 4]);
}
}
Some((width as u32, height as u32, out))
}
SpriteOrigin::Paletted {
texture,
palette_key,
palette,
} => {
let (w, h, pixels) = load(texture, "textures")?;
let (_, _, key) = load(palette_key, "textures/palettes")?;
let (_, _, target) = load(palette, "textures/palettes")?;
let mut mapped = argb(&pixels);
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
let out = mapped
.iter()
.flat_map(|p| {
let [a, r, g, b] = p.to_be_bytes();
[r, g, b, a]
})
.collect();
Some((w, h, out))
}
}
}
/// Every atlas definition of a resource tree (`atlases/*.json` in every
/// namespace), by atlas id.
pub fn atlases(root: &Path) -> BTreeMap<String, Vec<SpriteSource>> {
let mut out = BTreeMap::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let Ok(files) = fs::read_dir(ns.path().join("atlases")) else {
continue;
};
for f in files.flatten() {
let path = f.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if let Some(sources) = fs::read_to_string(&path).ok().and_then(|t| decode(&t)) {
out.insert(format!("{namespace}:{name}"), sources);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn textures() -> Vec<String> {
let mut t: Vec<String> = [
"minecraft:item/apple",
"minecraft:item/stick",
"minecraft:trims/items/boots_trim",
"minecraft:block/stone",
"minecraft:entity/sheet",
]
.iter()
.map(|s| s.to_string())
.collect();
t.sort();
t
}
#[test]
fn sources_run_in_order() {
let sources = decode(
r#"{"sources":[
{"type":"directory","source":"item","prefix":"item/"},
{"type":"minecraft:single","resource":"block/stone","sprite":"item/stone"},
{"type":"filter","pattern":{"path":"stick"}},
{"type":"unstitch","resource":"entity/sheet","divisor_x":2,"regions":[{"sprite":"a","x":1,"y":0,"width":1,"height":1}]},
{"type":"paletted_permutations","textures":["trims/items/boots_trim"],"palette_key":"trims/color_palettes/trim_palette","permutations":{"quartz":"trims/color_palettes/quartz"}}
]}"#,
)
.unwrap();
let listed = list(&sources, &textures());
let ids: Vec<&str> = listed.keys().map(String::as_str).collect();
assert_eq!(
ids,
[
"minecraft:a",
"minecraft:item/apple",
"minecraft:item/stone",
"minecraft:trims/items/boots_trim_quartz"
]
);
assert_eq!(
listed["minecraft:item/stone"],
SpriteOrigin::File("minecraft:block/stone".into())
);
}
#[test]
fn malformed_sources_fail_the_file() {
assert!(decode(r#"{"sources":[{"type":"directory","source":"item"}]}"#).is_none());
assert!(
decode(r#"{"sources":[{"type":"unstitch","resource":"x","regions":[]}]}"#).is_none()
);
assert!(decode(r#"{"sources":[{"type":"horizon"}]}"#).is_none());
}
#[test]
fn unstitch_cuts_by_divisor() {
let pixels: Vec<u8> = (0..16u8).flat_map(|i| [i, i, i, 255]).collect();
let load = |_: &str, _: &str| Some((4u32, 4u32, pixels.clone()));
let origin = SpriteOrigin::Region {
texture: "t".into(),
region: Region {
sprite: "s".into(),
x: 1.0,
y: 1.0,
width: 1.0,
height: 1.0,
},
divisor_x: 2.0,
divisor_y: 2.0,
};
let (w, h, out) = generate(&origin, &load).unwrap();
assert_eq!((w, h), (2, 2));
assert_eq!(out[0], 10);
assert_eq!(out[4], 11);
assert_eq!(out[8], 14);
}
}
@@ -0,0 +1,106 @@
//! The player's attack strength (`Player.attackStrengthTicker`,
//! `itemSwapTicker`, `getAttackStrengthScale`, `getCurrentItemAttackStrengthDelay`):
//! the charge the attack indicator shows and attacks scale their damage by.
/// `Attributes.ATTACK_SPEED` base value.
pub const BASE_ATTACK_SPEED: f64 = 4.0;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AttackStrength {
/// `LivingEntity.attackStrengthTicker`.
pub ticker: i32,
/// `LivingEntity.itemSwapTicker`.
pub swap_ticker: i32,
/// `Player.lastItemInMainHand`: the stack as last compared.
last_item: Option<HeldStack>,
}
/// What `ItemStack.matches` / `isSameItem` compare of the main-hand stack:
/// its item id and the rest of the stack (count and components) as a key.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HeldStack {
pub item: String,
pub rest: String,
}
/// `Player.getCurrentItemAttackStrengthDelay` for an ATTACK_SPEED value.
pub fn delay(attack_speed: f64) -> f32 {
(1.0 / attack_speed * 20.0) as f32
}
impl AttackStrength {
/// The attack-strength part of `Player.tick`: both tickers advance, and a
/// main-hand change to a different item resets them
/// (`!ItemStack.matches` and `!ItemStack.isSameItem`).
pub fn tick(&mut self, main_hand: Option<HeldStack>) {
self.ticker += 1;
self.swap_ticker += 1;
if self.last_item != main_hand {
let same_item = match (&self.last_item, &main_hand) {
(Some(a), Some(b)) => a.item == b.item,
(None, None) => true,
_ => false,
};
if !same_item {
self.reset();
}
self.last_item = main_hand;
}
}
/// `resetAttackStrengthTicker`: swinging at nothing, attacking or
/// stopping a block break.
pub fn reset(&mut self) {
self.ticker = 0;
self.swap_ticker = 0;
}
/// `resetOnlyAttackStrengthTicker` (`Player.onAttack`).
pub fn reset_attack(&mut self) {
self.ticker = 0;
}
/// `getAttackStrengthScale(a)` with the ATTACK_SPEED attribute value.
pub fn scale(&self, a: f32, attack_speed: f64) -> f32 {
((self.ticker as f32 + a) / delay(attack_speed)).clamp(0.0, 1.0)
}
/// `getItemSwapScale(a)`.
pub fn swap_scale(&self, a: f32, attack_speed: f64) -> f32 {
((self.swap_ticker as f32 + a) / delay(attack_speed)).clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn held(item: &str, rest: &str) -> Option<HeldStack> {
Some(HeldStack {
item: item.into(),
rest: rest.into(),
})
}
#[test]
fn charges_over_the_attack_delay() {
let mut s = AttackStrength::default();
// A sword's 1.6 attacks per second: 12.5 ticks to full strength.
assert_eq!(delay(1.6), 12.5);
for _ in 0..6 {
s.tick(None);
}
assert_eq!(s.scale(0.25, 1.6), 6.25 / 12.5);
for _ in 0..10 {
s.tick(None);
}
assert_eq!(s.scale(0.0, 1.6), 1.0);
}
#[test]
fn switching_items_resets_but_restacking_does_not() {
let mut s = AttackStrength::default();
s.tick(held("minecraft:stone", "64"));
assert_eq!(s.ticker, 0, "picking up an item resets");
s.tick(held("minecraft:stone", "63"));
assert_eq!(
s.ticker, 1,
"the same item with another count keeps charging"
);
s.tick(held("minecraft:diamond_sword", ""));
assert_eq!((s.ticker, s.swap_ticker), (0, 0));
s.tick(held("minecraft:diamond_sword", ""));
s.reset_attack();
assert_eq!((s.ticker, s.swap_ticker), (0, 1));
}
}
+466 -436
View File
@@ -1,9 +1,16 @@
//! Sound playback: `sounds.json` events with weighted variants, Ogg/Vorbis
//! decoding, and a small mixer with vanilla's linear distance attenuation
//! (`SoundInstance.Attenuation.LINEAR`, default range 16 blocks) and stereo panning.
//! Sound playback: vanilla's `SoundEngine` bookkeeping (`sound_engine`) over a
//! small mixer standing in for OpenAL: one channel per source with its gain,
//! pitch, linear distance attenuation (`AL_LINEAR_DISTANCE`, reference 0,
//! rolloff 1), looping, relative flag and source state, Ogg/Vorbis decoding
//! (streams on a worker thread) and stereo panning.
use crate::Result;
use crate::sound_engine::{
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
Sounds, TickContext,
};
use crate::sound_events::{Sound, SoundRegistry};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use serde_json::Value;
use horizon_core::random::{LegacyRandom, RandomSource};
use std::{
collections::HashMap,
fs,
@@ -11,46 +18,32 @@ use std::{
sync::{Arc, Mutex},
};
#[derive(Clone, Debug)]
struct Variant {
name: String,
volume: f32,
pitch: f32,
weight: u32,
attenuation: f32,
is_event: bool,
/// An OpenAL source's state.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum State {
Initial,
Playing,
Paused,
Stopped,
}
struct Voice {
samples: Arc<Vec<f32>>,
/// An OpenAL source.
struct Channel {
samples: Option<(Arc<Vec<f32>>, u32)>,
position: f64,
step: f64,
left: f32,
right: f32,
/// The sound event and `SoundSource` index (what `/stopsound` matches).
event: Arc<str>,
source: usize,
state: State,
gain: f32,
pitch: f32,
/// `AL_LINEAR_DISTANCE` max distance, or no attenuation.
linear: Option<f32>,
looping: bool,
at: [f32; 3],
relative: bool,
}
#[derive(Default)]
struct Mixer {
voices: Vec<Voice>,
/// The streaming music track (`SoundSource.MUSIC`), kept apart from the
/// voice cap and scaled by `music_gain` (MusicManager's fade).
music: Option<Voice>,
music_gain: f32,
/// Tickable sound instances the game keeps a handle to (ambient loops).
tracked: Vec<Tracked>,
}
struct Tracked {
id: u64,
voice: Voice,
looping: bool,
volume: f32,
}
/// The current music track: decoding on a worker until the voice exists.
struct MusicTrack {
event: String,
/// Set by the decoder thread once the voice is in the mixer (or failed).
ready: Arc<std::sync::atomic::AtomicBool>,
channels: HashMap<ChannelId, Channel>,
listener: [f64; 3],
listener_yaw: f32,
}
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
/// what the subtitle overlay needs.
@@ -58,30 +51,31 @@ struct MusicTrack {
pub struct Heard {
/// The `subtitle` translation key of the sound event.
pub subtitle: String,
/// Where it played (None = relative to the listener, e.g. UI sounds).
/// The instance's position (relative sounds report their raw coordinates).
pub at: Option<[f64; 3]>,
/// `SoundEngine.getRange`: infinite for relative and non-attenuated sounds.
pub range: f32,
pub looping: bool,
}
/// The mixer side of the engine (`Backend`).
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
root: PathBuf,
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
next: ChannelId,
live: bool,
}
pub struct Audio {
_stream: Option<cpal::Stream>,
/// `subtitle` keys of `sounds.json` events.
subtitles: HashMap<String, String>,
pub engine: SoundEngine,
backend: MixerBackend,
/// Record `Heard` sounds (the subtitle overlay is listening).
pub record_heard: bool,
heard: Vec<Heard>,
mixer: Arc<Mutex<Mixer>>,
rate: u32,
root: PathBuf,
events: HashMap<String, Vec<Variant>>,
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
seed: u64,
pub listener: [f64; 3],
pub listener_yaw: f32,
pub master: f32,
music: Option<MusicTrack>,
next_tracked: u64,
/// Seeds of positional plays (the client level random's `nextLong`).
random: LegacyRandom,
/// A `SoundSource` index the next sound plays on (`/playsound`).
source_override: Option<usize>,
/// Positional sounds played since the last drain, kept while the world
@@ -130,75 +124,261 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
}
Some((mono, rate))
}
impl Audio {
pub fn new(root: &Path) -> Self {
let mut events = HashMap::new();
let mut subtitles = HashMap::new();
if let Ok(text) = fs::read(root.join("assets/minecraft/sounds.json"))
&& let Ok(Value::Object(map)) = serde_json::from_slice::<Value>(&text)
impl MixerBackend {
fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
for (name, def) in map {
let variants = def["sounds"]
.as_array()
.into_iter()
.flatten()
.map(|s| match s {
Value::String(n) => Variant {
name: n.clone(),
volume: 1.0,
pitch: 1.0,
weight: 1,
attenuation: 16.0,
is_event: false,
},
_ => Variant {
name: s["name"].as_str().unwrap_or("").to_owned(),
volume: s["volume"].as_f64().unwrap_or(1.0) as f32,
pitch: s["pitch"].as_f64().unwrap_or(1.0) as f32,
weight: s["weight"].as_u64().unwrap_or(1) as u32,
attenuation: s["attenuation_distance"].as_f64().unwrap_or(16.0) as f32,
is_event: s["type"].as_str() == Some("event"),
},
})
.collect::<Vec<_>>();
if let Some(key) = def["subtitle"].as_str() {
subtitles.insert(format!("minecraft:{name}"), key.to_owned());
}
events.insert(format!("minecraft:{name}"), variants);
}
f(c);
}
let mixer = Arc::new(Mutex::new(Mixer {
music_gain: 1.0,
..Mixer::default()
}));
let (stream, rate) = match Self::open(mixer.clone()) {
Ok(v) => (Some(v.0), v.1),
Err(e) => {
eprintln!("Horizon: audio unavailable: {e}");
(None, 48_000)
}
}
impl Backend for MixerBackend {
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
self.next += 1;
let id = self.next;
self.mixer.lock().ok()?.channels.insert(
id,
Channel {
samples: None,
position: 0.0,
state: State::Initial,
gain: 1.0,
pitch: 1.0,
linear: None,
looping: false,
at: [0.0; 3],
relative: false,
},
);
Some(id)
}
fn destroy(&mut self, channel: ChannelId) {
if let Ok(mut m) = self.mixer.lock() {
m.channels.remove(&channel);
}
}
fn set_pitch(&mut self, channel: ChannelId, pitch: f32) {
self.with(channel, |c| c.pitch = pitch);
}
fn set_volume(&mut self, channel: ChannelId, volume: f32) {
self.with(channel, |c| c.gain = volume);
}
fn set_attenuation(&mut self, channel: ChannelId, linear: Option<f32>) {
self.with(channel, |c| c.linear = linear);
}
fn set_looping(&mut self, channel: ChannelId, looping: bool) {
self.with(channel, |c| c.looping = looping);
}
fn set_position(&mut self, channel: ChannelId, p: [f64; 3]) {
self.with(channel, |c| c.at = [p[0] as f32, p[1] as f32, p[2] as f32]);
}
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
self.with(channel, |c| c.relative = relative);
}
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
/// worker), then attach and `alSourcePlay`. A sound that cannot load
/// leaves its source initial, as a failed buffer future does in vanilla.
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
if !self.live {
return;
}
let (namespace, path) = sound.path();
let file = self.root.join("assets").join(namespace).join(&path);
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
if let Some(samples) = samples
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.samples = Some(samples);
c.position = 0.0;
c.state = State::Playing;
}
};
if sound.stream {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
std::thread::spawn(move || {
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
if looping
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.looping = true;
}
attach(&mixer, samples);
});
} else {
let samples = self
.cache
.entry(format!("{namespace}:{path}"))
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
.clone();
attach(&self.mixer, samples);
}
}
fn stop(&mut self, channel: ChannelId) {
self.with(channel, |c| {
if c.state != State::Initial {
c.state = State::Stopped;
}
});
}
fn pause(&mut self, channel: ChannelId) {
self.with(channel, |c| {
if c.state == State::Playing {
c.state = State::Paused;
}
});
}
fn unpause(&mut self, channel: ChannelId) {
self.with(channel, |c| {
if c.state == State::Paused {
c.state = State::Playing;
}
});
}
fn stopped(&mut self, channel: ChannelId) -> bool {
self.mixer
.lock()
.map(|m| {
m.channels
.get(&channel)
.is_none_or(|c| c.state == State::Stopped)
})
.unwrap_or(true)
}
}
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
/// distance 0 and rolloff 1: the distance clamps to the max distance).
fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
match linear {
None => 1.0,
Some(max) if max > 0.0 => (1.0 - distance.min(max) / max).clamp(0.0, 1.0),
Some(_) => 0.0,
}
}
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
data.fill(0.0);
let listener = mixer.listener;
let yaw = mixer.listener_yaw.to_radians();
// Gains and pans per source for this buffer.
let mut voices: Vec<(ChannelId, f32, f32, f64)> = Vec::new();
for (id, c) in &mixer.channels {
let Some((_, source_rate)) = &c.samples else {
continue;
};
if c.state != State::Playing {
continue;
}
let d = if c.relative {
[f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]
} else {
[
f64::from(c.at[0]) - listener[0],
f64::from(c.at[1]) - listener[1],
f64::from(c.at[2]) - listener[2],
]
};
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
// The listener's right vector for a Minecraft yaw.
let side = if c.relative {
(d[0] as f32) / distance.max(0.001)
} else {
(-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32 / distance.max(0.001)
};
let pan = side.clamp(-1.0, 1.0);
let left = gain * (1.0 - pan.max(0.0) * 0.6);
let right = gain * (1.0 + pan.min(0.0) * 0.6);
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, left, right, step));
}
for (id, left, right, step) in voices {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
};
let Some((samples, _)) = c.samples.clone() else {
continue;
};
for frame in data.chunks_mut(channels) {
let mut i = c.position as usize;
if i + 1 >= samples.len() {
if c.looping && samples.len() > 1 {
c.position = 0.0;
i = 0;
} else {
c.state = State::Stopped;
break;
}
}
let t = (c.position - i as f64) as f32;
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
} else {
frame[0] += s * (left + right) / 2.0;
}
c.position += step;
}
}
for s in data.iter_mut() {
*s = s.clamp(-1.0, 1.0);
}
}
impl Audio {
pub fn new(root: &Path) -> Self {
Self::with_output(root, true)
}
/// No output device: the dedicated server.
pub fn silent(root: &Path) -> Self {
Self::with_output(root, false)
}
fn with_output(root: &Path, output: bool) -> Self {
let registry = SoundRegistry::load(root);
let mixer = Arc::new(Mutex::new(Mixer::default()));
let opened = if output {
Self::open(mixer.clone())
} else {
Err("no output on a dedicated server".into())
};
let (stream, live) = match opened {
Ok(v) => (Some(v), true),
Err(e) => {
if output {
eprintln!("Horizon: audio unavailable: {e}");
}
(None, false)
}
};
let mut engine = SoundEngine::new(registry);
engine.loaded = live;
Self {
_stream: stream,
subtitles,
engine,
backend: MixerBackend {
mixer,
root: root.to_owned(),
cache: HashMap::new(),
next: 0,
live,
},
record_heard: false,
heard: Vec::new(),
mixer,
rate,
root: root.to_owned(),
events,
cache: HashMap::new(),
seed: 0x5DEE_CE66,
listener: [0.0; 3],
listener_yaw: 0.0,
master: 1.0,
music: None,
next_tracked: 0,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
source_override: None,
capture: None,
}
}
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<(cpal::Stream, u32)> {
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
// Test runs (harnesses, agents) launch the game on a virtual screen;
// keep them off the user's speakers.
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
}
let host = cpal::default_host();
let device = host
.default_output_device()
@@ -209,269 +389,110 @@ impl Audio {
let stream = device.build_output_stream(
&config.into(),
move |data: &mut [f32], _| {
data.fill(0.0);
let Ok(mut mixer) = mixer.lock() else { return };
for frame in data.chunks_mut(channels) {
let (mut l, mut r) = (0.0f32, 0.0f32);
for v in mixer.voices.iter_mut() {
let i = v.position as usize;
if i + 1 >= v.samples.len() {
continue;
}
let t = (v.position - i as f64) as f32;
let s = v.samples[i] * (1.0 - t) + v.samples[i + 1] * t;
l += s * v.left;
r += s * v.right;
v.position += v.step;
}
for t in mixer.tracked.iter_mut() {
let v = &mut t.voice;
let mut i = v.position as usize;
if t.looping && i + 1 >= v.samples.len() {
v.position = 0.0;
i = 0;
}
if i + 1 < v.samples.len() {
let f = (v.position - i as f64) as f32;
let s = (v.samples[i] * (1.0 - f) + v.samples[i + 1] * f) * t.volume;
l += s * v.left;
r += s * v.right;
v.position += v.step;
}
}
let gain = mixer.music_gain;
if let Some(v) = mixer.music.as_mut() {
let i = v.position as usize;
if i + 1 < v.samples.len() {
let t = (v.position - i as f64) as f32;
let s = (v.samples[i] * (1.0 - t) + v.samples[i + 1] * t) * gain;
l += s * v.left;
r += s * v.right;
v.position += v.step;
}
}
if channels >= 2 {
frame[0] = l.clamp(-1.0, 1.0);
frame[1] = r.clamp(-1.0, 1.0);
} else {
frame[0] = ((l + r) / 2.0).clamp(-1.0, 1.0);
}
}
mixer
.voices
.retain(|v| (v.position as usize) + 1 < v.samples.len());
mixer.tracked.retain(|t| {
t.looping || (t.voice.position as usize) + 1 < t.voice.samples.len()
});
if mixer
.music
.as_ref()
.is_some_and(|v| (v.position as usize) + 1 >= v.samples.len())
{
mixer.music = None;
if let Ok(mut m) = mixer.lock() {
mix(&mut m, data, channels, rate);
} else {
data.fill(0.0);
}
},
|e| eprintln!("Horizon: audio stream error: {e}"),
None,
)?;
stream.play()?;
Ok((stream, rate))
Ok(stream)
}
fn random(&mut self) -> u64 {
self.seed ^= self.seed << 13;
self.seed ^= self.seed >> 7;
self.seed ^= self.seed << 17;
self.seed
}
fn resolve(&mut self, event: &str, depth: u32) -> Option<Variant> {
let variants = self.events.get(event)?.clone();
let total: u32 = variants.iter().map(|v| v.weight).sum();
if total == 0 {
return None;
fn bound(&mut self) -> Bound<'_, MixerBackend> {
Bound {
engine: &mut self.engine,
backend: &mut self.backend,
}
let mut pick = (self.random() % u64::from(total)) as u32;
let chosen = variants.into_iter().find(|v| {
if pick < v.weight {
true
} else {
pick -= v.weight;
false
}
/// `SoundManager.updateSource`: the camera position and yaw.
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32) {
self.listener = position;
self.listener_yaw = yaw;
if let Ok(mut m) = self.backend.mixer.lock() {
m.listener = position;
m.listener_yaw = yaw;
}
}
/// Sync the volume options, refreshing the categories that changed
/// (the option sliders' `refreshCategoryVolume`).
fn sync_volumes(&mut self) {
let mut volumes = [self.master; 11];
for (i, v) in volumes.iter_mut().enumerate().skip(1) {
*v = crate::settings::source_volume(i);
}
for source in 0..11 {
if self.engine.source_volumes[source] != volumes[source] {
self.engine.source_volumes[source] = volumes[source];
self.engine
.refresh_category_volume(source, &mut self.backend);
}
})?;
if chosen.is_event && depth < 4 {
let name = if chosen.name.contains(':') {
chosen.name.clone()
} else {
format!("minecraft:{}", chosen.name)
};
let mut inner = self.resolve(&name, depth + 1)?;
inner.volume *= chosen.volume;
inner.pitch *= chosen.pitch;
return Some(inner);
}
Some(chosen)
self.engine.listening = self.record_heard;
}
/// Play a sound event at a world position (None = non-positional UI sound).
/// `SoundManager.tick(paused)`, once per client tick.
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
pub fn pause(&mut self) {
self.engine.pause_all_except(
&[crate::sound_engine::MUSIC, crate::sound_engine::UI],
&mut self.backend,
);
}
/// `SoundManager.resume`.
pub fn resume(&mut self) {
self.engine.resume(&mut self.backend);
}
/// `SoundManager.stop()`: every sound.
pub fn stop_all(&mut self) {
self.engine.stop_all(&mut self.backend);
}
/// Play a sound event at a world position, as `ClientLevel.playLocalSound`
/// (a `SimpleSoundInstance` seeded from the level random), or relative to
/// the listener without attenuation (`None`).
pub fn play(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
let seed = self.random.next_long();
self.play_seeded(event, at, volume, pitch, seed);
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
event: &str,
at: Option<[f64; 3]>,
volume: f32,
pitch: f32,
seed: i64,
) {
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
capture.push((event.to_owned(), at, volume, pitch));
}
let Some(variant) = self.resolve(event, 0) else {
return;
};
if self.record_heard
&& let Some(key) = self.subtitles.get(event)
{
let range = if at.is_some() {
variant.attenuation * volume.max(1.0)
} else {
f32::INFINITY
};
self.heard.push(Heard {
subtitle: key.clone(),
at,
range,
looping: false,
});
}
if self._stream.is_none() {
return;
}
let file = variant.name.trim_start_matches("minecraft:").to_owned();
let entry = self.cache.entry(file.clone()).or_insert_with(|| {
decode(
&self
.root
.join(format!("assets/minecraft/sounds/{file}.ogg")),
)
.map(|(s, r)| (Arc::new(s), r))
});
let Some((samples, rate)) = entry.clone() else {
return;
};
self.sync_volumes();
let event = crate::sound_events::normalize(event);
let source = self
.source_override
.unwrap_or_else(|| crate::settings::sound_source(event));
let volume = (volume * variant.volume).clamp(0.0, 1.0)
* self.master
* crate::settings::source_volume(source);
let pitch = (pitch * variant.pitch).clamp(0.5, 2.0);
let (mut left, mut right) = (volume, volume);
if let Some(p) = at {
let d = [
p[0] - self.listener[0],
p[1] - self.listener[1],
p[2] - self.listener[2],
];
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let range = variant.attenuation * volume.max(1.0);
let gain = (1.0 - distance / range).clamp(0.0, 1.0);
if gain <= 0.0 {
return;
}
let yaw = self.listener_yaw.to_radians();
// Right vector of a Minecraft yaw.
let side = (-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32
/ distance.max(0.001);
let pan = side.clamp(-1.0, 1.0);
left = volume * gain * (1.0 - pan.max(0.0) * 0.6);
right = volume * gain * (1.0 + pan.min(0.0) * 0.6);
}
let step = f64::from(rate) / f64::from(self.rate) * f64::from(pitch);
if let Ok(mut m) = self.mixer.lock() {
if m.voices.len() > 64 {
m.voices.remove(0);
}
m.voices.push(Voice {
samples,
position: 0.0,
step,
left,
right,
event: event.into(),
.unwrap_or_else(|| crate::settings::sound_source(&event));
let random = LegacyRandom::new(seed);
let instance = match at {
Some(at) => SoundInstance::at(&event, source, volume, pitch, random, at),
None => SoundInstance::new(
&event,
source,
});
}
}
/// `SoundManager.play` of a music instance (`SimpleSoundInstance.forMusic`:
/// non-positional, streamed). Decoding runs on a worker thread; the track
/// counts as active from now on. Returns false when nothing can play.
pub fn play_music(&mut self, event: &str) -> bool {
self.stop_music();
if self._stream.is_none() {
return false;
}
let Some(variant) = self.resolve(event, 0) else {
return false;
volume,
pitch,
random,
false,
0,
Attenuation::None,
[0.0; 3],
true,
),
};
let path = self.root.join(format!(
"assets/minecraft/sounds/{}.ogg",
variant.name.trim_start_matches("minecraft:")
));
let volume =
variant.volume.clamp(0.0, 1.0) * self.master * crate::settings::event_volume(event);
let step_scale = f64::from(variant.pitch.clamp(0.5, 2.0)) / f64::from(self.rate);
let ready = Arc::new(std::sync::atomic::AtomicBool::new(false));
let (mixer, done) = (self.mixer.clone(), ready.clone());
let music_event = event.to_owned();
std::thread::spawn(move || {
if let Some((samples, rate)) = decode(&path)
&& let Ok(mut m) = mixer.lock()
&& !done.load(std::sync::atomic::Ordering::Acquire)
{
m.music = Some(Voice {
samples: Arc::new(samples),
position: 0.0,
step: f64::from(rate) * step_scale,
left: volume,
right: volume,
event: music_event.as_str().into(),
source: 1,
});
}
done.store(true, std::sync::atomic::Ordering::Release);
});
self.music = Some(MusicTrack {
event: event.to_owned(),
ready,
});
true
}
/// `SoundManager.stop` of the music instance.
pub fn stop_music(&mut self) {
if let Some(track) = self.music.take() {
// A decoder still running sees the flag and drops its result.
track
.ready
.store(true, std::sync::atomic::Ordering::Release);
}
if let Ok(mut m) = self.mixer.lock() {
m.music = None;
}
}
/// `SoundManager.isActive`: decoding or still sounding.
pub fn music_active(&mut self) -> bool {
let Some(track) = &self.music else {
return false;
};
if !track.ready.load(std::sync::atomic::Ordering::Acquire) {
return true;
}
let playing = self.mixer.lock().is_ok_and(|m| m.music.is_some());
if !playing {
self.music = None;
}
playing
}
/// The event of the current music track.
pub fn music_event(&self) -> Option<&str> {
self.music.as_ref().map(|t| t.event.as_str())
}
/// Every sound event id (for command completion).
pub fn event_names(&self) -> Vec<String> {
let mut v: Vec<String> = self.events.keys().cloned().collect();
v.sort();
v
self.play_instance(instance, false);
}
/// Play a sound on a given `SoundSource` index (`/playsound`).
pub fn play_on(
@@ -486,102 +507,111 @@ impl Audio {
self.play(event, at, volume, pitch);
self.source_override = None;
}
/// `SimpleSoundInstance.forJukeboxSong` at a jukebox; the handle stays
/// until `release`.
pub fn play_jukebox_song(&mut self, event: &str, pos: [i32; 3]) -> InstanceId {
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
let event = crate::sound_events::normalize(event);
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
self.play_instance(instance, true).0
}
/// Stop and drop a held instance.
pub fn stop_and_release(&mut self, id: InstanceId) {
self.stop_instance(id);
self.release(id);
}
/// Every sound event id (for command completion).
pub fn event_names(&self) -> Vec<String> {
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
v.sort();
v
}
/// `ClientboundStopSoundPacket`: stop the playing sounds of an event
/// and/or a source.
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
let matches = |v: &Voice| {
event.is_none_or(|e| &*v.event == e) && source.is_none_or(|s| v.source == s)
};
if let Ok(mut m) = self.mixer.lock() {
m.voices.retain(|v| !matches(v));
m.tracked.retain(|t| !matches(&t.voice));
if m.music.as_ref().is_some_and(matches) {
m.music = None;
}
}
if self
.music
.as_ref()
.is_some_and(|t| event.is_none_or(|e| t.event == e) && source.is_none_or(|s| s == 1))
{
self.music = None;
}
let event = event.map(crate::sound_events::normalize);
self.engine
.stop_matching(event.as_deref(), source, &mut self.backend);
}
/// The sounds played since the last call, for the subtitle overlay.
pub fn take_heard(&mut self) -> Vec<Heard> {
std::mem::take(&mut self.heard)
}
/// `SoundManager.updateCategoryVolume(MUSIC, gain)`.
pub fn set_music_gain(&mut self, gain: f32) {
if let Ok(mut m) = self.mixer.lock() {
m.music_gain = gain;
}
}
/// Play a relative (non-positional) tickable sound instance the caller
/// keeps a handle to: ambient loops (`looping`) and sub-sounds.
pub fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64> {
let variant = self.resolve(event, 0)?;
if self.record_heard
&& let Some(key) = self.subtitles.get(event)
{
self.heard.push(Heard {
subtitle: key.clone(),
at: None,
range: f32::INFINITY,
looping,
});
}
self._stream.as_ref()?;
let file = variant.name.trim_start_matches("minecraft:").to_owned();
let root = &self.root;
let (samples, rate) = self
.cache
.entry(file.clone())
.or_insert_with(|| {
decode(&root.join(format!("assets/minecraft/sounds/{file}.ogg")))
.map(|(s, r)| (Arc::new(s), r))
std::mem::take(&mut self.engine.notifications)
.into_iter()
.filter_map(|n| {
Some(Heard {
subtitle: n.subtitle?,
at: Some(n.position),
range: n.range,
looping: n.looping,
})
})
.clone()?;
let gain =
variant.volume.clamp(0.0, 1.0) * self.master * crate::settings::event_volume(event);
let step =
f64::from(rate) / f64::from(self.rate) * f64::from(variant.pitch.clamp(0.5, 2.0));
self.next_tracked += 1;
let id = self.next_tracked;
let mut m = self.mixer.lock().ok()?;
m.tracked.push(Tracked {
id,
voice: Voice {
samples,
position: 0.0,
step,
left: gain,
right: gain,
event: event.into(),
source: crate::settings::sound_source(event),
},
.collect()
}
}
impl Sounds for Audio {
fn engine(&mut self) -> &mut SoundEngine {
&mut self.engine
}
fn play_instance(&mut self, instance: SoundInstance, retain: bool) -> (InstanceId, PlayResult) {
self.sync_volumes();
self.bound().play_instance(instance, retain)
}
fn play_id(&mut self, id: InstanceId) -> PlayResult {
self.sync_volumes();
self.bound().play_id(id)
}
fn stop_instance(&mut self, id: InstanceId) {
self.bound().stop_instance(id);
}
fn update_category_volume(&mut self, source: usize, gain: f32) {
self.bound().update_category_volume(source, gain);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
Channel {
samples: Some((Arc::new(samples), 48_000)),
position: 0.0,
state: State::Playing,
gain: 1.0,
pitch: 1.0,
linear,
looping,
volume: volume.clamp(0.0, 1.0),
});
Some(id)
}
/// A tracked instance's volume (`AbstractTickableSoundInstance.volume`).
pub fn set_tracked_volume(&mut self, id: u64, volume: f32) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(t) = m.tracked.iter_mut().find(|t| t.id == id)
{
t.volume = volume.clamp(0.0, 1.0);
at,
relative: false,
}
}
pub fn stop_tracked(&mut self, id: u64) {
if let Ok(mut m) = self.mixer.lock() {
m.tracked.retain(|t| t.id != id);
}
#[test]
fn linear_attenuation_reaches_zero_at_the_max_distance() {
assert_eq!(distance_gain(None, 100.0), 1.0);
assert_eq!(distance_gain(Some(16.0), 0.0), 1.0);
assert_eq!(distance_gain(Some(16.0), 8.0), 0.5);
assert_eq!(distance_gain(Some(16.0), 16.0), 0.0);
assert_eq!(distance_gain(Some(16.0), 40.0), 0.0);
}
/// Still sounding (a finished one-shot is gone).
pub fn tracked_active(&self, id: u64) -> bool {
self.mixer
.lock()
.is_ok_and(|m| m.tracked.iter().any(|t| t.id == id))
#[test]
fn sources_play_attenuated_then_stop_or_loop() {
let mut m = Mixer::default();
m.channels
.insert(1, channel(vec![1.0; 4], Some(16.0), [8.0, 0.0, 0.0], false));
m.channels
.insert(2, channel(vec![1.0; 4], None, [0.0; 3], true));
let mut data = [0.0f32; 16];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(m.channels[&1].state, State::Stopped);
assert_eq!(m.channels[&2].state, State::Playing);
// Mono sum of the two at the first frame: the far one is half as loud.
let first = data[0] + data[1];
assert!(first > 1.4 && first < 3.1, "{first}");
}
}
+235
View File
@@ -0,0 +1,235 @@
//! Auto-jump (`LocalPlayer.updateAutoJump` and `canAutoJump`): after the
//! local player's own move, look ahead along the movement for a step that
//! the player can clear with a jump and is not walled in above; if found, the
//! next `aiStep` presses jump for one tick (`autoJumpTime = 1`).
use horizon_core::{
geometry::{Aabb, Axis, Vec3, VoxelShape, trig},
position::BlockPos,
};
/// `Float.MIN_VALUE`, the "no obstacle" marker.
const NO_OBSTACLE: f32 = f32::from_bits(1);
/// What `updateAutoJump` reads from the player.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AutoJumper {
pub position: Vec3,
/// `getBoundingBox().maxY`.
pub max_y: f64,
pub x_rot: f32,
pub y_rot: f32,
/// `getSpeed()`.
pub speed: f32,
/// `input.getMoveVector()` (x: strafe, y: forward).
pub move_vector: [f32; 2],
/// `getBbWidth()`, `getBbHeight()`.
pub width: f32,
pub height: f32,
/// The jump boost amplifier, when the effect is active.
pub jump_boost: Option<i32>,
}
/// The level queries, with the player's collision context.
pub trait AutoJumpWorld {
type Error;
/// `getBlockState(p).getCollisionShape(level, p, CollisionContext.of(player))`.
fn collision_shape(&mut self, p: BlockPos) -> Result<VoxelShape, Self::Error>;
/// `level.getCollisions(player, bounds)` flattened with `toAabbs`, in order.
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, Self::Error>;
}
/// `LocalPlayer.canAutoJump`. `staying_on_surface` is
/// `isStayingOnGroundSurface()` (the shift key).
#[allow(clippy::too_many_arguments)]
pub fn can_auto_jump(
enabled: bool,
auto_jump_time: i32,
on_ground: bool,
staying_on_surface: bool,
passenger: bool,
move_vector: [f32; 2],
block_jump_factor: f32,
) -> bool {
enabled
&& auto_jump_time <= 0
&& on_ground
&& !staying_on_surface
&& !passenger
&& move_vector[0] * move_vector[0] + move_vector[1] * move_vector[1] > 0.0
&& f64::from(block_jump_factor) >= 1.0
}
/// `Vec3.directionFromRotation(rotX, rotY)`.
pub fn direction_from_rotation(rot_x: f32, rot_y: f32) -> Vec3 {
let rad = trig::DEG_TO_RAD;
let pi = std::f32::consts::PI;
let y_cos = trig::cos(f64::from(-rot_y * rad - pi));
let y_sin = trig::sin(f64::from(-rot_y * rad - pi));
let x_cos = -trig::cos(f64::from(-rot_x * rad));
let x_sin = trig::sin(f64::from(-rot_x * rad));
Vec3::new(
f64::from(y_sin * x_cos),
f64::from(x_sin),
f64::from(y_cos * x_cos),
)
}
/// `AABB.intersects(Vec3, Vec3)`: the box overlaps the segment's bounds.
fn intersects_segment(b: Aabb, from: Vec3, to: Vec3) -> bool {
b.intersects(Aabb::new(from, to))
}
/// `LocalPlayer.updateAutoJump(xa, za)` once `canAutoJump` holds: whether
/// it sets `autoJumpTime = 1`. `xa`/`za` are the float horizontal
/// displacement of the move.
pub fn update_auto_jump<W: AutoJumpWorld>(
world: &mut W,
p: &AutoJumper,
xa: f32,
za: f32,
) -> Result<bool, W::Error> {
let move_begin = p.position;
let move_end = move_begin.add_vector(Vec3::new(f64::from(xa), 0.0, f64::from(za)));
let mut move_diff = Vec3::new(f64::from(xa), 0.0, f64::from(za));
let current_speed = p.speed;
let mut move_dist_sq = move_diff.length_squared() as f32;
if move_dist_sq <= 0.001 {
let input_xa = current_speed * p.move_vector[0];
let input_za = current_speed * p.move_vector[1];
let sin = trig::sin(f64::from(p.y_rot * trig::DEG_TO_RAD));
let cos = trig::cos(f64::from(p.y_rot * trig::DEG_TO_RAD));
move_diff = Vec3::new(
f64::from(input_xa * cos - input_za * sin),
move_diff.y,
f64::from(input_za * cos + input_xa * sin),
);
move_dist_sq = move_diff.length_squared() as f32;
if move_dist_sq <= 0.001 {
return Ok(false);
}
}
// Mth.invSqrt: 1.0F / (float) Math.sqrt(x).
let inverted = 1.0 / move_dist_sq.sqrt();
let move_dir = move_diff.scale(f64::from(inverted));
let facing = direction_from_rotation(p.x_rot, p.y_rot);
let facing_vs_moving = (facing.x * move_dir.x + facing.z * move_dir.z) as f32;
if facing_vs_moving < -0.15 {
return Ok(false);
}
let mut ceiling = BlockPos::containing(Vec3::new(move_begin.x, p.max_y, move_begin.z));
if !world.collision_shape(ceiling)?.is_empty() {
return Ok(false);
}
ceiling = ceiling.offset(0, 1, 0);
if !world.collision_shape(ceiling)?.is_empty() {
return Ok(false);
}
let mut jump_height = 1.2f32;
if let Some(amplifier) = p.jump_boost {
jump_height += (amplifier + 1) as f32 * 0.75;
}
let look_ahead = crate::camera::java_max(current_speed * 7.0, 1.0 / inverted);
let seg_begin = move_begin;
let seg_end = move_end.add_vector(move_dir.scale(f64::from(look_ahead)));
let (width, height) = (p.width, p.height);
let test_box = Aabb::new(
seg_begin,
seg_end.add_vector(Vec3::new(0.0, f64::from(height), 0.0)),
)
.inflate(Vec3::new(f64::from(width), 0.0, f64::from(width)));
let lift = Vec3::new(0.0, f64::from(0.51f32), 0.0);
let seg_begin = seg_begin.add_vector(lift);
let seg_end = seg_end.add_vector(lift);
let right_dir = move_dir.cross(Vec3::new(0.0, 1.0, 0.0));
let right_offset = right_dir.scale(f64::from(width * 0.5));
let left_begin = seg_begin.subtract(right_offset);
let left_end = seg_end.subtract(right_offset);
let right_begin = seg_begin.add_vector(right_offset);
let right_end = seg_end.add_vector(right_offset);
let mut obstacle = NO_OBSTACLE;
for b in world.collision_boxes(test_box)? {
if intersects_segment(b, left_begin, left_end)
|| intersects_segment(b, right_begin, right_end)
{
obstacle = b.max_position().y as f32;
let obstacle_pos = BlockPos::containing(b.center());
let mut steps = 1i32;
while (steps as f32) < jump_height {
let above = obstacle_pos.offset(0, steps, 0);
let shape = world.collision_shape(above)?;
if !shape.is_empty() {
obstacle = shape.max(Axis::Y) as f32 + above.y as f32;
if f64::from(obstacle) - p.position.y > f64::from(jump_height) {
return Ok(false);
}
}
if steps > 1 {
ceiling = ceiling.offset(0, 1, 0);
if !world.collision_shape(ceiling)?.is_empty() {
return Ok(false);
}
}
steps += 1;
}
break;
}
}
if obstacle != NO_OBSTACLE {
let y_delta = (f64::from(obstacle) - p.position.y) as f32;
// Negated as in LocalPlayer.updateAutoJump, so a NaN delta still jumps.
#[allow(clippy::neg_cmp_op_on_partial_ord)]
let jumps = !(y_delta <= 0.5) && !(y_delta > jump_height);
if jumps {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
struct Step;
impl AutoJumpWorld for Step {
type Error = ();
fn collision_shape(&mut self, p: BlockPos) -> Result<VoxelShape, ()> {
Ok(if p.x == 1 && p.y == 0 {
VoxelShape::block()
} else {
VoxelShape::empty()
})
}
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, ()> {
let b = Aabb::new(Vec3::new(1.0, 0.0, -5.0), Vec3::new(2.0, 1.0, 5.0));
Ok(if b.intersects(bounds) {
vec![b]
} else {
Vec::new()
})
}
}
#[test]
fn walking_into_a_one_block_step_jumps() {
let p = AutoJumper {
position: Vec3::new(0.6, 0.0, 0.5),
max_y: 1.8,
x_rot: 0.0,
y_rot: -90.0,
speed: 0.1,
move_vector: [0.0, 1.0],
width: 0.6,
height: 1.8,
jump_boost: None,
};
assert!(update_auto_jump(&mut Step, &p, 0.1, 0.0).unwrap());
// Moving away from the step does not.
assert!(!update_auto_jump(&mut Step, &p, -0.1, 0.0).unwrap());
}
#[test]
fn sneaking_or_standing_still_cannot_auto_jump() {
assert!(can_auto_jump(true, 0, true, false, false, [0.0, 1.0], 1.0));
assert!(!can_auto_jump(true, 0, true, true, false, [0.0, 1.0], 1.0));
assert!(!can_auto_jump(true, 0, true, false, false, [0.0, 0.0], 1.0));
assert!(!can_auto_jump(true, 0, true, false, false, [0.0, 1.0], 0.5));
}
}
+2 -1
View File
@@ -2,13 +2,14 @@
#![forbid(unsafe_code)]
use std::{
io::{BufRead, BufReader, Write},
os::unix::net::UnixStream,
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
thread,
time::{Duration, Instant},
};
use horizon_client::control::UnixStream;
const USAGE: &str = "usage: horizon-ctl [--socket PATH] COMMAND [ARGS...]
Local commands:
+20 -26
View File
@@ -17,11 +17,16 @@ pub struct SpawnEntry {
pub weight: u32,
pub min: u32,
pub max: u32,
/// A `ConstantInt` count (sampling draws nothing).
pub constant: bool,
}
#[derive(Clone, Debug)]
pub struct BiomeInfo {
/// `natural_mob_spawns` by category (creature, monster, ...).
pub spawns: HashMap<String, Vec<SpawnEntry>>,
/// `natural_mob_spawns` spawn costs by entity type.
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
pub name: String,
pub temperature: f32,
@@ -103,37 +108,26 @@ impl Biomes {
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let categories = &attributes["minecraft:gameplay/natural_mob_spawns"]["argument"]["spawns_by_category"];
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = entries
.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let (min, max) = match &e["count"] {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1) as u32;
(n, n)
}
c => (
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
),
};
Some(SpawnEntry {
entity: e["type"].as_str()?.to_owned(),
weight: e["weight"].as_u64().unwrap_or(1) as u32,
min,
max,
})
})
.collect();
let list = crate::mobs::spawn_selection::parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
creature_probability: v["creature_spawn_probability"].as_f64().unwrap_or(0.1)
as f32,
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
temperature,
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
@@ -0,0 +1,968 @@
//! Client-ticked block-entity animation state, as vanilla's block entities
//! keep it: `ChestLidController` (chests, trapped and copper chests, ender
//! chests), `EnchantingTableBlockEntity.bookAnimationTick`,
//! `ShulkerBoxBlockEntity` (open count, animation status, progress),
//! `BellBlockEntity` (ring ticks, shake direction, resonation),
//! `ConduitBlockEntity.clientTick` (tick count, shape check, rotation,
//! hunting, nautilus particles), `SkullBlockEntity.animation` (powered dragon
//! and piglin heads) and `DecoratedPotBlockEntity` wobbles.
//!
//! [`BlockEntityAnims`] holds the state per position and turns it into the
//! renderer's [`BlockAnim`]. Its tick methods take what the level answers
//! (blocks, entities, game time) as closures, so the parity fixture can drive
//! them with scripted inputs (`tools/test_block_entity_anim_parity.py`).
use crate::block_entity_render::BlockAnim;
use horizon_core::{
geometry::trig::{self, Mth},
random::{LegacyRandom, RandomSource},
};
use std::collections::HashMap;
/// `Direction` names by `get3DDataValue`.
pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
fn step(direction: u8) -> [i32; 3] {
match direction {
0 => [0, -1, 0],
1 => [0, 1, 0],
2 => [0, 0, -1],
3 => [0, 0, 1],
4 => [-1, 0, 0],
_ => [1, 0, 0],
}
}
/// `Direction.from3DDataValue`: `BY_3D_DATA[Mth.abs(data % 6)]`.
pub fn from_3d_data_value(data: i32) -> u8 {
(data % 6).unsigned_abs() as u8
}
/// The 3D data value of a direction name.
pub fn direction_index(name: &str) -> Option<u8> {
DIRECTIONS.iter().position(|d| *d == name).map(|i| i as u8)
}
/// `ChestLidController`, shared by `ChestBlockEntity` (chests, trapped and
/// copper chests) and `EnderChestBlockEntity`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ChestLid {
pub should_be_open: bool,
pub openness: f32,
pub o_openness: f32,
}
impl ChestLid {
/// `triggerEvent(1, openCount)`: the lid opens while anyone has it open.
pub fn trigger_event(&mut self, b0: i32, b1: i32) -> bool {
if b0 == 1 {
self.should_be_open = b1 > 0;
true
} else {
false
}
}
/// `tickLid` (the client ticker `lidAnimateTick`).
pub fn tick(&mut self) {
self.o_openness = self.openness;
if !self.should_be_open && self.openness > 0.0 {
self.openness = (self.openness - 0.1).max(0.0);
} else if self.should_be_open && self.openness < 1.0 {
self.openness = (self.openness + 0.1).min(1.0);
}
}
/// `getOpenness(a)`: `Mth.lerp(a, oOpenness, openness)`.
pub fn get_openness(&self, a: f32) -> f32 {
self.o_openness + a * (self.openness - self.o_openness)
}
}
/// `ChestBlock.opennessCombiner`: a double chest opens as far as its more
/// open half (`Math.max(first, second)`, first being the `right` half).
pub fn combined_openness(first: f32, second: Option<f32>) -> f32 {
match second {
Some(second) => java_max(first, second),
None => first,
}
}
/// `Math.max(float, float)`.
fn java_max(a: f32, b: f32) -> f32 {
if a.is_nan() {
a
} else if a == 0.0 && b == 0.0 && a.to_bits() != b.to_bits() {
0.0
} else if a >= b {
a
} else {
b
}
}
/// `ChestRenderer.submit` and `ChestModel.setupAnim`: the lid and lock
/// `xRot` for a render-state openness.
pub fn chest_lid_x_rot(open: f32) -> f32 {
let open = 1.0 - open;
let open = 1.0 - open * open * open;
-(open * std::f32::consts::FRAC_PI_2)
}
/// `EnchantingTableBlockEntity` book fields.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct EnchantingBook {
pub time: i32,
pub flip: f32,
pub o_flip: f32,
pub flip_t: f32,
pub flip_a: f32,
pub open: f32,
pub o_open: f32,
pub rot: f32,
pub o_rot: f32,
pub t_rot: f32,
}
/// The `while (a >= PI) a -= 2PI; while (a < -PI) a += 2PI` loops.
fn wrap_pi(mut a: f32) -> f32 {
use std::f32::consts::PI;
while a >= PI {
a -= 2.0 * PI;
}
while a < -PI {
a += 2.0 * PI;
}
a
}
/// `Mth.floor(float)`.
fn mth_floor(v: f32) -> i32 {
let i = v as i32;
if v < i as f32 { i - 1 } else { i }
}
/// `Mth.frac(float)`.
pub fn mth_frac(v: f32) -> f32 {
v - mth_floor(v) as f32
}
/// `EnchantTableRenderState` (`EnchantTableRenderer.extractRenderState`) and
/// the `BookModel.State` its `submit` builds.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct BookRender {
pub flip: f32,
pub open: f32,
pub time: f32,
pub y_rot: f32,
/// `BookModel.State.forAnimation`: openness, pageFlip1, pageFlip2.
pub openness: f32,
pub page_flip1: f32,
pub page_flip2: f32,
}
impl EnchantingBook {
/// `bookAnimationTick`. `players` are the feet positions of the level's
/// non-spectator players (`getNearestPlayer(centre, 3.0, false)`);
/// `random` is the block entity class's shared static `RANDOM`.
pub fn tick(&mut self, pos: [i32; 3], players: &[[f64; 3]], random: &mut impl RandomSource) {
self.o_open = self.open;
self.o_rot = self.rot;
let c = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
let mut best = -1.0;
let mut nearest = None;
for p in players {
let (dx, dy, dz) = (p[0] - c[0], p[1] - c[1], p[2] - c[2]);
let dist = dx * dx + dy * dy + dz * dz;
if dist < 9.0 && (best == -1.0 || dist < best) {
best = dist;
nearest = Some(*p);
}
}
if let Some(p) = nearest {
let xd = p[0] - c[0];
let zd = p[2] - c[2];
self.t_rot = trig::atan2(zd, xd) as f32;
self.open += 0.1;
if self.open < 0.5 || random.next_int_bound(40).unwrap_or(1) == 0 {
let old = self.flip_t;
loop {
let a = random.next_int_bound(4).unwrap_or(0);
let b = random.next_int_bound(4).unwrap_or(0);
self.flip_t += (a - b) as f32;
if old != self.flip_t {
break;
}
}
}
} else {
self.t_rot += 0.02;
self.open -= 0.1;
}
self.rot = wrap_pi(self.rot);
self.t_rot = wrap_pi(self.t_rot);
let dir = wrap_pi(self.t_rot - self.rot);
self.rot += dir * 0.4;
self.open = self.open.clamp(0.0, 1.0);
self.time += 1;
self.o_flip = self.flip;
let diff = ((self.flip_t - self.flip) * 0.4).clamp(-0.2, 0.2);
self.flip_a += (diff - self.flip_a) * 0.9;
self.flip += self.flip_a;
}
/// `EnchantTableRenderer.extractRenderState` and the book state of its
/// `submit`.
pub fn render(&self, a: f32) -> BookRender {
let flip = self.o_flip + a * (self.flip - self.o_flip);
let open = self.o_open + a * (self.open - self.o_open);
let time = self.time as f32 + a;
let y_rot = self.o_rot + wrap_pi(self.rot - self.o_rot) * a;
let ff1 = mth_frac(flip + 0.25) * 1.6 - 0.3;
let ff2 = mth_frac(flip + 0.75) * 1.6 - 0.3;
BookRender {
flip,
open,
time,
y_rot,
openness: ((time * 0.02).msin() * 0.1 + 1.25) * open,
page_flip1: ff1.clamp(0.0, 1.0),
page_flip2: ff2.clamp(0.0, 1.0),
}
}
}
/// `ShulkerBoxBlockEntity.AnimationStatus`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ShulkerStatus {
#[default]
Closed,
Opening,
Opened,
Closing,
}
/// `ShulkerBoxBlockEntity` lid state.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Shulker {
pub open_count: i32,
pub status: ShulkerStatus,
pub progress: f32,
pub progress_old: f32,
}
/// Side effects of one [`Shulker::tick`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ShulkerTick {
/// `doNeighborUpdates` calls (shape and neighbour updates around the box).
pub neighbor_updates: u32,
/// `moveCollidedEntities`: the swept lid box (min x, y, z, max x, y, z);
/// entities in it move out along the facing.
pub push: Option<[f64; 6]>,
}
/// `Shulker.getProgressDeltaAabb(size, direction, from, to, position)`.
pub fn progress_delta_aabb(
size: f32,
direction: u8,
from: f32,
to: f32,
position: [f64; 3],
) -> [f64; 6] {
let size_d = f64::from(size);
let mut b = [
-size_d * 0.5,
0.0,
-size_d * 0.5,
size_d * 0.5,
size_d,
size_d * 0.5,
];
let max = f64::from(from.max(to));
let min = f64::from(from.min(to));
let s = step(direction);
for axis in 0..3 {
// expandTowards
let a = f64::from(s[axis]) * max * size_d;
if a < 0.0 {
b[axis] += a;
} else if a > 0.0 {
b[axis + 3] += a;
}
}
for axis in 0..3 {
// contract
let a = -f64::from(s[axis]) * (1.0 + min) * size_d;
if a < 0.0 {
b[axis] -= a;
} else if a > 0.0 {
b[axis + 3] -= a;
}
}
// new AABB keeps min <= max; then move to the position.
let mut out = [0.0; 6];
for axis in 0..3 {
out[axis] = b[axis].min(b[axis + 3]) + position[axis];
out[axis + 3] = b[axis].max(b[axis + 3]) + position[axis];
}
out
}
impl Shulker {
/// `triggerEvent(1, openCount)`.
pub fn trigger_event(&mut self, b0: i32, b1: i32) -> bool {
if b0 != 1 {
return false;
}
self.open_count = b1;
if b1 == 0 {
self.status = ShulkerStatus::Closing;
}
if b1 == 1 {
self.status = ShulkerStatus::Opening;
}
true
}
/// `startOpen`/`stopOpen` for a non-spectator user: the new open count to
/// send as block event 1, and whether this was the first opener (open) or
/// the last closer (close), which play the sound and game event.
pub fn start_open(&mut self) -> (i32, bool) {
if self.open_count < 0 {
self.open_count = 0;
}
self.open_count += 1;
(self.open_count, self.open_count == 1)
}
pub fn stop_open(&mut self) -> (i32, bool) {
self.open_count -= 1;
(self.open_count, self.open_count <= 0)
}
/// `updateAnimation` (`ShulkerBoxBlockEntity.tick`); `facing` is the
/// block's facing as a 3D data value.
pub fn tick(&mut self, pos: [i32; 3], facing: u8) -> ShulkerTick {
let mut out = ShulkerTick::default();
self.progress_old = self.progress;
match self.status {
ShulkerStatus::Closed => self.progress = 0.0,
ShulkerStatus::Opening => {
self.progress += 0.1;
if self.progress_old == 0.0 {
out.neighbor_updates += 1;
}
if self.progress >= 1.0 {
self.status = ShulkerStatus::Opened;
self.progress = 1.0;
out.neighbor_updates += 1;
}
let bottom = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
out.push = Some(progress_delta_aabb(
1.0,
facing,
self.progress_old,
self.progress,
bottom,
));
}
ShulkerStatus::Opened => self.progress = 1.0,
ShulkerStatus::Closing => {
self.progress -= 0.1;
if self.progress_old == 1.0 {
out.neighbor_updates += 1;
}
if self.progress <= 0.0 {
self.status = ShulkerStatus::Closed;
self.progress = 0.0;
out.neighbor_updates += 1;
}
}
}
out
}
/// `getProgress(a)`: `Mth.lerp(a, progressOld, progress)`.
pub fn get_progress(&self, a: f32) -> f32 {
self.progress_old + a * (self.progress - self.progress_old)
}
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
let bottom = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
let mut b = progress_delta_aabb(1.0, facing, 0.0, 0.5, bottom);
for axis in 0..3 {
b[axis] += 1.0e-6;
b[axis + 3] -= 1.0e-6;
}
b
}
}
/// What the bell's tick asks about an entity it gathered when rung.
#[derive(Clone, Copy, Debug)]
pub struct BellEntity {
pub position: [f64; 3],
/// `isAlive() && !isRemoved()`.
pub alive: bool,
/// In `EntityTypeTags.RAIDERS`.
pub raider: bool,
}
/// `BellBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Bell {
pub ticks: i32,
pub shaking: bool,
/// `clickDirection` as a 3D data value.
pub click_direction: Option<u8>,
pub last_ring_timestamp: i64,
/// `nearbyEntities`: the living entities around the bell when it was last
/// gathered, as caller-defined handles.
pub nearby: Option<Vec<u64>>,
pub resonating: bool,
pub resonation_ticks: i32,
}
/// Side effects of one [`Bell::tick`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BellTick {
/// Resonation started: `BELL_RESONATE` (played by the server).
pub resonate: bool,
/// Resonation ended (`ResonationEndAction`): the client's
/// `showBellParticles` entity-effect particles (position and colour) and
/// the server's `makeRaidersGlow` targets.
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
}
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
let dx = f64::from(pos[0]) + 0.5 - p[0];
let dy = f64::from(pos[1]) + 0.5 - p[1];
let dz = f64::from(pos[2]) + 0.5 - p[2];
dx * dx + dy * dy + dz * dz < distance * distance
}
impl Bell {
/// `triggerEvent(1, direction)`. `gather` lists the living entities whose
/// position is inside the bell's block box inflated by 48
/// (`updateEntities`, refreshed at most every 60 ticks).
pub fn trigger_event(
&mut self,
b0: i32,
b1: i32,
game_time: i64,
gather: impl FnOnce() -> Vec<u64>,
) -> bool {
if b0 != 1 {
return false;
}
if game_time > self.last_ring_timestamp + 60 || self.nearby.is_none() {
self.last_ring_timestamp = game_time;
self.nearby = Some(gather());
}
self.resonation_ticks = 0;
self.click_direction = Some(from_3d_data_value(b1));
self.ticks = 0;
self.shaking = true;
true
}
/// `BellBlockEntity.tick` with the client's resonation end action.
pub fn tick(&mut self, pos: [i32; 3], entity: impl Fn(u64) -> Option<BellEntity>) -> BellTick {
let mut out = BellTick::default();
if self.shaking {
self.ticks += 1;
}
if self.ticks >= 50 {
self.shaking = false;
self.ticks = 0;
}
let nearby = self.nearby.clone().unwrap_or_default();
let raider_in = |e: &BellEntity, distance: f64| {
e.alive && closer_to_center_than(pos, e.position, distance) && e.raider
};
if self.ticks >= 5
&& self.resonation_ticks == 0
&& nearby
.iter()
.filter_map(|&h| entity(h))
.any(|e| raider_in(&e, 32.0))
{
self.resonating = true;
out.resonate = true;
}
if self.resonating {
if self.resonation_ticks < 40 {
self.resonation_ticks += 1;
} else {
// showBellParticles / makeRaidersGlow.
let mut color = 16_700_985;
let views: Vec<(u64, BellEntity)> = nearby
.iter()
.filter_map(|&h| Some((h, entity(h)?)))
.collect();
let count = views
.iter()
.filter(|(_, e)| closer_to_center_than(pos, e.position, 48.0))
.count() as i32;
let per = ((count - 21) / -2).clamp(3, 15);
let mut particles = Vec::new();
let mut glow = Vec::new();
for (h, e) in &views {
if !raider_in(e, 48.0) {
continue;
}
glow.push(*h);
let (bx, bz) = (f64::from(pos[0]), f64::from(pos[2]));
let dist = ((e.position[0] - bx) * (e.position[0] - bx)
+ (e.position[2] - bz) * (e.position[2] - bz))
.sqrt();
let x3 = bx + f64::from(0.5f32) + 1.0 / dist * (e.position[0] - bx);
let z3 = bz + f64::from(0.5f32) + 1.0 / dist * (e.position[2] - bz);
for _ in 0..per {
color += 5;
particles.push(([x3, f64::from(pos[1]) + 0.5, z3], color));
}
}
out.ended = Some((particles, glow));
self.resonating = false;
}
}
out
}
}
/// The conduit's `destroyTarget` as the client sees it.
#[derive(Clone, Copy, Debug)]
pub struct ConduitTarget {
pub position: [f64; 3],
pub eye_y: f64,
pub width: f32,
pub height: f32,
}
/// `ConduitBlockEntity` (client side).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Conduit {
pub tick_count: i32,
pub active_rotation: f32,
pub is_active: bool,
pub is_hunting: bool,
pub effect_blocks: Vec<[i32; 3]>,
}
/// One nautilus particle: position and velocity.
pub type Particle = ([f64; 3], [f64; 3]);
impl Conduit {
/// `updateShape`: water all around, then the frame blocks
/// (`BlockTags.CONDUIT_EFFECT_BLOCK`) in the three 5x5 rings.
fn update_shape(
&mut self,
pos: [i32; 3],
is_water: &impl Fn([i32; 3]) -> bool,
is_frame: &impl Fn([i32; 3]) -> bool,
) -> bool {
self.effect_blocks.clear();
for ox in -1..=1 {
for oy in -1..=1 {
for oz in -1..=1 {
if !is_water([pos[0] + ox, pos[1] + oy, pos[2] + oz]) {
return false;
}
}
}
}
for ox in -2i32..=2 {
for oy in -2i32..=2 {
for oz in -2i32..=2 {
let (ax, ay, az) = (ox.abs(), oy.abs(), oz.abs());
if (ax > 1 || ay > 1 || az > 1)
&& (ox == 0 && (ay == 2 || az == 2)
|| oy == 0 && (ax == 2 || az == 2)
|| oz == 0 && (ax == 2 || ay == 2))
{
let p = [pos[0] + ox, pos[1] + oy, pos[2] + oz];
if is_frame(p) {
self.effect_blocks.push(p);
}
}
}
}
}
self.effect_blocks.len() >= 16
}
/// `ConduitBlockEntity.clientTick`: returns the nautilus particles of
/// `animationTick`, drawn from the client level's random.
pub fn client_tick(
&mut self,
pos: [i32; 3],
game_time: i64,
is_water: impl Fn([i32; 3]) -> bool,
is_frame: impl Fn([i32; 3]) -> bool,
target: Option<ConduitTarget>,
random: &mut impl RandomSource,
) -> Vec<Particle> {
self.tick_count += 1;
if game_time % 40 == 0 {
self.is_active = self.update_shape(pos, &is_water, &is_frame);
self.is_hunting = self.effect_blocks.len() >= 42;
}
let particles = self.animation_tick(pos, target, random);
if self.is_active {
self.active_rotation += 1.0;
}
particles
}
fn animation_tick(
&self,
pos: [i32; 3],
target: Option<ConduitTarget>,
random: &mut impl RandomSource,
) -> Vec<Particle> {
let mut out = Vec::new();
let hh = f64::from(((self.tick_count + 35) as f32 * 0.1).msin() / 2.0 + 0.5);
let hh = (hh * hh + hh) * f64::from(0.3f32);
let end = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 1.5 + hh,
f64::from(pos[2]) + 0.5,
];
for p in &self.effect_blocks {
if random.next_int_bound(50).unwrap_or(1) == 0 {
let dx = -0.5 + random.next_float() + (p[0] - pos[0]) as f32;
let dy = -2.0 + random.next_float() + (p[1] - pos[1]) as f32;
let dz = -0.5 + random.next_float() + (p[2] - pos[2]) as f32;
out.push((end, [f64::from(dx), f64::from(dy), f64::from(dz)]));
}
}
if let Some(t) = target {
let randx = (-0.5 + random.next_float()) * (3.0 + t.width);
let randy = -1.0 + random.next_float() * t.height;
let randz = (-0.5 + random.next_float()) * (3.0 + t.width);
out.push((
[t.position[0], t.eye_y, t.position[2]],
[f64::from(randx), f64::from(randy), f64::from(randz)],
));
}
out
}
}
/// `SkullBlockEntity` animation (client ticker of dragon and piglin heads).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Skull {
pub animating: bool,
pub tick_count: i32,
}
impl Skull {
/// `SkullBlockEntity.animation`.
pub fn tick(&mut self, powered: bool) {
if powered {
self.animating = true;
self.tick_count += 1;
} else {
self.animating = false;
}
}
/// `getAnimation(a)`.
pub fn get_animation(&self, a: f32) -> f32 {
if self.animating {
self.tick_count as f32 + a
} else {
self.tick_count as f32
}
}
}
/// `DecoratedPotBlockEntity.WobbleStyle`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WobbleStyle {
Positive,
Negative,
}
impl WobbleStyle {
pub fn duration(self) -> i32 {
match self {
Self::Positive => 7,
Self::Negative => 10,
}
}
}
/// `DecoratedPotBlockEntity` wobble fields.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PotWobble {
pub started_at: i64,
pub style: Option<WobbleStyle>,
}
impl PotWobble {
/// `triggerEvent(1, style)`.
pub fn trigger_event(&mut self, event: i32, data: i32, game_time: i64) -> bool {
if event == 1 && (0..2).contains(&data) {
self.started_at = game_time;
self.style = Some(if data == 0 {
WobbleStyle::Positive
} else {
WobbleStyle::Negative
});
true
} else {
false
}
}
/// `DecoratedPotRenderer.extractRenderState`'s `wobbleProgress`.
pub fn progress(&self, game_time: i64, partial: f32) -> f32 {
match self.style {
Some(style) => {
((game_time - self.started_at) as f32 + partial) / style.duration() as f32
}
None => 0.0,
}
}
}
/// `TheEndGatewayBlockEntity` fields the beam reads.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub teleport_cooldown: i32,
}
impl EndGateway {
/// `beamAnimationTick` (the client ticker).
pub fn tick(&mut self) {
self.age = self.age.wrapping_add(1);
if self.teleport_cooldown > 0 {
self.teleport_cooldown -= 1;
}
}
/// `portalTick` on the server copy: whether it called `triggerCooldown`,
/// whose block event 1 reaches the client copy.
pub fn portal_tick(&mut self) -> bool {
let cooling = self.teleport_cooldown > 0;
self.age = self.age.wrapping_add(1);
if cooling {
self.teleport_cooldown -= 1;
false
} else if self.age % 2400 == 0 {
self.teleport_cooldown = 40;
true
} else {
false
}
}
/// `EndGatewayBlock.entityInside` on the server copy: an entity that can
/// use the portal starts the cooldown unless it is already running.
pub fn enter(&mut self) -> bool {
if self.teleport_cooldown > 0 {
return false;
}
self.teleport_cooldown = 40;
true
}
/// `triggerEvent`: event 1 starts the 40 tick cooldown.
pub fn trigger_event(&mut self, event: i32, _data: i32) -> bool {
if event == 1 {
self.teleport_cooldown = 40;
true
} else {
false
}
}
}
/// The client ticker a block entity runs (`getTicker` on a client level),
/// or [`Ticker::Pot`] for decorated pots, which keep wobble state unticked.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Ticker {
/// `ChestBlockEntity` / `EnderChestBlockEntity.lidAnimateTick`.
Lid,
/// `EnchantingTableBlockEntity.bookAnimationTick`.
Book,
/// `ShulkerBoxBlockEntity.tick`, with the box's facing.
Shulker {
facing: u8,
},
/// `BellBlockEntity.clientTick`.
Bell,
/// `ConduitBlockEntity.clientTick`.
Conduit,
/// `SkullBlockEntity.animation` (dragon and piglin heads only).
Skull {
powered: bool,
},
Pot,
/// `TheEndGatewayBlockEntity.beamAnimationTick`.
Gateway,
}
/// The [`Ticker`] of a block state, by block name and property lookup.
pub fn ticker(name: &str, prop: impl Fn(&str) -> Option<String>) -> Option<Ticker> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
let unwaxed = name.strip_prefix("waxed_").unwrap_or(name);
Some(match unwaxed {
"chest"
| "trapped_chest"
| "ender_chest"
| "copper_chest"
| "exposed_copper_chest"
| "weathered_copper_chest"
| "oxidized_copper_chest" => Ticker::Lid,
"enchanting_table" => Ticker::Book,
"bell" => Ticker::Bell,
"conduit" => Ticker::Conduit,
"decorated_pot" => Ticker::Pot,
"end_gateway" => Ticker::Gateway,
"dragon_head" | "dragon_wall_head" | "piglin_head" | "piglin_wall_head" => Ticker::Skull {
powered: prop("powered").as_deref() == Some("true"),
},
n if n.ends_with("shulker_box") => Ticker::Shulker {
facing: prop("facing")
.as_deref()
.and_then(direction_index)
.unwrap_or(1),
},
_ => return None,
})
}
/// The animated block entities of a level, by position.
#[derive(Clone, Debug)]
pub struct BlockEntityAnims {
pub chests: HashMap<[i32; 3], ChestLid>,
pub books: HashMap<[i32; 3], EnchantingBook>,
/// `EnchantingTableBlockEntity.RANDOM`, shared by every table.
pub book_random: LegacyRandom,
pub shulkers: HashMap<[i32; 3], Shulker>,
pub bells: HashMap<[i32; 3], Bell>,
pub conduits: HashMap<[i32; 3], Conduit>,
pub skulls: HashMap<[i32; 3], Skull>,
pub pots: HashMap<[i32; 3], PotWobble>,
pub gateways: HashMap<[i32; 3], EndGateway>,
/// The integrated server's copies of the gateways (`portalTick`).
pub gateway_servers: HashMap<[i32; 3], EndGateway>,
/// The client level's random (conduit particles).
pub random: LegacyRandom,
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
pub kinds: Vec<Option<Ticker>>,
}
impl Default for BlockEntityAnims {
fn default() -> Self {
Self {
chests: HashMap::new(),
books: HashMap::new(),
book_random: LegacyRandom::new(0x626f6f6b),
shulkers: HashMap::new(),
bells: HashMap::new(),
conduits: HashMap::new(),
skulls: HashMap::new(),
pots: HashMap::new(),
gateways: HashMap::new(),
gateway_servers: HashMap::new(),
random: LegacyRandom::new(0x6c6576656c),
kinds: Vec::new(),
}
}
}
impl BlockEntityAnims {
/// Fills [`Self::kinds`] for every state of the registry.
pub fn classify(&mut self, registry: &horizon_world::registry::BlockRegistry) {
self.kinds = (0..registry.len() as u32)
.map(|id| {
registry
.state(horizon_world::registry::BlockStateId(id))
.and_then(|s| ticker(&s.name, |k| s.properties.get(k).cloned()))
})
.collect();
}
/// The ticker of a block state.
pub fn ticker(&self, state: u32) -> Option<Ticker> {
self.kinds.get(state as usize).copied().flatten()
}
/// Drops the state of block entities that are gone or changed kind:
/// `present` maps the loaded block entities to their tickers.
pub fn retain_present(&mut self, present: &HashMap<[i32; 3], Ticker>) {
let is = |p: &[i32; 3], f: fn(&Ticker) -> bool| present.get(p).is_some_and(f);
self.chests.retain(|p, _| is(p, |t| *t == Ticker::Lid));
self.books.retain(|p, _| is(p, |t| *t == Ticker::Book));
self.shulkers
.retain(|p, _| is(p, |t| matches!(t, Ticker::Shulker { .. })));
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
self.conduits
.retain(|p, _| is(p, |t| *t == Ticker::Conduit));
self.skulls
.retain(|p, _| is(p, |t| matches!(t, Ticker::Skull { .. })));
self.pots.retain(|p, _| is(p, |t| *t == Ticker::Pot));
self.gateways
.retain(|p, _| is(p, |t| *t == Ticker::Gateway));
}
/// Drops the state of positions whose block entity is gone.
pub fn retain(&mut self, keep: impl Fn([i32; 3]) -> bool) {
self.chests.retain(|p, _| keep(*p));
self.books.retain(|p, _| keep(*p));
self.shulkers.retain(|p, _| keep(*p));
self.bells.retain(|p, _| keep(*p));
self.conduits.retain(|p, _| keep(*p));
self.skulls.retain(|p, _| keep(*p));
self.pots.retain(|p, _| keep(*p));
self.gateways.retain(|p, _| keep(*p));
self.gateway_servers.retain(|p, _| keep(*p));
}
/// `getOpenNess(partial)` of the chest or ender chest at `pos`.
pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 {
self.chests
.get(&pos)
.map_or(0.0, |l| l.get_openness(partial))
}
/// The renderer's view of the block entity at `pos`; `sherds` are the
/// pot's decorations (back, left, right, front), when it is a pot.
pub fn block_anim(
&self,
pos: [i32; 3],
game_time: i64,
partial: f32,
sherds: [Option<String>; 4],
) -> BlockAnim {
let mut anim = BlockAnim {
sherds,
// Gateways not ticked yet render settled (no beam).
gateway: self
.gateways
.get(&pos)
.map_or((i64::MAX, 0), |g| (g.age, g.teleport_cooldown)),
..BlockAnim::default()
};
if let Some(b) = self.books.get(&pos) {
anim.book = *b;
}
if let Some(s) = self.shulkers.get(&pos) {
anim.shulker = (s.progress, s.progress_old);
}
if let Some(b) = self.bells.get(&pos) {
anim.bell_ticks = b.ticks;
anim.bell_shake = b
.shaking
.then_some(b.click_direction)
.flatten()
.map(|d| DIRECTIONS[usize::from(d)]);
}
if let Some(c) = self.conduits.get(&pos) {
anim.conduit = (c.tick_count, c.is_active, c.is_hunting, c.active_rotation);
}
if let Some(s) = self.skulls.get(&pos) {
anim.skull = (s.animating, s.tick_count);
}
if let Some(w) = self.pots.get(&pos)
&& w.style.is_some()
{
// extractRenderState never copies lastWobbleStyle into the render
// state, so DecoratedPotRenderer.submit always takes the
// non-POSITIVE branch (with the style's own duration).
anim.wobble = Some((false, w.progress(game_time, partial)));
}
anim
}
}
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
// The world border wall (core/world_border): camera-relative wall quads
// moved by the model offset, the forcefield texture scrolled by the texture
// offset and modulated by the status colour and fade.
struct Globals {
view_proj: mat4x4<f32>,
}
struct Border {
color: vec4<f32>,
// xyz: model offset, w: texture translation (u and v)
offset: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var forcefield: texture_2d<f32>;
@group(0) @binding(2) var forcefield_sampler: sampler;
@group(0) @binding(3) var<uniform> border: Border;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn vs_border(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>) -> Out {
var out: Out;
out.clip = globals.view_proj * vec4(position + border.offset.xyz, 1.0);
out.uv = uv + vec2(border.offset.w);
return out;
}
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
let color = textureSample(forcefield, forcefield_sampler, in.uv);
if color.a == 0.0 {
discard;
}
return color * border.color;
}
+289
View File
@@ -0,0 +1,289 @@
//! The world border wall (`WorldBorderRenderer`, `WorldBorderRenderState`):
//! which border the camera is near, the wall's fade and status colour, the
//! cached vertex buffer of the four walls, the scrolling texture transform
//! and which walls are drawn. Kept free of GPU types so the parity fixture
//! can compare every value with vanilla's renderer.
use crate::worldborder::{WorldBorder, jclamp, jmax, jmin, status_color};
/// `WorldBorderRenderer.RENDERING_OFFSET`.
const OFFSET: f64 = 0.01;
/// `WorldBorderRenderState`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct BorderRenderState {
pub min_x: f64,
pub max_x: f64,
pub min_z: f64,
pub max_z: f64,
/// `BorderStatus.getColor`, set only when the wall is visible.
pub tint: u32,
pub alpha: f64,
}
impl BorderRenderState {
/// `closestBorder`: the four walls by distance from (x, z), nearest
/// first (a stable sort of north, south, west, east), as
/// `Direction.get2DDataValue` side indices and distances.
pub fn closest_border(&self, x: f64, z: f64) -> [(usize, f64); 4] {
let mut sides = [
(2, z - self.min_z),
(0, self.max_z - z),
(1, x - self.min_x),
(3, self.max_x - x),
];
// Comparator.comparingDouble: Double.compare order, stable.
sides.sort_by(|a, b| java_double_compare(a.1, b.1));
sides
}
}
/// `Double.compare`.
fn java_double_compare(a: f64, b: f64) -> std::cmp::Ordering {
use std::cmp::Ordering::*;
if a < b {
Less
} else if a > b {
Greater
} else {
let bits = |v: f64| {
if v.is_nan() {
0x7ff8_0000_0000_0000u64 as i64
} else {
v.to_bits() as i64
}
};
bits(a).cmp(&bits(b))
}
}
/// `WorldBorderRenderer.extract`.
pub fn extract(
border: &WorldBorder,
partial: f32,
camera: [f64; 3],
render_distance: f64,
state: &mut BorderRenderState,
) {
let [min_x, min_z, max_x, max_z] = border.bounds_at(partial);
state.min_x = min_x;
state.max_x = max_x;
state.min_z = min_z;
state.max_z = max_z;
let (x, z) = (camera[0], camera[2]);
let deep_inside = x < max_x - render_distance
&& x > min_x + render_distance
&& z < max_z - render_distance
&& z > min_z + render_distance;
let far_outside = x < min_x - render_distance
|| x > max_x + render_distance
|| z < min_z - render_distance
|| z > max_z + render_distance;
if !deep_inside && !far_outside {
let alpha = 1.0 - border.distance(x, z) / render_distance;
state.alpha = jclamp(alpha.powf(4.0), 0.0, 1.0);
state.tint = status_color(border.status());
} else {
state.alpha = 0.0;
}
}
/// One `POSITION_TEX` vertex of the wall buffer: position relative to the
/// buffer origin and texture coordinates.
pub type BorderVertex = [f32; 5];
/// What `WorldBorderRenderer.prepareDynamicTransforms` writes: the colour
/// modulator, the model offset of the buffer origin from the camera and
/// the texture matrix translation (both u and v).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BorderTransforms {
pub color: [f32; 4],
pub model_offset: [f32; 3],
pub texture_offset: f32,
}
/// `WorldBorderRenderer`'s cached wall buffer.
#[derive(Clone, Debug)]
pub struct BorderRenderer {
needs_rebuild: bool,
/// Set when `prepare` rebuilt the buffer (cleared by [`Self::take_rebuilt`]).
rebuilt: bool,
last_min_x: f64,
last_min_z: f64,
last_border: [f64; 4],
/// Four quads: south, west, north, east (`get2DDataValue` order).
pub vertices: [BorderVertex; 16],
}
impl Default for BorderRenderer {
fn default() -> Self {
Self {
needs_rebuild: true,
rebuilt: false,
last_min_x: 0.0,
last_min_z: 0.0,
last_border: [0.0; 4],
vertices: [[0.0; 5]; 16],
}
}
}
/// `Mth.floor(double)`.
fn floor(v: f64) -> i32 {
let i = v as i32;
if v < f64::from(i) { i - 1 } else { i }
}
/// `Mth.ceil(double)`.
fn ceil(v: f64) -> i32 {
let i = v as i32;
if v > f64::from(i) { i + 1 } else { i }
}
/// `Mth.frac(double)`.
fn frac(v: f64) -> f64 {
v - v.floor()
}
impl BorderRenderer {
/// `invalidate`: rebuild on the next prepare (e.g. after a resource reload).
pub fn invalidate(&mut self) {
self.needs_rebuild = true;
}
/// `prepare`: rebuilds the buffer when the border moved; returns whether
/// the wall is drawn this frame.
pub fn prepare(
&mut self,
state: &BorderRenderState,
camera: [f64; 3],
render_distance: f64,
depth_far: f32,
) -> bool {
if state.alpha <= 0.0 {
return false;
}
let half_height = depth_far;
let v0 = (-frac(camera[1] * 0.5)) as f32;
let v1 = v0 + half_height;
let border = [state.min_x, state.min_z, state.max_x, state.max_z];
if self.needs_rebuild || border != self.last_border {
self.rebuild(
state,
render_distance,
camera[2],
camera[0],
half_height,
v1,
v0,
);
}
true
}
/// `rebuildWorldBorderBuffer`.
#[allow(clippy::too_many_arguments)]
fn rebuild(
&mut self,
state: &BorderRenderState,
render_distance: f64,
camera_z: f64,
camera_x: f64,
h: f32,
v1: f32,
v0: f32,
) {
let border_min_x = state.min_x + OFFSET;
let border_max_x = state.max_x - OFFSET;
let border_min_z = state.min_z + OFFSET;
let border_max_z = state.max_z - OFFSET;
let min_z = jmax(f64::from(floor(camera_z - render_distance)), border_min_z);
let max_z = jmin(f64::from(ceil(camera_z + render_distance)), border_max_z);
let u0z = (floor(min_z) & 1) as f32 * 0.5;
let u1z = (max_z - min_z) as f32 / 2.0;
let min_x = jmax(f64::from(floor(camera_x - render_distance)), border_min_x);
let max_x = jmin(f64::from(ceil(camera_x + render_distance)), border_max_x);
let u0x = (floor(min_x) & 1) as f32 * 0.5;
let u1x = (max_x - min_x) as f32 / 2.0;
let (dx, dz) = ((max_x - min_x) as f32, (max_z - min_z) as f32);
let south = (border_max_z - min_z) as f32;
let east = (border_max_x - min_x) as f32;
self.vertices = [
[0.0, -h, south, u0x, v1],
[dx, -h, south, u1x + u0x, v1],
[dx, h, south, u1x + u0x, v0],
[0.0, h, south, u0x, v0],
[0.0, -h, 0.0, u0z, v1],
[0.0, -h, dz, u1z + u0z, v1],
[0.0, h, dz, u1z + u0z, v0],
[0.0, h, 0.0, u0z, v0],
[dx, -h, 0.0, u0x, v1],
[0.0, -h, 0.0, u1x + u0x, v1],
[0.0, h, 0.0, u1x + u0x, v0],
[dx, h, 0.0, u0x, v0],
[east, -h, dz, u0z, v1],
[east, -h, 0.0, u1z + u0z, v1],
[east, h, 0.0, u1z + u0z, v0],
[east, h, dz, u0z, v0],
];
self.last_border = [state.min_x, state.min_z, state.max_x, state.max_z];
self.last_min_x = min_x;
self.last_min_z = min_z;
self.needs_rebuild = false;
self.rebuilt = true;
}
/// Whether the buffer was rebuilt since the last call (it must be
/// uploaded again).
pub fn take_rebuilt(&mut self) -> bool {
std::mem::take(&mut self.rebuilt)
}
/// `prepareDynamicTransforms` at `Util.getMillis()` = `millis`.
pub fn transforms(
&self,
state: &BorderRenderState,
camera: [f64; 3],
millis: i64,
) -> BorderTransforms {
let channel = |shift: u32| ((state.tint >> shift) & 0xFF) as f32 / 255.0;
let offset = (millis % 3000) as f32 / 3000.0;
BorderTransforms {
color: [channel(16), channel(8), channel(0), state.alpha as f32],
model_offset: [
(self.last_min_x - camera[0]) as f32,
(-camera[1]) as f32,
(self.last_min_z - camera[2]) as f32,
],
texture_offset: offset,
}
}
/// `draw`: the walls within the render distance, nearest first, as side
/// indices into [`Self::vertices`] (four vertices each).
pub fn sides(state: &BorderRenderState, camera: [f64; 3], render_distance: f64) -> Vec<usize> {
state
.closest_border(camera[0], camera[2])
.into_iter()
.filter(|&(_, d)| d < render_distance)
.map(|(side, _)| side)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn near_wall_is_drawn_and_tinted() {
let mut border = WorldBorder::default();
border.set_size(100.0);
let mut state = BorderRenderState::default();
let camera = [40.0, 64.0, 0.0];
extract(&border, 0.0, camera, 128.0, &mut state);
assert!(state.alpha > 0.0);
assert_eq!(state.tint, 0x20A0FF);
let mut r = BorderRenderer::default();
assert!(r.prepare(&state, camera, 128.0, 512.0));
// East wall first (10 blocks away), then north and south, then west.
assert_eq!(
BorderRenderer::sides(&state, camera, 128.0),
vec![3, 2, 0, 1]
);
let t = r.transforms(&state, camera, 4500);
assert_eq!(t.texture_offset, 0.5);
// Far inside a huge border, nothing is drawn.
border.set_size(10_000.0);
extract(&border, 0.0, camera, 128.0, &mut state);
assert_eq!(state.alpha, 0.0);
assert!(!r.prepare(&state, camera, 128.0, 512.0));
}
}
+3 -1
View File
@@ -57,7 +57,9 @@ impl BossBar {
hover: Some(Box::new(Text::literal(self.id.clone()))),
..Style::default()
};
bracketed(comp_text(&self.name).with_style(&style))
// ComponentUtils.wrapInSquareBrackets(name).withStyle(...): the
// brackets take the colour too.
bracketed(comp_text(&self.name)).with_style(&style)
}
/// The filled fraction of the bar (`BossEvent.getProgress`).
pub fn progress(&self) -> f32 {
+783
View File
@@ -0,0 +1,783 @@
//! The camera (`Camera.tick`, `tickFov`, `calculateFov`, `alignWithEntity`,
//! `getMaxZoom`, `setRotation`, `move`), view and hurt bobbing
//! (`GameRenderer.bobView`, `bobHurt`), the walk and bob history they read
//! (`ClientAvatarState`), and `AbstractClientPlayer.getFieldOfViewModifier`.
//!
//! The float arithmetic follows vanilla and JOML's default (non-FMA,
//! non-fast-math) code paths operation by operation.
use horizon_core::geometry::trig;
/// `(float) Math.PI`.
const PI: f32 = std::f32::consts::PI;
/// `(float) (Math.PI / 180.0)`.
const DEG_TO_RAD: f32 = trig::DEG_TO_RAD;
/// `Math.min(float, float)`: NaN wins and -0.0 is below 0.0.
pub fn java_min(a: f32, b: f32) -> f32 {
if a.is_nan() || b.is_nan() {
f32::NAN
} else if a == 0.0 && b == 0.0 {
f32::from_bits(a.to_bits() | b.to_bits())
} else if a <= b {
a
} else {
b
}
}
/// `Math.max(float, float)`.
pub fn java_max(a: f32, b: f32) -> f32 {
if a.is_nan() || b.is_nan() {
f32::NAN
} else if a == 0.0 && b == 0.0 {
f32::from_bits(a.to_bits() & b.to_bits())
} else if a >= b {
a
} else {
b
}
}
/// `Mth.lerp(float, float, float)`.
pub fn lerp(alpha: f32, p0: f32, p1: f32) -> f32 {
p0 + alpha * (p1 - p0)
}
/// `Mth.lerp(double, double, double)`.
pub fn lerp_d(alpha: f64, p0: f64, p1: f64) -> f64 {
p0 + alpha * (p1 - p0)
}
/// `Mth.wrapDegrees(float)`.
pub fn wrap_degrees(angle: f32) -> f32 {
let mut normalized = angle % 360.0;
if normalized >= 180.0 {
normalized -= 360.0;
}
if normalized < -180.0 {
normalized += 360.0;
}
normalized
}
/// `Mth.clamp(float, float, float)`.
pub fn clamp(value: f32, min: f32, max: f32) -> f32 {
if value < min {
min
} else {
java_min(value, max)
}
}
/// JOML's float trigonometry (`org.joml.Math` without fast math).
pub mod joml {
use super::PI;
const TAU: f32 = std::f32::consts::TAU;
const HALF_PI: f32 = std::f32::consts::FRAC_PI_2;
/// `Math.sin(float)`: `java.lang.Math.sin` narrowed.
pub fn sin(rad: f32) -> f32 {
f64::from(rad).sin() as f32
}
/// `Math.cosFromSin(float, float)`.
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
let cos = (1.0 - sin * sin).sqrt();
let a = angle + HALF_PI;
let mut b = a - (a / TAU) as i32 as f32 * TAU;
if f64::from(b) < 0.0 {
b += TAU;
}
if b >= PI { -cos } else { cos }
}
/// A JOML `Quaternionf` (x, y, z, w).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Quat {
pub x: f32,
pub y: f32,
pub z: f32,
pub w: f32,
}
impl Quat {
pub const IDENTITY: Self = Self {
x: 0.0,
y: 0.0,
z: 0.0,
w: 1.0,
};
/// `Quaternionf.rotationYXZ(angleY, angleX, angleZ)`.
pub fn rotation_yxz(angle_y: f32, angle_x: f32, angle_z: f32) -> Self {
let sx = sin(angle_x * 0.5);
let cx = cos_from_sin(sx, angle_x * 0.5);
let sy = sin(angle_y * 0.5);
let cy = cos_from_sin(sy, angle_y * 0.5);
let sz = sin(angle_z * 0.5);
let cz = cos_from_sin(sz, angle_z * 0.5);
let x = cy * sx;
let y = sy * cx;
let z = sy * sx;
let w = cy * cx;
Self {
x: x * cz + y * sz,
y: y * cz - x * sz,
z: w * sz - z * cz,
w: w * cz + z * sz,
}
}
/// `Quaternionf.transform(x, y, z, dest)` (`Vector3f.rotate`).
pub fn transform(&self, v: [f32; 3]) -> [f32; 3] {
let [x, y, z] = v;
let xx = self.x * self.x;
let yy = self.y * self.y;
let zz = self.z * self.z;
let ww = self.w * self.w;
let xy = self.x * self.y;
let xz = self.x * self.z;
let yz = self.y * self.z;
let xw = self.x * self.w;
let zw = self.z * self.w;
let yw = self.y * self.w;
let k = 1.0 / (xx + yy + zz + ww);
[
(xx - yy - zz + ww) * k * x + (2.0 * (xy - zw) * k * y + 2.0 * (xz + yw) * k * z),
2.0 * (xy + zw) * k * x + ((yy - xx - zz + ww) * k * y + 2.0 * (yz - xw) * k * z),
2.0 * (xz - yw) * k * x + (2.0 * (yz + xw) * k * y + (zz - xx - yy + ww) * k * z),
]
}
}
const PERSPECTIVE: u8 = 1;
const IDENTITY: u8 = 4;
const TRANSLATION: u8 = 8;
/// A JOML `Matrix4f` with its property flags, which select the code
/// paths (and so the rounding) of later operations. `m[column * 4 + row]`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Mat4 {
pub m: [f32; 16],
pub properties: u8,
}
impl Default for Mat4 {
fn default() -> Self {
Self::IDENTITY
}
}
impl Mat4 {
/// `new Matrix4f()`.
pub const IDENTITY: Self = Self {
m: [
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
],
properties: 30,
};
fn get(&self, column: usize, row: usize) -> f32 {
self.m[column * 4 + row]
}
fn set(&mut self, column: usize, row: usize, v: f32) {
self.m[column * 4 + row] = v;
}
fn reset_unless_identity(&mut self) {
if self.properties & IDENTITY == 0 {
self.m = Self::IDENTITY.m;
}
}
/// `Matrix4f.translation(x, y, z)`.
pub fn translation(&mut self, x: f32, y: f32, z: f32) {
self.reset_unless_identity();
self.set(3, 0, x);
self.set(3, 1, y);
self.set(3, 2, z);
self.properties = 26;
}
/// `Matrix4f.setTranslation(x, y, z)`.
pub fn set_translation(&mut self, x: f32, y: f32, z: f32) {
self.set(3, 0, x);
self.set(3, 1, y);
self.set(3, 2, z);
self.properties &= !(PERSPECTIVE | IDENTITY);
}
/// `Matrix4f.translate(x, y, z)`.
pub fn translate(&mut self, x: f32, y: f32, z: f32) {
if self.properties & IDENTITY != 0 {
self.translation(x, y, z);
return;
}
for row in 0..4 {
let v = self.get(0, row) * x
+ (self.get(1, row) * y + (self.get(2, row) * z + self.get(3, row)));
self.set(3, row, v);
}
self.properties &= !(PERSPECTIVE | IDENTITY);
}
fn rotation_axis(&mut self, axis: usize, ang: f32) {
let sin = sin(ang);
let cos = cos_from_sin(sin, ang);
self.reset_unless_identity();
match axis {
0 => {
self.set(1, 1, cos);
self.set(1, 2, sin);
self.set(2, 1, -sin);
self.set(2, 2, cos);
}
1 => {
self.set(0, 0, cos);
self.set(0, 2, -sin);
self.set(2, 0, sin);
self.set(2, 2, cos);
}
_ => {
self.set(0, 0, cos);
self.set(0, 1, sin);
self.set(1, 0, -sin);
self.set(1, 1, cos);
}
}
self.properties = 18;
}
/// `Matrix4f.rotateX/rotateY/rotateZ(ang)`, with the identity and
/// pure-translation shortcuts.
fn rotate_axis(&mut self, axis: usize, ang: f32) {
if self.properties & IDENTITY != 0 {
self.rotation_axis(axis, ang);
return;
}
if self.properties & TRANSLATION != 0 {
let (x, y, z) = (self.get(3, 0), self.get(3, 1), self.get(3, 2));
self.rotation_axis(axis, ang);
self.set_translation(x, y, z);
return;
}
let sin = sin(ang);
let cos = cos_from_sin(sin, ang);
let s = *self;
match axis {
0 => {
for row in 0..4 {
let (l1, l2) = (s.get(1, row), s.get(2, row));
self.set(2, row, l1 * -sin + l2 * cos);
self.set(1, row, l1 * cos + l2 * sin);
}
}
1 => {
for row in 0..4 {
let (l0, l2) = (s.get(0, row), s.get(2, row));
self.set(0, row, l0 * cos + l2 * -sin);
self.set(2, row, l0 * sin + l2 * cos);
}
}
_ => {
// rotateTowardsXY(sin, cos).
let (dx, dy) = (sin, cos);
for row in 0..4 {
let (l0, l1) = (s.get(0, row), s.get(1, row));
self.set(0, row, l0 * dy + l1 * dx);
self.set(1, row, l0 * -dx + l1 * dy);
}
}
}
self.properties &= !(PERSPECTIVE | IDENTITY | TRANSLATION);
}
pub fn rotate_x(&mut self, ang: f32) {
self.rotate_axis(0, ang);
}
pub fn rotate_y(&mut self, ang: f32) {
self.rotate_axis(1, ang);
}
pub fn rotate_z(&mut self, ang: f32) {
// rotateTowardsXY also short-cuts on identity, but rotateZ has
// already taken that branch.
self.rotate_axis(2, ang);
}
}
}
use joml::{Mat4, Quat};
/// `CameraType`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CameraType {
#[default]
FirstPerson,
ThirdPersonBack,
ThirdPersonFront,
}
impl CameraType {
pub fn is_first_person(self) -> bool {
self == Self::FirstPerson
}
pub fn is_mirrored(self) -> bool {
self == Self::ThirdPersonFront
}
/// `CameraType.cycle` (F5).
pub fn cycle(self) -> Self {
match self {
Self::FirstPerson => Self::ThirdPersonBack,
Self::ThirdPersonBack => Self::ThirdPersonFront,
Self::ThirdPersonFront => Self::FirstPerson,
}
}
}
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FovPlayer {
/// `Abilities.flying`.
pub flying: bool,
/// `Abilities.getWalkingSpeed()`.
pub walking_speed: f32,
/// `getAttributeValue(MOVEMENT_SPEED)`.
pub movement_speed: f64,
/// The item in use and its use ticks, when using one.
pub using: Option<(UseItem, i32)>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UseItem {
Bow,
Spyglass,
Other,
}
/// `AbstractClientPlayer.getFieldOfViewModifier(firstPerson, effectScale)`.
pub fn field_of_view_modifier(p: &FovPlayer, first_person: bool, effect_scale: f32) -> f32 {
let mut modifier = 1.0f32;
if p.flying {
modifier *= 1.1;
}
let walking_speed = p.walking_speed;
if walking_speed != 0.0 {
let speed_factor = p.movement_speed as f32 / walking_speed;
modifier *= (speed_factor + 1.0) / 2.0;
}
if let Some((item, ticks)) = p.using {
match item {
UseItem::Bow => {
let scale = java_min(ticks as f32 / 20.0, 1.0);
modifier *= 1.0 - scale * scale * 0.15;
}
// isScoping: using a spyglass.
UseItem::Spyglass if first_person => return 0.1,
_ => {}
}
}
lerp(effect_scale, 1.0, modifier)
}
/// The camera entity as `Camera.alignWithEntity` and the field-of-view
/// death and hurt effects see it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CameraEntity {
pub position: [f64; 3],
/// `xo`, `yo`, `zo`.
pub previous: [f64; 3],
pub x_rot: f32,
pub y_rot: f32,
pub x_rot_o: f32,
pub y_rot_o: f32,
/// `Entity.getEyeHeight()`.
pub eye_height: f32,
/// A living entity's `getScale()` and camera distance attribute.
pub living: Option<LivingCamera>,
/// The vehicle's scale and camera distance when it is living.
pub mount: Option<(f32, f64)>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LivingCamera {
pub scale: f32,
pub camera_distance: f64,
/// `isSleeping()` with `getBedOrientation()` as its `toYRot()`.
pub sleeping: Option<Option<f32>>,
/// `isDeadOrDying()` and `deathTime`.
pub dead: Option<i32>,
}
impl CameraEntity {
/// `Entity.getXRot(partialTicks)`.
pub fn view_x_rot(&self, partial: f32) -> f32 {
if partial == 1.0 {
self.x_rot
} else {
lerp(partial, self.x_rot_o, self.x_rot)
}
}
/// `LocalPlayer.getViewYRot` (`getYRot(partialTicks)`, a `Mth.rotLerp`).
pub fn view_y_rot(&self, partial: f32) -> f32 {
if partial == 1.0 {
self.y_rot
} else {
self.y_rot_o + partial * wrap_degrees(self.y_rot - self.y_rot_o)
}
}
}
/// The fluid `Camera.getFluidInCamera` reports, as the field of view sees it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CameraFluid {
#[default]
None,
Water,
Lava,
PowderSnow,
}
/// `Camera`: the eye-height and field-of-view easing and the view placement.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
pub eye_height: f32,
pub eye_height_old: f32,
pub fov_modifier: f32,
pub old_fov_modifier: f32,
pub position: [f64; 3],
pub x_rot: f32,
pub y_rot: f32,
pub rotation: Quat,
pub forwards: [f32; 3],
pub up: [f32; 3],
pub left: [f32; 3],
pub detached: bool,
}
impl Default for Camera {
fn default() -> Self {
Self {
eye_height: 0.0,
eye_height_old: 0.0,
fov_modifier: 0.0,
old_fov_modifier: 0.0,
position: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
rotation: Quat::IDENTITY,
forwards: [0.0, 0.0, -1.0],
up: [0.0, 1.0, 0.0],
left: [-1.0, 0.0, 0.0],
detached: false,
}
}
}
impl Camera {
/// `Camera.tick`: the eye height eases halfway toward the entity's
/// (with a level and an entity), then `tickFov` with the camera entity's
/// field-of-view modifier target (1 when it is not a player).
pub fn tick(&mut self, entity_eye_height: Option<f32>, target_fov_modifier: f32) {
if let Some(eye) = entity_eye_height {
self.eye_height_old = self.eye_height;
self.eye_height += (eye - self.eye_height) * 0.5;
}
self.old_fov_modifier = self.fov_modifier;
self.fov_modifier += (target_fov_modifier - self.fov_modifier) * 0.5;
self.fov_modifier = clamp(self.fov_modifier, 0.1, 1.5);
}
/// `Camera.setRotation`.
pub fn set_rotation(&mut self, y_rot: f32, x_rot: f32) {
self.x_rot = x_rot;
self.y_rot = y_rot;
self.rotation = Quat::rotation_yxz(PI - y_rot * DEG_TO_RAD, -x_rot * DEG_TO_RAD, 0.0);
self.forwards = self.rotation.transform([0.0, 0.0, -1.0]);
self.up = self.rotation.transform([0.0, 1.0, 0.0]);
self.left = self.rotation.transform([-1.0, 0.0, 0.0]);
}
/// `Camera.move(forwards, up, right)`.
pub fn move_by(&mut self, forwards: f32, up: f32, right: f32) {
let offset = self.rotation.transform([right, up, -forwards]);
self.position = [
self.position[0] + f64::from(offset[0]),
self.position[1] + f64::from(offset[1]),
self.position[2] + f64::from(offset[2]),
];
}
/// `Camera.alignWithEntity` (not riding a minecart with the new
/// movement): the entity's view rotation and interpolated eye, then a
/// detached camera backed off along the view (`getMaxZoom`) or a sleeping
/// one turned to the bed. `clip` is `Level.clip` with
/// `ClipContext.Block.VISUAL` and no fluids; it returns the hit location
/// unless the ray misses.
pub fn align_with_entity(
&mut self,
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
let p = f64::from(partial);
self.position = [
lerp_d(p, entity.previous[0], entity.position[0]),
lerp_d(p, entity.previous[1], entity.position[1])
+ f64::from(lerp(partial, self.eye_height_old, self.eye_height)),
lerp_d(p, entity.previous[2], entity.position[2]),
];
self.detached = !camera_type.is_first_person();
if self.detached {
if camera_type.is_mirrored() {
self.set_rotation(self.y_rot + 180.0, -self.x_rot);
}
let (mut distance, mut scale) = (4.0f32, 1.0f32);
if let Some(living) = &entity.living {
scale = living.scale;
distance = living.camera_distance as f32;
}
let (mut mount_scale, mut mount_distance) = (scale, distance);
if let Some((s, d)) = entity.mount {
mount_scale = s;
mount_distance = d as f32;
}
let zoom = self.max_zoom(
java_max(scale * distance, mount_scale * mount_distance),
&mut clip,
);
self.move_by(-zoom, 0.0, 0.0);
} else if let Some(LivingCamera {
sleeping: Some(bed),
..
}) = entity.living
{
self.set_rotation(bed.map_or(0.0, |yaw| yaw - 180.0), 0.0);
self.move_by(0.0, 0.15, 0.0);
}
}
/// `Camera.getMaxZoom`: eight rays from the corners of a 0.2 cube about
/// the eye, shortening the distance to the nearest visual-shape hit.
pub fn max_zoom(
&self,
mut distance: f32,
clip: &mut impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) -> f32 {
let at = self.position;
for i in 0..8 {
let offset = [
((i & 1) * 2 - 1) as f32,
((i >> 1 & 1) * 2 - 1) as f32,
((i >> 2 & 1) * 2 - 1) as f32,
];
let from: [f64; 3] = std::array::from_fn(|a| at[a] + f64::from(offset[a] * 0.1));
let back = f64::from(-distance);
let to: [f64; 3] =
std::array::from_fn(|a| from[a] + f64::from(self.forwards[a]) * back);
if let Some(hit) = clip(from, to) {
let d = [hit[0] - at[0], hit[1] - at[1], hit[2] - at[2]];
let dist_sq = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]) as f32;
if dist_sq < distance * distance {
distance = dist_sq.sqrt();
}
}
}
distance
}
/// `Camera.calculateFov`: the option scaled by the eased modifier, then
/// the death and fluid effects.
pub fn calculate_fov(
&self,
partial: f32,
fov_option: i32,
effect_scale: f32,
dead: Option<i32>,
fluid: CameraFluid,
panoramic: bool,
) -> f32 {
if panoramic {
return 90.0;
}
let fov = fov_option as f32 * lerp(partial, self.old_fov_modifier, self.fov_modifier);
modify_fov_based_on_death_or_fluid(partial, fov, effect_scale, dead, fluid)
}
/// `Camera.calculateHudFov`.
pub fn calculate_hud_fov(
&self,
partial: f32,
effect_scale: f32,
dead: Option<i32>,
fluid: CameraFluid,
) -> f32 {
modify_fov_based_on_death_or_fluid(partial, 70.0, effect_scale, dead, fluid)
}
}
/// `Camera.modifyFovBasedOnDeathOrFluid`. `dead` is the camera entity's
/// `deathTime` while it is a living entity that is dead or dying.
pub fn modify_fov_based_on_death_or_fluid(
partial: f32,
mut fov: f32,
effect_scale: f32,
dead: Option<i32>,
fluid: CameraFluid,
) -> f32 {
if let Some(death_time) = dead {
let duration = java_min(death_time as f32 + partial, 20.0);
fov /= (1.0 - 500.0 / (duration + 500.0)) * 2.0 + 1.0;
}
if matches!(fluid, CameraFluid::Lava | CameraFluid::Water) {
fov *= lerp(effect_scale, 1.0, 0.857_142_87);
}
fov
}
/// `ClientAvatarState`: the walked distance and view bob of a player.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct AvatarBob {
pub walk_dist: f32,
pub walk_dist_o: f32,
pub bob: f32,
pub bob_o: f32,
}
impl AvatarBob {
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
pub fn tick(&mut self) {
self.walk_dist_o = self.walk_dist;
}
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
/// with the float horizontal displacement.
pub fn add_walked(&mut self, dx: f32, dz: f32) {
self.walk_dist += (dx * dx + dz * dz).sqrt() * 0.6;
}
/// `AbstractClientPlayer.updateBob`: toward the horizontal speed (at most
/// 0.1) on the ground, toward 0 otherwise.
pub fn update_bob(&mut self, on_ground_alive_not_swimming: bool, velocity: [f64; 3]) {
let target = if on_ground_alive_not_swimming {
java_min(
0.1,
(velocity[0] * velocity[0] + velocity[2] * velocity[2]).sqrt() as f32,
)
} else {
0.0
};
self.bob_o = self.bob;
self.bob += (target - self.bob) * 0.4;
}
/// `ClientAvatarState.resetBob` (`AbstractClientPlayer.rideTick`).
pub fn reset_bob(&mut self) {
self.bob_o = self.bob;
self.bob = 0.0;
}
pub fn interpolated_bob(&self, partial: f32) -> f32 {
lerp(partial, self.bob_o, self.bob)
}
pub fn backwards_interpolated_walk_distance(&self, partial: f32) -> f32 {
let wda = self.walk_dist - self.walk_dist_o;
-(self.walk_dist + wda * partial)
}
}
/// The parts of `CameraRenderState.entityRenderState` the bobbing reads.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct BobState {
/// A living camera entity: `hurtTime - partial`, `hurtDuration`,
/// `getHurtDir()`, and `deathTime + partial` when dead or dying.
pub living: Option<HurtBob>,
/// A player camera entity: the backwards-interpolated walk distance and
/// the interpolated bob.
pub player: Option<(f32, f32)>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct HurtBob {
pub hurt_time: f32,
pub hurt_duration: i32,
pub hurt_dir: f32,
pub death_time: Option<f32>,
}
/// `PoseStack.rotateDegrees` on the pose with an `Axis`.
fn rotate_degrees(m: &mut Mat4, axis: usize, degrees: f32) {
let ang = degrees * DEG_TO_RAD;
match axis {
0 => m.rotate_x(ang),
1 => m.rotate_y(ang),
_ => m.rotate_z(ang),
}
}
/// `GameRenderer.bobHurt`.
pub fn bob_hurt(state: &BobState, damage_tilt_strength: f64, pose: &mut Mat4) {
let Some(living) = state.living else { return };
let mut hurt = living.hurt_time;
if let Some(death_time) = living.death_time {
let duration = java_min(death_time, 20.0);
rotate_degrees(pose, 2, 40.0 - 8000.0 / (duration + 200.0));
}
if hurt < 0.0 {
return;
}
hurt /= living.hurt_duration as f32;
hurt = trig::sin(f64::from(hurt * hurt * hurt * hurt * PI));
let rr = living.hurt_dir;
rotate_degrees(pose, 1, -rr);
let tilt = (f64::from(-hurt) * 14.0 * damage_tilt_strength) as f32;
rotate_degrees(pose, 2, tilt);
rotate_degrees(pose, 1, rr);
}
/// `GameRenderer.bobView`.
pub fn bob_view(state: &BobState, pose: &mut Mat4) {
let Some((walk, bob)) = state.player else {
return;
};
pose.translate(
trig::sin(f64::from(walk * PI)) * bob * 0.5,
-(trig::cos(f64::from(walk * PI)) * bob).abs(),
0.0,
);
rotate_degrees(pose, 2, trig::sin(f64::from(walk * PI)) * bob * 3.0);
rotate_degrees(
pose,
0,
(trig::cos(f64::from(walk * PI - 0.2)) * bob).abs() * 5.0,
);
}
/// The bob matrix `GameRenderer.renderLevel` multiplies into the projection
/// (and `renderItemInHand` into the hand's pose).
pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option: bool) -> Mat4 {
let mut pose = Mat4::IDENTITY;
bob_hurt(state, damage_tilt_strength, &mut pose);
if bob_view_option {
bob_view(state, &mut pose);
}
pose
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn camera_eases_eye_height_and_fov() {
let mut camera = Camera::default();
camera.tick(Some(1.62), 1.0);
assert_eq!(camera.eye_height, 0.81);
assert_eq!(camera.fov_modifier, 0.5);
camera.tick(None, 1.0);
assert_eq!(camera.eye_height, 0.81);
assert_eq!(camera.fov_modifier, 0.75);
}
#[test]
fn rotation_faces_south_at_zero_yaw() {
let mut camera = Camera::default();
camera.set_rotation(0.0, 0.0);
assert!((camera.forwards[2] - 1.0).abs() < 1e-6);
camera.set_rotation(90.0, 0.0);
assert!((camera.forwards[0] + 1.0).abs() < 1e-6);
}
#[test]
fn detached_camera_backs_off_until_blocked() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: None,
mount: None,
};
let mut camera = Camera::default();
camera.align_with_entity(&entity, 1.0, CameraType::ThirdPersonBack, |_, _| None);
assert!((camera.position[2] + 4.0).abs() < 1e-6);
camera.align_with_entity(&entity, 1.0, CameraType::ThirdPersonBack, |_, _| {
Some([0.0, 0.0, -1.0])
});
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
}
#[test]
fn fov_modifier_combines_speed_flight_and_bow() {
let mut p = FovPlayer {
flying: false,
walking_speed: 0.1,
movement_speed: 0.13,
using: None,
};
assert_eq!(
field_of_view_modifier(&p, true, 1.0),
(0.13f32 / 0.1 + 1.0) / 2.0
);
assert_eq!(field_of_view_modifier(&p, true, 0.0), 1.0);
p.using = Some((UseItem::Spyglass, 3));
assert_eq!(field_of_view_modifier(&p, true, 1.0), 0.1);
assert_ne!(field_of_view_modifier(&p, false, 1.0), 0.1);
}
}
+124 -110
View File
@@ -5,6 +5,7 @@
use crate::{
command::{Dispatcher, ParseResults, Registries, Suggestion},
hud::{HudAtlas, HudBatch, rgba},
hud_layout::{self, GuiDraw, PoseOp},
text::{self, Style, Text},
ui::{EditBox, char_len},
};
@@ -12,15 +13,16 @@ use crate::{
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
/// The chat options (doubles, as vanilla keeps them).
#[derive(Clone, Copy, Debug)]
pub struct ChatOptions {
pub opacity: f32,
pub background_opacity: f32,
pub scale: f32,
pub width: f32,
pub height_focused: f32,
pub height_unfocused: f32,
pub line_spacing: f32,
pub opacity: f64,
pub background_opacity: f64,
pub scale: f64,
pub width: f64,
pub height_focused: f64,
pub height_unfocused: f64,
pub line_spacing: f64,
}
impl Default for ChatOptions {
fn default() -> Self {
@@ -37,20 +39,21 @@ impl Default for ChatOptions {
}
impl ChatOptions {
/// ChatComponent.getWidth/getHeight.
pub fn width_px(&self) -> f32 {
(self.width * 280.0 + 40.0).floor()
pub fn width_px(&self) -> i32 {
(self.width * 280.0 + 40.0).floor() as i32
}
pub fn height_px(&self, focused: bool) -> f32 {
pub fn height_px(&self, focused: bool) -> i32 {
((if focused {
self.height_focused
} else {
self.height_unfocused
}) * 160.0
+ 20.0)
.floor()
.floor() as i32
}
pub fn line_height(&self) -> f32 {
(9.0 * (self.line_spacing + 1.0)).floor()
/// `getLineHeight`.
pub fn line_height(&self) -> i32 {
(9.0 * (self.line_spacing + 1.0)) as i32
}
}
#[derive(Clone, Debug)]
@@ -59,12 +62,25 @@ struct Message {
content: Text,
indicator: Option<u32>,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
struct Line {
added: u64,
content: Text,
indicator: Option<u32>,
}
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
/// fading out quadratically over 20.
fn time_alpha(ticks: u64, added: u64) -> f32 {
let delta = ticks as i64 - added as i64;
let mut t = delta as f64 / 200.0;
t = 1.0 - t;
t *= 10.0;
t = t.clamp(0.0, 1.0);
t *= t;
t as f32
}
#[derive(Default)]
pub struct Chat {
messages: Vec<Message>,
@@ -80,10 +96,11 @@ pub struct Chat {
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
(self.options.height_px(focused) / self.options.line_height().max(1.0)) as usize
(self.options.height_px(focused) / self.options.line_height().max(1)) as usize
}
fn max_width(&self) -> f32 {
(self.options.width_px() / self.options.scale.max(0.01)).floor()
/// The wrapping width, `Mth.floor(getWidth() / getScale())`.
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.
pub fn add(
@@ -108,7 +125,7 @@ impl Chat {
}
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
let width = self.max_width();
for line in atlas.font.split(&message.content, width) {
for line in atlas.font.wrap_components(&message.content, width) {
if focused && self.scroll > 0 {
self.new_since_scroll = true;
self.scroll_by(1, focused);
@@ -164,116 +181,113 @@ impl Chat {
self.scroll = pos as usize;
}
}
/// ChatComponent.extractRenderState.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
if self.lines.is_empty() {
return;
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
let total = self.lines.len();
if total == 0 {
return Vec::new();
}
let o = &self.options;
let scale = o.scale;
if scale <= 0.0 {
return;
}
let max_width = (o.width_px() / scale).ceil();
let chat_bottom = ((batch.height - 40.0) / scale).floor();
let text_opacity = o.opacity * 0.9 + 0.1;
let scale = o.scale as f32;
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
let text_opacity = o.opacity as f32 * 0.9 + 0.1;
let background_opacity = o.background_opacity as f32;
let entry_height = o.line_height();
let bottom_to_text = (8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing).round();
let per_page = self.lines_per_page(focused);
let alpha_of = |line: &Line| -> f32 {
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
let alpha_of = |line: &Line| {
if focused {
return 1.0;
1.0
} else {
time_alpha(ticks, line.added)
}
let t =
((1.0 - (ticks.saturating_sub(line.added)) as f64 / 200.0) * 10.0).clamp(0.0, 1.0);
(t * t) as f32
};
let visible = self.lines.len().saturating_sub(self.scroll).min(per_page);
let mut count = 0;
// Pass 1: backgrounds; pass 2: text (same order as vanilla).
let s = |v: f32| v * scale;
for i in (0..visible).rev() {
let line = &self.lines[i + self.scroll];
let alpha = alpha_of(line);
if alpha <= 1.0e-5 {
continue;
}
count += 1;
let bottom = chat_bottom - i as f32 * entry_height;
let top = bottom - entry_height;
batch.fill(
atlas,
0.0,
s(top),
s(max_width + 8.0),
s(entry_height),
rgba(0, 0, 0, (alpha * o.background_opacity * 255.0) as u8),
);
let visible = total
.saturating_sub(self.scroll)
.min(self.lines_per_page(focused));
let lines: Vec<(usize, &Line, f32)> = (0..visible)
.rev()
.map(|i| (i, &self.lines[i + self.scroll]))
.map(|(i, line)| (i, line, alpha_of(line)))
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
.collect();
let mut out = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
for &(i, _, alpha) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
out.push(hud_layout::fill(
-4,
bottom - entry_height,
max_width + 4 + 4,
bottom,
hud_layout::as8(alpha * background_opacity) << 24,
));
}
for i in (0..visible).rev() {
let line = &self.lines[i + self.scroll];
let alpha = alpha_of(line);
if alpha <= 1.0e-5 {
continue;
for &(i, line, alpha) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
let opacity = alpha * text_opacity;
let color = hud_layout::white(opacity);
if color >> 24 != 0 {
out.push(GuiDraw::Text {
text: line.content.clone(),
x: 0,
y: bottom - bottom_to_text,
color,
shadow: true,
});
}
let bottom = chat_bottom - i as f32 * entry_height;
let top = bottom - entry_height;
let text_top = bottom - bottom_to_text;
let a = (alpha * text_opacity * 255.0) as u8;
if let Some(c) = line.indicator {
batch.fill(
atlas,
0.0,
s(top),
s(2.0),
s(entry_height),
rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, a),
);
}
if a > 3 {
batch.text_styled(
atlas,
&line.content,
s(4.0),
s(text_top),
rgba(255, 255, 255, a),
true,
);
if let Some(indicator) = line.indicator {
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
bottom - entry_height,
-2,
bottom,
color,
));
}
}
// Scroll bar while focused and scrolled content exists.
if focused && count > 0 {
let total = self.lines.len() as f32;
let chat_height = count as f32 * entry_height;
let virtual_height = total * entry_height;
if focused {
let count = lines.len() as i32;
let chat_height = count * entry_height;
let virtual_height = total as i32 * entry_height;
let y = self.scroll as i32 * chat_height / total as i32 - chat_bottom;
let height = chat_height * chat_height / virtual_height;
if virtual_height != chat_height {
let y = self.scroll as f32 * chat_height / total - chat_bottom;
let height = chat_height * chat_height / virtual_height;
let alpha = if y > 0.0 { 170 } else { 96 };
let c = if self.new_since_scroll {
let alpha: u32 = if y > 0 { 170 } else { 96 };
let color = if self.new_since_scroll {
0xCC3333
} else {
0x3333AA
};
let x = max_width + 4.0 + 4.0;
batch.fill(
atlas,
s(x),
s(-y - height),
s(2.0),
s(height),
rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, alpha),
);
batch.fill(
atlas,
s(x + 1.0),
s(-y - height),
s(1.0),
s(height),
rgba(0xCC, 0xCC, 0xCC, alpha),
);
let x = max_width + 4;
out.push(hud_layout::fill(
x,
-y,
x + 2,
-y - height,
alpha << 24 | color,
));
out.push(hud_layout::fill(
x + 2,
-y,
x + 1,
-y - height,
alpha << 24 | 0xCCCCCC,
));
}
}
out.push(GuiDraw::Pop);
out
}
/// Draws [`Chat::layout`] into the HUD batch.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
}
/// Plain text of the newest messages (for tests and the control socket).
pub fn recent_lines(&self, n: usize) -> Vec<String> {
+105
View File
@@ -78,6 +78,23 @@ impl SceneCollisions<'_, '_> {
)?)
}
}
/// `noCollision(null, box)`: block collisions under the empty collision
/// context, as entity-less queries such as spawn checks use.
pub fn no_collision_empty<W, E>(world: &mut W, bounds: Aabb) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
{
no_world_collision(
world,
CollisionCheckRequest {
entity: EntityIdentity(0),
context: CollisionContext::empty(false),
bounds,
candidate_budget: 1_000_000,
},
)
.unwrap_or(false)
}
impl BlockCollisionWorld for SceneCollisions<'_, '_> {
type Error = io::Error;
type Chunk = ChunkPos;
@@ -166,3 +183,91 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
Ok(false)
}
}
/// Collision queries over any block lookup (test worlds without chunks):
/// every chunk is present, nothing is suffocating, no entities collide.
pub struct LookupCollisions<'w, 'r> {
pub block: &'w dyn Fn(i32, i32, i32) -> horizon_world::registry::BlockStateId,
pub binding: &'w BlockCollisionRegistry<'r>,
}
impl BlockCollisionWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
type Chunk = ChunkPos;
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(Some(p))
}
fn collision_block(&mut self, _: ChunkPos, p: BlockPos) -> io::Result<CollisionBlock> {
self.binding.collision_block((self.block)(p.x, p.y, p.z))
}
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
Ok(false)
}
fn context_shape(
&mut self,
block: CollisionBlock,
p: BlockPos,
context: &CollisionContext<'_>,
) -> io::Result<VoxelShape> {
self.binding
.collision_shape_with_context(self, block.state, p, context)
.map_err(io::Error::other)
}
}
impl EntityMovementWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
Ok(Vec::new())
}
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
Ok(None)
}
}
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
}
fn moving_piston_shape(&mut self, _: [i32; 3]) -> io::Result<Option<VoxelShape>> {
Ok(None)
}
}
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
fn shulker_collision_state(
&mut self,
_: [i32; 3],
) -> io::Result<Option<ShulkerCollisionState>> {
Ok(None)
}
}
impl LiquidCollisionWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
fn entity_liquid_shape(&mut self, _: EntityIdentity) -> io::Result<VoxelShape> {
Ok(VoxelShape::empty())
}
fn fluid(&mut self, p: BlockPos) -> io::Result<FluidState> {
FluidState::from_block(
self.binding
.registry()
.state((self.block)(p.x, p.y, p.z))
.ok_or_else(|| io::Error::other("fluid state outside registry"))?,
)
}
fn entity_can_stand(&mut self, _: EntityIdentity, _: FluidState) -> io::Result<bool> {
Ok(false)
}
}
impl PowderSnowWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
fn powder_snow_entity(&mut self, _: EntityIdentity) -> io::Result<PowderSnowEntity> {
Ok(PowderSnowEntity {
fall_distance: 0.0,
falling_block: false,
})
}
fn powder_snow_walkable_tag(&mut self, _: EntityIdentity) -> io::Result<bool> {
Ok(false)
}
fn feet_are_leather_boots(&mut self, _: EntityIdentity) -> io::Result<bool> {
Ok(false)
}
}
+218 -32
View File
@@ -83,6 +83,73 @@ pub struct RegistryIds {
}
const EMPTY: &[String] = &[];
impl Registries {
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
/// mob effects, data component types and dialogs, block entities and tag
/// members. Registries that data packs reload stay with the game.
pub fn fill_from_export(&mut self, r: &serde_json::Value) {
fn strings(v: &serde_json::Value) -> Vec<String> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
let fill = |field: &mut Vec<String>, key: &str| {
if field.is_empty() {
*field = strings(&r[key]);
}
};
fill(&mut self.effects, "effects");
fill(&mut self.enchantments, "enchantments");
fill(&mut self.attributes, "attributes");
fill(&mut self.damage_types, "damage_types");
fill(&mut self.templates, "templates");
fill(&mut self.persistent_components, "persistent_components");
fill(&mut self.learnable_recipes, "learnable_recipes");
const RELOADED: [&str; 5] = [
"minecraft:advancement",
"minecraft:recipe",
"minecraft:loot_table",
"minecraft:predicate",
"minecraft:item_modifier",
];
for (name, entry) in r["by_registry"].as_object().into_iter().flatten() {
if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) {
continue;
}
self.by_registry.insert(
name.clone(),
RegistryIds {
ids: strings(&entry["ids"]),
tags: strings(&entry["tags"]),
},
);
}
if self.block_entities.is_empty() {
self.block_entities = strings(&r["block_entities"]).into_iter().collect();
}
if self.block_tag_members.is_empty() {
for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() {
self.block_tag_members.insert(tag.clone(), strings(members));
}
}
if self.clock_time_markers.is_empty() {
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
self.clock_time_markers
.insert(clock.clone(), strings(markers));
}
}
if self.advancement_criteria.is_empty() {
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
self.advancement_criteria
.insert(id.clone(), strings(criteria));
}
}
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
@@ -254,6 +321,47 @@ pub enum ArgType {
SlotSource,
/// ResourceOrIdArgument over a registry: an id or an inline SNBT value.
ResourceOrId(&'static str),
/// ResourceSelectorArgument: a `*`/`?` pattern over a registry's ids.
ResourceSelector(&'static str),
}
/// FilenameUtils.wildcardMatch (case-sensitive) of `*` and `?` patterns.
pub fn wildcard_match(text: &str, pattern: &str) -> bool {
let t: Vec<char> = text.chars().collect();
let p: Vec<char> = pattern.chars().collect();
let (mut ti, mut pi) = (0, 0);
let (mut star, mut mark) = (None, 0);
while ti < t.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
ti += 1;
pi += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
mark = ti;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
mark += 1;
ti = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
/// ResourceSelectorArgument: the registry ids a pattern selects.
pub fn select_resources<'a>(ids: &'a [String], pattern: &str) -> Vec<&'a String> {
let pattern = if pattern.contains(':') {
pattern.to_owned()
} else {
format!("minecraft:{pattern}")
};
ids.iter()
.filter(|id| wildcard_match(id, &pattern))
.collect()
}
/// SlotRanges.SLOTS: every slot range name and its size, in vanilla order.
@@ -540,9 +648,9 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
&& ns
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
let path_ok = path
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/'));
let path_ok = path.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
pub fn normalize(id: &str) -> String {
@@ -690,7 +798,11 @@ pub fn read_double_bounds(reader: &mut Reader) -> CmdResult<DoubleRange> {
}
/// MinMaxBounds.FloatDegrees.fromReader (no swap check).
pub fn read_float_bounds(reader: &mut Reader) -> CmdResult<(Option<f32>, Option<f32>)> {
read_bounds(reader, |s| java_parse_double(s).map(|v| v as f32), "parsing.float.invalid")
read_bounds(
reader,
|s| java_parse_double(s).map(|v| v as f32),
"parsing.float.invalid",
)
}
/// Integer.parseInt over number characters.
pub fn java_parse_int(s: &str) -> Option<i32> {
@@ -886,7 +998,12 @@ fn java_split(s: &str) -> Vec<&str> {
}
/// SharedSuggestionProvider.suggestCoordinates / suggest2DCoordinates: the
/// candidates `ty` parses completely.
fn suggest_coordinates(builder: &mut SuggestionsBuilder, reg: &Registries, ty: &ArgType, relevant: bool) {
fn suggest_coordinates(
builder: &mut SuggestionsBuilder,
reg: &Registries,
ty: &ArgType,
relevant: bool,
) {
let remaining = builder.remaining().to_owned();
let three = matches!(ty, ArgType::BlockPos | ArgType::Vec3 { .. });
let coords: [String; 3] = if remaining.starts_with('^') {
@@ -903,7 +1020,11 @@ fn suggest_coordinates(builder: &mut SuggestionsBuilder, reg: &Registries, ty: &
let mut result: Vec<String> = Vec::new();
let [x, y, z] = &coords;
if remaining.is_empty() {
let full = if three { format!("{x} {y} {z}") } else { format!("{x} {z}") };
let full = if three {
format!("{x} {y} {z}")
} else {
format!("{x} {z}")
};
if valid(&full) {
result.push(x.clone());
if three {
@@ -965,7 +1086,11 @@ fn parse_nbt_path(reader: &mut Reader) -> CmdResult<()> {
// StringReader.peek past the end (StringIndexOutOfBoundsException).
let len = reader.string.encode_utf16().count();
let message = format!("Index {len} out of bounds for length {len}");
return Err(CommandError::tr_at("command.exception", &[&message], reader));
return Err(CommandError::tr_at(
"command.exception",
&[&message],
reader,
));
}
match reader.peek() {
'{' => {
@@ -988,7 +1113,10 @@ fn parse_nbt_path(reader: &mut Reader) -> CmdResult<()> {
_ => {
let s = reader.cursor;
while reader.can_read()
&& !matches!(reader.peek(), ' ' | '"' | '\'' | '[' | ']' | '.' | '{' | '}')
&& !matches!(
reader.peek(),
' ' | '"' | '\'' | '[' | ']' | '.' | '{' | '}'
)
{
reader.skip();
}
@@ -1049,10 +1177,9 @@ fn particle_options(id: &str, t: &horizon_world::nbt::Tag) -> Result<(), String>
{
// withAlternative(BlockState.CODEC, the block id codec).
let first = match state {
horizon_world::nbt::Tag::Compound(_) => super::codec::either(
"Not a string".into(),
no_key("id", state),
),
horizon_world::nbt::Tag::Compound(_) => {
super::codec::either("Not a string".into(), no_key("id", state))
}
_ => not_map(state),
};
let _ = snbt;
@@ -1121,7 +1248,10 @@ impl ArgType {
reader.cursor = start;
return Err(CommandError::tr_at(
"argument.double.low",
&[&crate::snbt::java_double(*min), &crate::snbt::java_double(v)],
&[
&crate::snbt::java_double(*min),
&crate::snbt::java_double(v),
],
reader,
));
}
@@ -1129,7 +1259,10 @@ impl ArgType {
reader.cursor = start;
return Err(CommandError::tr_at(
"argument.double.big",
&[&crate::snbt::java_double(*max), &crate::snbt::java_double(v)],
&[
&crate::snbt::java_double(*max),
&crate::snbt::java_double(v),
],
reader,
));
}
@@ -1146,10 +1279,7 @@ impl ArgType {
// MessageArgument.Message.parseText.
let len = reader.remaining().encode_utf16().count();
if len > 256 {
return Err(CommandError::tr(
"argument.message.too_long",
&[&len, &256],
));
return Err(CommandError::tr("argument.message.too_long", &[&len, &256]));
}
let s = reader.remaining().to_owned();
if reg.permission >= 2 {
@@ -1326,10 +1456,37 @@ impl ArgType {
}
Arg::Str(name.to_owned())
}
ArgType::ResourceSelector(registry) => {
let start = reader.cursor;
while reader.can_read() && {
let c = reader.peek();
is_id_char(c) || c == '*' || c == '?'
} {
reader.skip();
}
let raw = reader.string[start..reader.cursor].to_owned();
let pattern = if raw.contains(':') {
raw
} else {
format!("minecraft:{raw}")
};
if select_resources(reg.ids(registry), &pattern).is_empty() {
return Err(CommandError::tr_at(
"argument.resource_selector.not_found",
&[&pattern, registry],
reader,
));
}
Arg::Str(pattern)
}
ArgType::HexColor => {
let text = reader.read_unquoted_string();
if !matches!(text.len(), 3 | 6) {
return Err(CommandError::tr_at("argument.hexcolor.invalid", &[&text], reader));
return Err(CommandError::tr_at(
"argument.hexcolor.invalid",
&[&text],
reader,
));
}
// Integer.parseInt(s, begin, end, 16) per channel: a bad digit
// escapes Brigadier as a dispatcher parse exception.
@@ -1341,7 +1498,11 @@ impl ArgType {
if let Some(i) = digits.find(|ch: char| !ch.is_ascii_hexdigit()) {
let index = i + usize::from(signed);
let message = format!("Error at index {index} in: \"{c}\"");
return Err(CommandError::tr_at("command.exception", &[&message], reader));
return Err(CommandError::tr_at(
"command.exception",
&[&message],
reader,
));
}
}
let v = i64::from_str_radix(text, 16).unwrap_or(0);
@@ -1361,7 +1522,10 @@ impl ArgType {
.take_while(|c| c.is_ascii_hexdigit() || *c == '-')
.map(char::len_utf8)
.sum();
match (run > 0).then(|| super::selector::java_uuid(&rest[..run])).flatten() {
match (run > 0)
.then(|| super::selector::java_uuid(&rest[..run]))
.flatten()
{
Some(_) => {
let text = rest[..run].to_owned();
reader.cursor += run;
@@ -1391,7 +1555,11 @@ impl ArgType {
let v = reader.read_float()?;
let unit = reader.read_unquoted_string();
let Some(m) = time_unit(unit) else {
return Err(CommandError::tr_at("argument.time.invalid_unit", &[], reader));
return Err(CommandError::tr_at(
"argument.time.invalid_unit",
&[],
reader,
));
};
// Math.round(float): floor(x + 0.5).
let ticks = (v * m as f32 + 0.5).floor() as i32;
@@ -1425,7 +1593,10 @@ impl ArgType {
// ParserBasedArgument.withCodec: SNBT, then the codec (errors at the start).
let tag = super::snbt::parse_tag_argument(reader)?;
let (decoded, key) = if matches!(self, ArgType::Style) {
(super::component::decode_style(&tag), "argument.style.invalid")
(
super::component::decode_style(&tag),
"argument.style.invalid",
)
} else {
(super::component::decode(&tag), "argument.component.invalid")
};
@@ -1481,15 +1652,26 @@ impl ArgType {
reader.skip();
}
let id = &reader.string[start..reader.cursor];
if !crate::scoreboard::criteria_exists(id, reg) {
reader.cursor = start;
return Err(CommandError::tr_at(
"argument.criteria.invalid",
&[&id],
reader,
));
// ObjectiveCriteria.byName: statistics resolve to their canonical name.
match crate::scoreboard::criteria_by_name(id, reg) {
Ok(Some(canonical)) => Arg::Str(canonical),
Ok(None) => {
reader.cursor = start;
return Err(CommandError::tr_at(
"argument.criteria.invalid",
&[&id],
reader,
));
}
Err(_) => {
reader.cursor = start;
return Err(CommandError::tr_at(
"argument.criteria.invalid",
&[&id],
reader,
));
}
}
Arg::Str(id.to_owned())
}
ArgType::ScoreboardSlot => {
let name = reader.read_unquoted_string();
@@ -1539,7 +1721,8 @@ impl ArgType {
if block {
parse_coord_int(reader)
} else {
parse_coord_double(reader, false)
// Vec2Argument.vec2(): integers are centred on the block.
parse_coord_double(reader, true)
}
};
let x = one(reader)?;
@@ -1701,6 +1884,9 @@ impl ArgType {
}
ArgType::StrEnum(values, _) => builder.suggest_matching(values.iter().copied()),
ArgType::HexColor => builder.suggest_matching(["F00", "FF0000"]),
ArgType::ResourceSelector(registry) => {
suggest_resources(builder, reg.ids(registry), "")
}
ArgType::GameProfile => {
let players = reg.players.clone();
super::selector::suggest(builder, reg, &|b| {
+3 -6
View File
@@ -379,7 +379,8 @@ impl<'r, 'a> BlockParser<'r, 'a> {
}
let prefix = b.remaining_lower();
for (name, _) in definition {
if !self.properties.iter().any(|(n, _)| n == name) && name.starts_with(&prefix) {
if !self.properties.iter().any(|(n, _)| n == name) && name.starts_with(&prefix)
{
b.suggest(format!("{name}="));
}
}
@@ -439,11 +440,7 @@ impl<'r, 'a> BlockParser<'r, 'a> {
}
/// BlockStateParser.parseForBlock / parseForTesting (the reader is rewound on errors).
pub fn parse(
reader: &mut Reader,
reg: &Registries,
for_testing: bool,
) -> CmdResult<BlockInput> {
pub fn parse(reader: &mut Reader, reg: &Registries, for_testing: bool) -> CmdResult<BlockInput> {
let cursor = reader.cursor;
let r = BlockParser::new(reader, reg, for_testing, true).parse();
if r.is_err() {
+32 -12
View File
@@ -77,19 +77,28 @@ fn require(map: &Compound, t: &Tag, keys: &[&str]) -> Result<(), String> {
Err(missing.join("; "))
}
}
/// The required fields of each context number provider type, in the
/// order DataFixerUpper reports them missing (the reverse of the record's).
fn provider_fields(id: &str, map: &Compound, t: &Tag) -> Result<(), String> {
match id {
"minecraft:uniform" => require(map, t, &["max", "min"]),
"minecraft:constant" => require(map, t, &["value"]),
"minecraft:binomial" => require(map, t, &["n", "p"]),
match id.trim_start_matches("minecraft:") {
"uniform" => require(map, t, &["max", "min"]),
"constant" => require(map, t, &["value"]),
"binomial" => require(map, t, &["p", "n"]),
"add" | "mul" | "avg" | "max" | "min" | "length" => require(map, t, &["inputs"]),
"sub" | "div" | "mod" | "floor_div" | "floor_mod" => require(map, t, &["right", "left"]),
"pow" => require(map, t, &["exponent", "base"]),
"abs" | "negate" | "from_float" | "from_int" | "floor" | "ceil" | "round" | "sqrt"
| "sin" | "cos" | "truncate" => require(map, t, &["input"]),
"conditional" => require(map, t, &["on_true", "condition"]),
"number_dispatcher" => require(map, t, &["cases"]),
"weighted_list" => require(map, t, &["distribution"]),
"enchantment_level" => require(map, t, &["amount"]),
"score" => require(map, t, &["score", "target"]),
_ => Ok(()),
}
}
/// A holder list codec: a single element, a `#tag`, or a list.
fn element_or_list(
t: &Tag,
element: &dyn Fn(&Tag) -> Result<(), String>,
) -> Result<(), String> {
fn element_or_list(t: &Tag, element: &dyn Fn(&Tag) -> Result<(), String>) -> Result<(), String> {
let first = match element(t) {
Ok(()) => return Ok(()),
Err(e) => e,
@@ -133,7 +142,19 @@ pub fn decode(registry: &str, t: &Tag, reg: &Registries) -> Result<(), String> {
"minecraft:slot_source" => element_or_list(t, &|x| {
dispatch(x, reg, "minecraft:slot_source_type", &|_, _, _| Ok(()))
}),
"minecraft:dialog" => dispatch(t, reg, "minecraft:dialog_type", &|_, _, _| Ok(())),
// Dialog types: CommonDialogData's title, then each type's own.
"minecraft:dialog" => dispatch(t, reg, "minecraft:dialog_type", &|id, map, t| {
let own: &[&str] = match id {
"minecraft:confirmation" => &["yes", "no"],
"minecraft:multi_action" => &["actions"],
"minecraft:dialog_list" => &["dialogs"],
_ => &[],
};
let keys: Vec<&str> = std::iter::once("title")
.chain(own.iter().copied())
.collect();
require(map, t, &keys)
}),
"minecraft:worldgen/feature" | "minecraft:loot_table" => match t {
Tag::Compound(_) => Ok(()),
_ => Err(not_map(t)),
@@ -179,7 +200,6 @@ pub fn resource_or_id(reader: &mut Reader, reg: &Registries, registry: &str) ->
}
reader.cursor = start;
let tag = super::snbt::parse_tag_argument(reader)?;
decode(registry, &tag, reg).map_err(|m| {
CommandError::tr_at("argument.resource_or_id.failed_to_parse", &[&m], reader)
})
decode(registry, &tag, reg)
.map_err(|m| CommandError::tr_at("argument.resource_or_id.failed_to_parse", &[&m], reader))
}
+23 -13
View File
@@ -47,7 +47,11 @@ fn is_string(t: &Tag) -> bool {
matches!(t, Tag::String(_))
}
fn string(t: &Tag) -> R {
if is_string(t) { Ok(()) } else { err("Not a string") }
if is_string(t) {
Ok(())
} else {
err("Not a string")
}
}
fn string_of(t: &Tag) -> Option<String> {
match t {
@@ -131,9 +135,9 @@ pub fn identifier(t: &Tag) -> Result<(), String> {
"Not a valid resource location: {s} Non [a-z0-9_.-] character in namespace of location: {ns}:{path}"
));
}
let path_ok = path
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/'));
let path_ok = path.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
});
if !path_ok {
return Err(format!(
"Not a valid resource location: {s} Non [a-z0-9/._-] character in path of location: {ns}:{path}"
@@ -208,7 +212,13 @@ fn style_fields(map: &Compound) -> R {
if let Some(c) = get(map, "color") {
color(c)?;
}
for key in ["bold", "italic", "underlined", "strikethrough", "obfuscated"] {
for key in [
"bold",
"italic",
"underlined",
"strikethrough",
"obfuscated",
] {
if let Some(v) = get(map, key)
&& bool_value(v).is_err()
{
@@ -262,12 +272,13 @@ fn content(kind: &str, map: &Compound) -> bool {
_ => false,
},
"selector" => {
get(map, "selector").is_some_and(selector_string) && optional_component(map, "separator")
get(map, "selector").is_some_and(selector_string)
&& optional_component(map, "separator")
}
"nbt" => {
let path_ok = get(map, "nbt").and_then(string_of).is_some_and(|p| {
crate::nbt_path::NbtPath::parse(&p).is_ok()
});
let path_ok = get(map, "nbt")
.and_then(string_of)
.is_some_and(|p| crate::nbt_path::NbtPath::parse(&p).is_ok());
let source = if let Some(e) = get(map, "entity") {
selector_string(e)
} else if let Some(b) = get(map, "block") {
@@ -336,10 +347,9 @@ fn full(t: &Tag) -> R {
style_fields(map)
}
fn component(t: &Tag) -> R {
either(
either(string(t), || non_empty_list(t, &component)),
|| full(t),
)
either(either(string(t), || non_empty_list(t, &component)), || {
full(t)
})
}
/// ComponentSerialization.CODEC.parse: the error message on failure.
pub fn decode(t: &Tag) -> Result<(), String> {
+53 -15
View File
@@ -1,7 +1,9 @@
//! ItemParser (item stacks with components, `stone[damage=5]`) and
//! ItemPredicateArgument (ComponentPredicateParser's packrat grammar:
//! `#logs[count~1..|!damage]`), with vanilla's errors and suggestions.
use super::args::{ItemInput, Registries, is_id_char, parse_identifier, read_identifier, suggest_resources};
use super::args::{
ItemInput, Registries, is_id_char, parse_identifier, read_identifier, suggest_resources,
};
use super::codec::{either, is_number, not_map, snbt};
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, opt, seq};
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
@@ -159,7 +161,10 @@ impl ItemState<'_, '_> {
self.sugg = ISugg::Component;
let ty = self.component_type()?;
if visited.contains(&ty) {
return Err(CommandError::tr("arguments.item.component.repeated", &[&ty]));
return Err(CommandError::tr(
"arguments.item.component.repeated",
&[&ty],
));
}
visited.push(ty);
self.sugg = ISugg::Nothing;
@@ -167,7 +172,10 @@ impl ItemState<'_, '_> {
} else {
let ty = self.component_type()?;
if visited.contains(&ty) {
return Err(CommandError::tr("arguments.item.component.repeated", &[&ty]));
return Err(CommandError::tr(
"arguments.item.component.repeated",
&[&ty],
));
}
visited.push(ty.clone());
self.sugg = ISugg::Assignment;
@@ -401,10 +409,12 @@ fn predicate_grammar() -> Grammar<IV> {
"type",
alt(vec![element, seq(vec![ch('#'), Term::Cut, tag]), any]),
|s| {
Some(IV::Type(match s.scope.get_any(&["element_type", "tag_type"]) {
Some(IV::Elem(t)) => Some(t),
_ => None,
}))
Some(IV::Type(
match s.scope.get_any(&["element_type", "tag_type"]) {
Some(IV::Elem(t)) => Some(t),
_ => None,
},
))
},
);
g.put_term("any_type", ch('*'), |_| Some(IV::Unit));
@@ -415,7 +425,11 @@ fn predicate_grammar() -> Grammar<IV> {
if r.ids("minecraft:item").iter().any(|i| i == id) {
Ok(IV::Elem(TypeRes::Item(id.to_owned())))
} else {
Err(CommandError::tr_at("argument.item.id.invalid", &[&id], reader))
Err(CommandError::tr_at(
"argument.item.id.invalid",
&[&id],
reader,
))
}
})
});
@@ -426,7 +440,11 @@ fn predicate_grammar() -> Grammar<IV> {
if r.tags("minecraft:item").iter().any(|i| i == id) {
Ok(IV::Elem(TypeRes::Tag(id.to_owned())))
} else {
Err(CommandError::tr_at("arguments.item.tag.unknown", &[&id], reader))
Err(CommandError::tr_at(
"arguments.item.tag.unknown",
&[&id],
reader,
))
}
})
});
@@ -509,29 +527,49 @@ fn predicate_grammar() -> Grammar<IV> {
if id == "minecraft:count" {
return Ok(IV::Comp(id.to_owned()));
}
let known = r.ids("minecraft:data_component_type").iter().any(|c| c == id);
let known = r
.ids("minecraft:data_component_type")
.iter()
.any(|c| c == id);
if known && r.persistent_components.iter().any(|c| c == id) {
Ok(IV::Comp(id.to_owned()))
} else {
Err(CommandError::tr_at("arguments.item.component.unknown", &[&id], reader))
Err(CommandError::tr_at(
"arguments.item.component.unknown",
&[&id],
reader,
))
}
})
});
g.put_custom("predicate_type", move |s| {
let r = reg(s);
let mut list = vec!["minecraft:count".to_owned()];
list.extend(r.ids("minecraft:data_component_predicate_type").iter().cloned());
list.extend(
r.ids("minecraft:data_component_predicate_type")
.iter()
.cloned(),
);
lookup(s, id, Rc::new(list), &|id, reader| {
if id == "minecraft:count"
|| r.ids("minecraft:data_component_predicate_type").iter().any(|c| c == id)
|| r.ids("minecraft:data_component_predicate_type")
.iter()
.any(|c| c == id)
{
return Ok(IV::Pred(id.to_owned()));
}
if r.ids("minecraft:data_component_type").iter().any(|c| c == id) {
if r.ids("minecraft:data_component_type")
.iter()
.any(|c| c == id)
{
// A component existence predicate (decodes Unit).
return Ok(IV::Pred(format!("exists:{id}")));
}
Err(CommandError::tr_at("arguments.item.predicate.unknown", &[&id], reader))
Err(CommandError::tr_at(
"arguments.item.predicate.unknown",
&[&id],
reader,
))
})
});
g.put_custom("tag", |s| super::snbt::rule_in(s, IV::Tag));
+8 -3
View File
@@ -18,6 +18,7 @@ pub mod tree_misc;
pub mod tree_scoreboard;
pub mod tree_world;
pub mod vanilla;
mod vanilla_order;
use crate::text::Text;
pub use args::{Arg, ArgType, Registries};
@@ -616,7 +617,8 @@ pub fn merge_suggestions(input: &str, list: Vec<Suggestion>) -> (Range, Vec<Sugg
// A stable insertion sort (the comparator mixes numeric and text order).
let mut sorted: Vec<Suggestion> = Vec::with_capacity(out.len());
for s in out {
let pos = sorted.partition_point(|x| compare_suggestions(x, &s) != std::cmp::Ordering::Greater);
let pos =
sorted.partition_point(|x| compare_suggestions(x, &s) != std::cmp::Ordering::Greater);
sorted.insert(pos, s);
}
((start, end), sorted)
@@ -644,7 +646,8 @@ impl Suggests {
for id in &reg.summonable {
if args::resource_matches(&contents, id) {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
let name = crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', ".")));
let name =
crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', ".")));
builder.suggest_tooltip(id.clone(), name);
}
}
@@ -673,7 +676,9 @@ impl Suggests {
.trigger_scores
.iter()
.filter(|(_, scores)| {
targets.iter().any(|t| scores.get(t).is_none_or(|locked| *locked))
targets
.iter()
.any(|t| scores.get(t).is_none_or(|locked| *locked))
})
.map(|(o, _)| o.as_str())
.collect();
+6 -6
View File
@@ -94,13 +94,11 @@ impl<V: Clone> Scope<V> {
let current = self.top_marker;
let n = self.top_entry - self.top_marker;
self.setup_new_frame();
let mut source = current + 1;
let mut target = self.top_entry;
for _ in 0..n {
for source in current + 1..current + 1 + n {
target += 1;
let key = self.stack[source].0;
self.set(target, key, None);
source += 1;
}
self.top_entry = target;
}
@@ -139,11 +137,13 @@ impl<V: Clone> Scope<V> {
}
}
pub fn get(&self, atom: Atom) -> Option<V> {
self.value_index(&[atom]).and_then(|i| self.stack[i].1.clone())
self.value_index(&[atom])
.and_then(|i| self.stack[i].1.clone())
}
/// Scope.getAny: the newest of several atoms.
pub fn get_any(&self, atoms: &[Atom]) -> Option<V> {
self.value_index(atoms).and_then(|i| self.stack[i].1.clone())
self.value_index(atoms)
.and_then(|i| self.stack[i].1.clone())
}
/// Whether the atom has an entry (even a null one).
pub fn has(&self, atom: Atom) -> bool {
@@ -385,7 +385,7 @@ impl<'g, V: Value> State<'g, V> {
r
}
fn first_error(&self) -> CommandError {
for (_, reason) in &self.errors {
if let Some((_, reason)) = self.errors.first() {
return match reason {
Reason::Delayed(key, args) => {
let args: Vec<&dyn std::fmt::Display> =
@@ -143,7 +143,12 @@ pub fn java_uuid(name: &str) -> Option<u128> {
Some((u128::from(msb) << 64) | u128::from(lsb))
}
fn rollback(reader: &mut Reader, start: usize, key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
fn rollback(
reader: &mut Reader,
start: usize,
key: &str,
args: &[&dyn std::fmt::Display],
) -> CommandError {
reader.cursor = start;
CommandError::tr_at(key, args, reader)
}
+57 -15
View File
@@ -153,7 +153,9 @@ fn parse_small(s: &str, radix: u32, lo: i32, hi: i32) -> Result<i32, String> {
}
fn parse_unsigned_int(s: &str, radix: u32) -> Result<i32, String> {
if s.starts_with('-') {
return Err(format!("Illegal leading minus sign on unsigned string {s}."));
return Err(format!(
"Illegal leading minus sign on unsigned string {s}."
));
}
let len = s.chars().count();
if len <= 5 || (radix == 10 && len <= 9) {
@@ -168,7 +170,9 @@ fn parse_unsigned_int(s: &str, radix: u32) -> Result<i32, String> {
}
fn parse_unsigned_long(s: &str, radix: u32) -> Result<i64, String> {
if s.starts_with('-') {
return Err(format!("Illegal leading minus sign on unsigned string {s}."));
return Err(format!(
"Illegal leading minus sign on unsigned string {s}."
));
}
let digits = s.strip_prefix('+').unwrap_or(s);
match u64::from_str_radix(digits, radix) {
@@ -338,7 +342,11 @@ pub fn grammar() -> Grammar<SV> {
|s| s.scope.get("integer_suffix"),
);
g.put_custom("binary_numeral", |s| {
number_run(s, |c| matches!(c, '0' | '1' | '_'), "snbt.parser.expected_binary_numeral")
number_run(
s,
|c| matches!(c, '0' | '1' | '_'),
"snbt.parser.expected_binary_numeral",
)
});
g.put_custom("decimal_numeral", |s| {
number_run(
@@ -394,7 +402,10 @@ pub fn grammar() -> Grammar<SV> {
} else if let Some(h) = get_str(s, "hex_numeral") {
(Base::Hex, h)
} else {
(Base::Binary, get_str(s, "binary_numeral").unwrap_or_default())
(
Base::Binary,
get_str(s, "binary_numeral").unwrap_or_default(),
)
};
Some(SV::Int(IntLit {
minus,
@@ -408,8 +419,14 @@ pub fn grammar() -> Grammar<SV> {
g.put_term(
"float_type_suffix",
alt(vec![
seq(vec![chs('f', 'F'), Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Float))]),
seq(vec![chs('d', 'D'), Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Double))]),
seq(vec![
chs('f', 'F'),
Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Float)),
]),
seq(vec![
chs('d', 'D'),
Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Double)),
]),
]),
|s| s.scope.get("float_type_suffix"),
);
@@ -420,7 +437,10 @@ pub fn grammar() -> Grammar<SV> {
seq(vec![chs('e', 'E'), opt(sign), decimal]),
|s| {
let minus = matches!(s.scope.get("sign"), Some(SV::Sign(true)));
Some(SV::Exp(minus, get_str(s, "decimal_numeral").unwrap_or_default()))
Some(SV::Exp(
minus,
get_str(s, "decimal_numeral").unwrap_or_default(),
))
},
);
let sign = g.named("sign");
@@ -470,7 +490,11 @@ pub fn grammar() -> Grammar<SV> {
float_tag(&text, ty, s).map(SV::Tag)
},
);
for (name, n) in [("string_hex_2", 2), ("string_hex_4", 4), ("string_hex_8", 8)] {
for (name, n) in [
("string_hex_2", 2),
("string_hex_4", 4),
("string_hex_8", 8),
] {
g.put_custom(name, move |s| {
greedy(
s,
@@ -521,7 +545,9 @@ pub fn grammar() -> Grammar<SV> {
if let Some(SV::Str(plain)) = s.scope.get_any(&["string_escape_sequence"]) {
return Some(SV::Str(plain));
}
if let Some(SV::Str(h)) = s.scope.get_any(&["string_hex_2", "string_hex_4", "string_hex_8"])
if let Some(SV::Str(h)) =
s.scope
.get_any(&["string_hex_2", "string_hex_4", "string_hex_8"])
{
let cp = u32::from_str_radix(&h, 16).unwrap_or(u32::MAX);
if cp > 0x10ffff {
@@ -531,7 +557,9 @@ pub fn grammar() -> Grammar<SV> {
));
return None;
}
return Some(SV::Str(char::from_u32(cp).unwrap_or('\u{fffd}').to_string()));
return Some(SV::Str(
char::from_u32(cp).unwrap_or('\u{fffd}').to_string(),
));
}
// Character.codePointOf: Unicode names are not tabulated here.
s.store_at_mark(delayed("snbt.parser.invalid_character_name"));
@@ -663,7 +691,8 @@ pub fn grammar() -> Grammar<SV> {
let qs = g.named("quoted_string_literal");
let uq = g.named("unquoted_string");
g.put_term("map_key", alt(vec![qs, uq]), |s| {
s.scope.get_any(&["quoted_string_literal", "unquoted_string"])
s.scope
.get_any(&["quoted_string_literal", "unquoted_string"])
});
let key = g.named("map_key");
let lit = g.named("literal");
@@ -712,9 +741,18 @@ pub fn grammar() -> Grammar<SV> {
g.put_term(
"array_prefix",
alt(vec![
seq(vec![ch('B'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Byte))]),
seq(vec![ch('L'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Long))]),
seq(vec![ch('I'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Int))]),
seq(vec![
ch('B'),
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Byte)),
]),
seq(vec![
ch('L'),
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Long)),
]),
seq(vec![
ch('I'),
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Int)),
]),
]),
|s| s.scope.get("array_prefix"),
);
@@ -890,7 +928,11 @@ thread_local! {
pub fn parse_tag_argument(reader: &mut Reader) -> CmdResult<Tag> {
GRAMMAR.with(|g| match g.parse_for_commands(reader)? {
SV::Tag(t) => Ok(t),
_ => Err(CommandError::tr_at("argument.nbt.expected.compound", &[], reader)),
_ => Err(CommandError::tr_at(
"argument.nbt.expected.compound",
&[],
reader,
)),
})
}
/// TagParser.parseCompoundAsArgument.
+24 -5
View File
@@ -37,9 +37,18 @@ fn reg() -> Registries {
},
);
};
registry("minecraft:worldgen/biome", &["minecraft:desert", "minecraft:plains"]);
registry("minecraft:damage_type", &["minecraft:fall", "minecraft:magic"]);
registry("minecraft:attribute", &["minecraft:max_health", "minecraft:scale"]);
registry(
"minecraft:worldgen/biome",
&["minecraft:desert", "minecraft:plains"],
);
registry(
"minecraft:damage_type",
&["minecraft:fall", "minecraft:magic"],
);
registry(
"minecraft:attribute",
&["minecraft:max_health", "minecraft:scale"],
);
registry("minecraft:item_modifier", &["a:b"]);
registry("minecraft:predicate", &["a:b"]);
registry(
@@ -54,7 +63,14 @@ fn reg() -> Registries {
"minecraft:entity_type",
&["minecraft:pig", "minecraft:player", "minecraft:zombie"],
);
registry("minecraft:item", &["minecraft:diamond", "minecraft:diamond_sword", "minecraft:stone"]);
registry(
"minecraft:item",
&[
"minecraft:diamond",
"minecraft:diamond_sword",
"minecraft:stone",
],
);
r
}
fn run(input: &str) -> CmdResult<(String, Vec<String>)> {
@@ -436,7 +452,10 @@ fn world_command_trees() {
"particle minecraft:dust{color:[1.0,0.0,0.0],scale:2.0} ~ ~ ~ 0.1 0.1 0.1 0 10 force @a",
"particle",
),
("particle minecraft:block{block_state:\"minecraft:stone\"} ~ ~ ~", "particle"),
(
"particle minecraft:block{block_state:\"minecraft:stone\"} ~ ~ ~",
"particle",
),
("attribute @s minecraft:max_health get 2", "attribute.get"),
(
"attribute @s minecraft:max_health base set 40",
+10 -2
View File
@@ -52,7 +52,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
d.requires(compute, GAMEMASTERS);
context_sources(d, compute, &mut |d, node| {
let f = d.literal(node, "float");
let p = d.argument(f, "provider", ArgType::ResourceOrId("minecraft:context_float_provider"));
let p = d.argument(
f,
"provider",
ArgType::ResourceOrId("minecraft:context_float_provider"),
);
d.executes(p, "compute");
let scale = d.argument(
p,
@@ -64,7 +68,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
);
d.executes(scale, "compute");
let i = d.literal(node, "integer");
let p = d.argument(i, "provider", ArgType::ResourceOrId("minecraft:context_int_provider"));
let p = d.argument(
i,
"provider",
ArgType::ResourceOrId("minecraft:context_int_provider"),
);
d.executes(p, "compute");
});
@@ -48,7 +48,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
let t = d.argument(as_, "targets", ENTITIES);
d.redirect(t, ex);
let over = d.literal(positioned, "over");
let hm = d.argument(over, "heightmap", ArgType::StrEnum(HEIGHTMAPS, "argument.enum.invalid"));
let hm = d.argument(
over,
"heightmap",
ArgType::StrEnum(HEIGHTMAPS, "argument.enum.invalid"),
);
d.redirect(hm, ex);
let rotated = d.literal(ex, "rotated");
let rot = d.argument(rotated, "rot", ArgType::Rotation);
@@ -179,7 +183,11 @@ fn conditionals(d: &mut Dispatcher, m: usize, ex: usize) {
conditional(d, targets, ex);
let predicate = d.literal(m, "predicate");
let p = d.argument(predicate, "predicate", ArgType::ResourceOrId("minecraft:predicate"));
let p = d.argument(
predicate,
"predicate",
ArgType::ResourceOrId("minecraft:predicate"),
);
conditional(d, p, ex);
let stopwatch = d.literal(m, "stopwatch");
@@ -1,7 +1,6 @@
//! Commands Horizon parses but does not run (multiplayer, profiling and
//! server-only features): `/dialog`, `/kick`, `/posteffect`, `/fetchprofile`,
//! `/spectate`, `/publish`, `/unpublish`, `/waypoint`, `/debug`, `/jfr` and
//! the `/test` root.
//! Commands of the hosted game rather than the world: `/dialog`, `/kick`,
//! `/posteffect`, `/fetchprofile`, `/spectate`, `/publish`, `/unpublish`,
//! `/waypoint`, `/debug`, `/jfr` and `/test`.
use super::{
Dispatcher,
args::ArgType,
@@ -126,7 +125,84 @@ pub fn register(d: &mut Dispatcher, root: usize) {
d.executes(l, "jfr");
}
// TestCommand (game tests; only its root is modelled)
// TestCommand (release build: no export subcommands)
let test = d.literal(root, "test");
d.requires(test, GAMEMASTERS);
let tests = ArgType::ResourceSelector("minecraft:test_instance");
let int0 = ArgType::Integer {
min: 0,
max: i32::MAX,
};
let int = ArgType::Integer {
min: i32::MIN,
max: i32::MAX,
};
// numberOfTimes [untilFailed [rotationSteps [testsPerRow]]], or only
// the first two.
let retry = |d: &mut Dispatcher, node: usize, id: &'static str, build_info: bool| {
let n = d.argument(node, "numberOfTimes", int0.clone());
d.executes(n, id);
let u = d.argument(n, "untilFailed", ArgType::Bool);
d.executes(u, id);
if build_info {
let r = d.argument(u, "rotationSteps", int.clone());
d.executes(r, id);
let t = d.argument(r, "testsPerRow", int.clone());
d.executes(t, id);
}
};
let run = d.literal(test, "run");
let t = d.argument(run, "tests", tests.clone());
d.executes(t, "test.run");
retry(d, t, "test.run", true);
let multiple = d.literal(test, "runmultiple");
let t = d.argument(multiple, "tests", tests.clone());
d.executes(t, "test.run");
let a = d.argument(t, "amount", int.clone());
d.executes(a, "test.run");
for name in ["runthese", "runclosest", "runthat"] {
let l = d.literal(test, name);
d.executes(l, "test.run.found");
retry(d, l, "test.run.found", false);
}
let failed = d.literal(test, "runfailed");
d.executes(failed, "test.run.found");
let only = d.argument(failed, "onlyRequiredTests", ArgType::Bool);
d.executes(only, "test.run.found");
retry(d, only, "test.run.found", true);
retry(d, failed, "test.run.found", true);
let verify = d.literal(test, "verify");
let t = d.argument(verify, "tests", tests.clone());
d.executes(t, "test.verify");
let locate = d.literal(test, "locate");
let t = d.argument(locate, "tests", tests);
d.executes(t, "test.locate");
for name in [
"resetclosest",
"resetthese",
"resetthat",
"clearthat",
"clearthese",
] {
let l = d.literal(test, name);
d.executes(l, "test.clear");
}
let all = d.literal(test, "clearall");
d.executes(all, "test.clear");
let r = d.argument(all, "radius", int.clone());
d.executes(r, "test.clear");
let stop = d.literal(test, "stop");
d.executes(stop, "test.stop");
let pos = d.literal(test, "pos");
d.executes(pos, "test.pos");
let var = d.argument(pos, "var", ArgType::Word);
d.executes(var, "test.pos");
let create = d.literal(test, "create");
let id = d.argument(create, "id", ArgType::Id("none"));
d.executes(id, "test.create");
let w = d.argument(id, "width", int.clone());
d.executes(w, "test.create");
let h = d.argument(w, "height", int.clone());
let depth = d.argument(h, "depth", int);
d.executes(depth, "test.create");
}
@@ -169,7 +169,11 @@ fn item(d: &mut Dispatcher, root: usize) {
d.argument(p, "targetPos", ArgType::BlockPos)
};
let slot = d.argument(target, "slot", ArgType::SlotSource);
let modifier = d.argument(slot, "modifier", ArgType::ResourceOrId("minecraft:item_modifier"));
let modifier = d.argument(
slot,
"modifier",
ArgType::ResourceOrId("minecraft:item_modifier"),
);
d.executes(modifier, "item.modify");
}
for mode in ["replace", "fill", "override"] {
@@ -56,19 +56,31 @@ pub fn register(d: &mut Dispatcher, root: usize) {
let place = d.literal(root, "place");
d.requires(place, GAMEMASTERS);
let feature = d.literal(place, "feature");
let f = d.argument(feature, "feature", ArgType::ResourceOrId("minecraft:worldgen/feature"));
let f = d.argument(
feature,
"feature",
ArgType::ResourceOrId("minecraft:worldgen/feature"),
);
d.executes(f, "place.feature");
let pos = d.argument(f, "pos", ArgType::BlockPos);
d.executes(pos, "place.feature");
let jigsaw = d.literal(place, "jigsaw");
let pool = d.argument(jigsaw, "pool", ArgType::ResKey("minecraft:worldgen/template_pool"));
let pool = d.argument(
jigsaw,
"pool",
ArgType::ResKey("minecraft:worldgen/template_pool"),
);
let target = d.argument(pool, "target", ArgType::Id("none"));
let depth = d.argument(target, "max_depth", ArgType::Integer { min: 1, max: 20 });
d.executes(depth, "place.jigsaw");
let position = d.argument(depth, "position", ArgType::BlockPos);
d.executes(position, "place.jigsaw");
let structure = d.literal(place, "structure");
let st = d.argument(structure, "structure", ArgType::ResKey("minecraft:worldgen/structure"));
let st = d.argument(
structure,
"structure",
ArgType::ResKey("minecraft:worldgen/structure"),
);
d.executes(st, "place.structure");
let pos = d.argument(st, "pos", ArgType::BlockPos);
d.executes(pos, "place.structure");
@@ -89,7 +101,10 @@ pub fn register(d: &mut Dispatcher, root: usize) {
let mirror = d.argument(
rotation,
"mirror",
ArgType::StrEnum(&["none", "left_right", "front_back"], "argument.enum.invalid"),
ArgType::StrEnum(
&["none", "left_right", "front_back"],
"argument.enum.invalid",
),
);
d.executes(mirror, "place");
let integrity = d.argument(mirror, "integrity", ArgType::Float { min: 0.0, max: 1.0 });
@@ -160,7 +175,14 @@ fn rotate_swing(d: &mut Dispatcher, root: usize) {
for hand in ["mainhand", "offhand"] {
let h = d.literal(targets, hand);
d.executes(h, "swing");
let animation = d.argument(h, "animation", ArgType::StrEnum(&["none", "whack", "stab"], "argument.swing_animation.invalid"));
let animation = d.argument(
h,
"animation",
ArgType::StrEnum(
&["none", "whack", "stab"],
"argument.swing_animation.invalid",
),
);
d.executes(animation, "swing");
let duration = d.argument(animation, "duration", ArgType::Time { min: 1 });
d.executes(duration, "swing");
@@ -285,7 +307,11 @@ fn fillbiome(d: &mut Dispatcher, root: usize) {
let biome = d.argument(to, "biome", ArgType::Res("minecraft:worldgen/biome"));
d.executes(biome, "fillbiome");
let replace = d.literal(biome, "replace");
let filter = d.argument(replace, "filter", ArgType::ResOrTag("minecraft:worldgen/biome"));
let filter = d.argument(
replace,
"filter",
ArgType::ResOrTag("minecraft:worldgen/biome"),
);
d.executes(filter, "fillbiome");
}
@@ -357,7 +383,11 @@ fn loot(d: &mut Dispatcher, root: usize) {
// Sources.
for t in targets {
let fish = d.literal(t, "fish");
let table = d.argument(fish, "loot_table", ArgType::ResourceOrId("minecraft:loot_table"));
let table = d.argument(
fish,
"loot_table",
ArgType::ResourceOrId("minecraft:loot_table"),
);
let pos = d.argument(table, "pos", ArgType::BlockPos);
d.executes(pos, "loot.fish");
for hand in ["mainhand", "offhand"] {
@@ -367,7 +397,11 @@ fn loot(d: &mut Dispatcher, root: usize) {
let tool = d.argument(pos, "tool", ArgType::Item);
d.executes(tool, "loot.fish");
let l = d.literal(t, "loot");
let lt = d.argument(l, "loot_table", ArgType::ResourceOrId("minecraft:loot_table"));
let lt = d.argument(
l,
"loot_table",
ArgType::ResourceOrId("minecraft:loot_table"),
);
d.executes(lt, "loot.loot");
let kill = d.literal(t, "kill");
let target = d.argument(kill, "target", ENTITY);
+47 -2
View File
@@ -322,13 +322,21 @@ pub fn build_with(dev: bool) -> Dispatcher {
let locate = d.literal(root, "locate");
d.requires(locate, GAMEMASTERS);
let s = d.literal(locate, "structure");
let a = d.argument(s, "structure", ArgType::ResOrTagKey("minecraft:worldgen/structure"));
let a = d.argument(
s,
"structure",
ArgType::ResOrTagKey("minecraft:worldgen/structure"),
);
d.executes(a, "locate.structure");
let b = d.literal(locate, "biome");
let a = d.argument(b, "biome", ArgType::ResOrTag("minecraft:worldgen/biome"));
d.executes(a, "locate.biome");
let p = d.literal(locate, "poi");
let a = d.argument(p, "poi", ArgType::ResOrTag("minecraft:point_of_interest_type"));
let a = d.argument(
p,
"poi",
ArgType::ResOrTag("minecraft:point_of_interest_type"),
);
d.executes(a, "locate.poi");
if dev {
@@ -395,6 +403,7 @@ pub fn build_with(dev: bool) -> Dispatcher {
super::tree_misc::register(&mut d, root);
super::tree_world::register(&mut d, root);
super::tree_extra::register(&mut d, root);
apply_vanilla_order(&mut d);
d
}
@@ -498,6 +507,42 @@ pub fn game_modes() -> [&'static str; 4] {
GameMode::ALL.map(GameMode::name)
}
/// The shared vanilla tree.
/// Reorder node children as vanilla registers them (Brigadier keeps
/// children in registration order; smart usage lists them that way).
fn apply_vanilla_order(d: &mut Dispatcher) {
for (path, order) in super::vanilla_order::ORDER {
let mut node = Some(0usize);
for name in path.split(' ').filter(|n| !n.is_empty()) {
node = node.and_then(|n| {
d.nodes[n]
.children
.iter()
.copied()
.find(|&c| d.nodes[c].name() == name)
});
}
let Some(n) = node else { continue };
let mut children = d.nodes[n].children.clone();
let rank = |c: usize| {
order
.iter()
.position(|o| *o == d.nodes[c].name())
.unwrap_or(order.len())
};
children.sort_by_key(|&c| rank(c));
d.nodes[n].children = children;
}
}
/// The tree the game runs: a release integrated server's, or with
/// `HORIZON_DEV_COMMANDS` set the development one
/// (SharedConstants.DEBUG_DEV_COMMANDS).
pub fn game_dispatcher() -> &'static Dispatcher {
if std::env::var_os("HORIZON_DEV_COMMANDS").is_some() {
dispatcher()
} else {
release_dispatcher()
}
}
pub fn dispatcher() -> &'static Dispatcher {
static TREE: std::sync::OnceLock<Dispatcher> = std::sync::OnceLock::new();
TREE.get_or_init(build)
@@ -0,0 +1,234 @@
//! The order of node children in vanilla's command tree, for the nodes
//! where Horizon's tree registers them differently (path of node names
//! from the root, children in vanilla's order). Generated by
//! tools/generate_command_order.py; do not edit by hand.
pub const ORDER: &[(&str, &[&str])] = &[
(
"",
&[
"advancement",
"attribute",
"execute",
"bossbar",
"clear",
"clone",
"compute",
"damage",
"data",
"datapack",
"debug",
"defaultgamemode",
"dialog",
"difficulty",
"effect",
"me",
"enchant",
"experience",
"xp",
"fill",
"fillbiome",
"forceload",
"function",
"gamemode",
"gamerule",
"give",
"help",
"item",
"kick",
"kill",
"list",
"locate",
"loot",
"msg",
"tell",
"w",
"swing",
"particle",
"place",
"playsound",
"posteffect",
"random",
"reload",
"recipe",
"fetchprofile",
"return",
"ride",
"rotate",
"say",
"schedule",
"scoreboard",
"seed",
"version",
"setblock",
"spawnpoint",
"setworldspawn",
"spectate",
"spreadplayers",
"stopsound",
"stopwatch",
"summon",
"tag",
"team",
"teammsg",
"tm",
"teleport",
"tp",
"tellraw",
"test",
"tick",
"time",
"title",
"trigger",
"waypoint",
"weather",
"worldborder",
"jfr",
"publish",
"unpublish",
],
),
(
"execute",
&[
"run",
"if",
"unless",
"as",
"at",
"store",
"positioned",
"rotated",
"facing",
"align",
"anchored",
"in",
"summon",
"on",
],
),
(
"execute if",
&[
"block",
"biome",
"loaded",
"dimension",
"score",
"blocks",
"entity",
"predicate",
"function",
"stopwatch",
"data",
"items",
"slots",
],
),
(
"execute unless",
&[
"block",
"biome",
"loaded",
"dimension",
"score",
"blocks",
"entity",
"predicate",
"function",
"stopwatch",
"data",
"items",
"slots",
],
),
("execute facing", &["entity", "pos"]),
("datapack", &["enable", "disable", "list", "create"]),
(
"datapack enable name",
&["after", "before", "last", "first"],
),
("item", &["replace", "fill", "override", "modify"]),
("item replace entity target slots", &["from", "with"]),
("item fill entity target slots", &["from", "with"]),
("item override entity target slots", &["from", "with"]),
(
"loot replace entity entities slot",
&["fish", "loot", "kill", "mine", "count"],
),
(
"loot replace entity entities slot fish loot_table pos",
&["tool", "mainhand", "offhand"],
),
(
"loot replace entity entities slot mine pos",
&["tool", "mainhand", "offhand"],
),
(
"loot replace entity entities slot count fish loot_table pos",
&["tool", "mainhand", "offhand"],
),
(
"loot replace entity entities slot count mine pos",
&["tool", "mainhand", "offhand"],
),
(
"loot replace block targetPos slot",
&["fish", "loot", "kill", "mine", "count"],
),
(
"loot replace block targetPos slot fish loot_table pos",
&["tool", "mainhand", "offhand"],
),
(
"loot replace block targetPos slot mine pos",
&["tool", "mainhand", "offhand"],
),
(
"loot replace block targetPos slot count fish loot_table pos",
&["tool", "mainhand", "offhand"],
),
(
"loot replace block targetPos slot count mine pos",
&["tool", "mainhand", "offhand"],
),
(
"loot insert targetPos fish loot_table pos",
&["tool", "mainhand", "offhand"],
),
(
"loot insert targetPos mine pos",
&["tool", "mainhand", "offhand"],
),
(
"loot give players fish loot_table pos",
&["tool", "mainhand", "offhand"],
),
(
"loot give players mine pos",
&["tool", "mainhand", "offhand"],
),
(
"loot spawn targetPos fish loot_table pos",
&["tool", "mainhand", "offhand"],
),
(
"loot spawn targetPos mine pos",
&["tool", "mainhand", "offhand"],
),
(
"scoreboard players",
&[
"list",
"set",
"get",
"add",
"remove",
"reset",
"enable",
"display",
"operation",
],
),
];
+47 -23
View File
@@ -152,9 +152,34 @@ impl Parser<'_> {
Err("unterminated string".into())
}
}
/// The style fields of a component object.
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
let mut style = Style::default();
if let Some(c) = o.get("color").and_then(Value::as_str) {
style.color = named_color(c).or_else(|| {
c.strip_prefix('#')
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
}
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
style.bold = flag("bold");
style.italic = flag("italic");
style.underlined = flag("underlined");
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
style
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
/// are resolved by `resolve` (ComponentUtils.updateForEntity).
pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
to_text_with(v, resolve, &mut |_| None)
}
/// `to_text` with a hook resolving other contents (`nbt`) from the object.
pub fn to_text_with(
v: &Value,
resolve: &mut dyn FnMut(&str) -> Text,
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
) -> Text {
match v {
Value::String(s) => Text::literal(s.clone()),
Value::Number(n) => Text::literal(n.to_string()),
@@ -164,32 +189,27 @@ pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
let Some((first, rest)) = list.split_first() else {
return Text::empty();
};
let mut base = to_text(first, resolve);
let style = base
.spans
.first()
.map(|s| s.style.clone())
.unwrap_or_default();
let mut base = to_text_with(first, resolve, contents);
// The siblings take the first component's own style, even when
// its text is empty.
let style = match first {
Value::Object(o) => object_style(o),
_ => base
.spans
.first()
.map(|s| s.style.clone())
.unwrap_or_default(),
};
for sibling in rest {
base.push(to_text(sibling, resolve).with_style(&style));
base.push(to_text_with(sibling, resolve, contents).with_style(&style));
}
base
}
Value::Object(o) => {
let mut style = Style::default();
if let Some(c) = o.get("color").and_then(Value::as_str) {
style.color = named_color(c).or_else(|| {
c.strip_prefix('#')
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
}
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
style.bold = flag("bold");
style.italic = flag("italic");
style.underlined = flag("underlined");
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
let content = if let Some(t) = o.get("text") {
let style = object_style(o);
let content = if let Some(t) = contents(o) {
t
} else if let Some(t) = o.get("text") {
match t {
Value::String(s) => Text::literal(s.clone()),
other => Text::literal(other.to_string()),
@@ -198,7 +218,11 @@ pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
let args: Vec<Text> = o
.get("with")
.and_then(Value::as_array)
.map(|a| a.iter().map(|x| to_text(x, resolve)).collect())
.map(|a| {
a.iter()
.map(|x| to_text_with(x, resolve, contents))
.collect()
})
.unwrap_or_default();
let translated = Text::translate(key, &args);
match o.get("fallback").and_then(Value::as_str) {
@@ -219,7 +243,7 @@ pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
.into_iter()
.flatten()
{
out.push(to_text(extra, resolve).with_style(&style));
out.push(to_text_with(extra, resolve, contents).with_style(&style));
}
out
}
+13 -3
View File
@@ -75,6 +75,9 @@ pub struct Crafter {
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
impl Crafter {
/// `slotCanBeDisabled`: only empty slots.
@@ -94,7 +97,7 @@ impl Crafter {
.count() as i32
}
/// `canPlaceItem`: a hopper fills the smallest stacks first.
pub fn can_place(&self, slot: usize, stack: &ItemStack, max_stack: u32) -> bool {
pub fn can_place(&self, slot: usize, _stack: &ItemStack, max_stack: u32) -> bool {
if slot >= 9 || self.disabled[slot] {
return false;
}
@@ -109,7 +112,7 @@ impl Crafter {
&& match &self.items[i] {
None => true,
Some(other) => {
other.count < current.count && other.same_item_same_components(stack)
other.count < current.count && other.same_item_same_components(current)
}
}
})
@@ -184,7 +187,12 @@ impl BlockEntity {
return Some(Self::Sculk(Box::new(sculk)));
}
if name == "minecraft:crafter" {
return Some(Self::Crafter(Crafter::default()));
// CrafterBlock.newBlockEntity copies the placed state's flag: the
// first tick sees it as a change from untriggered.
return Some(Self::Crafter(Crafter {
state_triggered: Some(false),
..Crafter::default()
}));
}
if name.ends_with("_banner") {
return Some(Self::Banner(Banner::default()));
@@ -200,6 +208,7 @@ impl BlockEntity {
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
"minecraft:hopper" => 5,
"minecraft:dispenser" | "minecraft:dropper" => 9,
n if n.ends_with("shulker_box") => 27,
@@ -211,6 +220,7 @@ impl BlockEntity {
pub fn id_for_block(name: &str) -> Option<&'static str> {
Some(match name {
"minecraft:chest" => "minecraft:chest",
n if n.ends_with("copper_chest") => "minecraft:chest",
"minecraft:trapped_chest" => "minecraft:trapped_chest",
"minecraft:barrel" => "minecraft:barrel",
"minecraft:hopper" => "minecraft:hopper",
+50 -1
View File
@@ -5,12 +5,61 @@
//! that depend on a later frame (screenshots, benchmarks) arrive when ready.
use std::{
io::{BufRead, BufReader, Write},
os::unix::net::{UnixListener, UnixStream},
path::{Path, PathBuf},
sync::mpsc,
thread,
};
#[cfg(unix)]
pub use std::os::unix::net::{UnixListener, UnixStream};
#[cfg(not(unix))]
pub use unsupported::{UnixListener, UnixStream};
/// Windows' standard library has no Unix domain sockets: there the control
/// socket reports itself unavailable and the game runs without it.
#[cfg(not(unix))]
mod unsupported {
use std::{io, path::Path};
fn unavailable() -> io::Error {
io::Error::new(
io::ErrorKind::Unsupported,
"the control socket needs Unix domain sockets (Linux or macOS)",
)
}
pub struct UnixStream;
impl UnixStream {
pub fn connect<P: AsRef<Path>>(_: P) -> io::Result<Self> {
Err(unavailable())
}
pub fn try_clone(&self) -> io::Result<Self> {
Err(unavailable())
}
}
impl io::Read for UnixStream {
fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
Err(unavailable())
}
}
impl io::Write for UnixStream {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(unavailable())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
pub struct UnixListener;
impl UnixListener {
pub fn bind<P: AsRef<Path>>(_: P) -> io::Result<Self> {
Err(unavailable())
}
pub fn incoming(&self) -> std::iter::Empty<io::Result<UnixStream>> {
std::iter::empty()
}
}
}
/// One command line from a client, with the channel for its JSON reply.
pub struct Request {
pub line: String,
+156 -56
View File
@@ -24,17 +24,26 @@ const FIREWORK_COLORS: [u32; 16] = [
11_250_603, 2_651_799, 8_073_150, 2_437_522, 5_320_730, 3_887_386, 11_743_532, 1_973_019,
];
/// A set of accepted items (expanded from an item, list or tag).
/// A set of accepted items (expanded from an item, list or tag), and the
/// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`).
#[derive(Clone, Debug)]
pub struct Ingredient(pub HashSet<String>);
pub struct Ingredient(pub HashSet<String>, pub Vec<String>);
impl Ingredient {
/// The items of a list, in order.
pub fn of(items: Vec<String>) -> Self {
Self(items.iter().cloned().collect(), items)
}
fn parse(v: &Value, items: &Items) -> Option<Self> {
let mut set = HashSet::new();
let mut list: Vec<String> = Vec::new();
let mut add = |s: &str| {
if let Some(tag) = s.strip_prefix('#') {
set.extend(items.item_tags.get(tag).into_iter().flatten().cloned());
} else {
set.insert(s.to_owned());
let more: Vec<String> = match s.strip_prefix('#') {
Some(tag) => items.item_tag_order.get(tag).cloned().unwrap_or_default(),
None => vec![s.to_owned()],
};
for i in more {
if !list.contains(&i) {
list.push(i);
}
}
};
match v {
@@ -50,12 +59,45 @@ impl Ingredient {
}
_ => return None,
}
(!set.is_empty()).then_some(Self(set))
(!list.is_empty()).then(|| Self::of(list))
}
pub fn test(&self, stack: &ItemStack) -> bool {
self.0.contains(&*stack.item)
}
}
/// `PlacementInfo`: the ingredients to find and, per recipe slot, the index
/// of its ingredient (`slotsToIngredientIndex`, -1 for an empty slot). Empty
/// when the recipe is impossible to place.
#[derive(Clone, Debug, Default)]
pub struct Placement {
pub ingredients: Vec<Ingredient>,
pub slots: Vec<i32>,
/// A `ShapedRecipe`'s width and height (the others fill the grid).
pub shaped: Option<(usize, usize)>,
}
impl Placement {
/// `PlacementInfo.createFromOptionals` (and `create` with no empty slots).
pub fn of(cells: Vec<Option<Ingredient>>, shaped: Option<(usize, usize)>) -> Self {
let mut out = Self {
shaped,
..Self::default()
};
for cell in cells {
match cell {
Some(i) if i.0.is_empty() => return Self::default(),
Some(i) => {
out.slots.push(out.ingredients.len() as i32);
out.ingredients.push(i);
}
None => out.slots.push(-1),
}
}
out
}
pub fn impossible(&self) -> bool {
self.slots.is_empty()
}
}
/// A recipe result: `ItemStackTemplate` (`id`, `count`, `components`), with the
/// id optional for the transmuting recipes that keep the input's item.
#[derive(Clone, Debug)]
@@ -1319,56 +1361,114 @@ impl Recipes {
map_scale,
};
for (name, recipe) in &self.crafting {
let result = match recipe {
Recipe::Special(special) => {
if !special.matches(&ctx, &input) {
continue;
}
special.assemble(&ctx, &input)
}
Recipe::Shaped {
width,
height,
cells,
result,
} => {
if *width != input.width
|| *height != input.height
|| cells.iter().flatten().count() != input.count()
{
continue;
}
let matches = |mirror: bool| {
(0..*height).all(|y| {
(0..*width).all(|x| {
let rx = if mirror { width - 1 - x } else { x };
match (&cells[y * width + rx], input.get(x, y)) {
(None, None) => true,
(Some(i), Some(s)) => i.test(s),
_ => false,
}
})
})
};
if !(matches(false) || matches(true)) {
continue;
}
Some(result.create())
}
Recipe::Shapeless {
ingredients,
result,
} => {
if !matches_all(ingredients, &input.stacks()) {
continue;
}
Some(result.create())
}
};
return result.map(|r| (name.as_str(), r));
if let Some(result) = Self::assemble_if_matches(&ctx, &input, recipe) {
return result.map(|r| (name.as_str(), r));
}
}
None
}
/// `Recipe.matches` then `assemble` for one recipe: None when it does
/// not match, Some(None) when it matches with nothing to make.
fn assemble_if_matches(
ctx: &Context,
input: &Input,
recipe: &Recipe,
) -> Option<Option<ItemStack>> {
match recipe {
Recipe::Special(special) => special
.matches(ctx, input)
.then(|| special.assemble(ctx, input)),
Recipe::Shaped {
width,
height,
cells,
result,
} => {
if *width != input.width
|| *height != input.height
|| cells.iter().flatten().count() != input.count()
{
return None;
}
let matches = |mirror: bool| {
(0..*height).all(|y| {
(0..*width).all(|x| {
let rx = if mirror { width - 1 - x } else { x };
match (&cells[y * width + rx], input.get(x, y)) {
(None, None) => true,
(Some(i), Some(s)) => i.test(s),
_ => false,
}
})
})
};
(matches(false) || matches(true)).then(|| Some(result.create()))
}
Recipe::Shapeless {
ingredients,
result,
} => matches_all(ingredients, &input.stacks()).then(|| Some(result.create())),
}
}
/// `recipe.matches(craftSlots.asCraftInput())` for one recipe by id
/// (`minecraft:name`).
pub fn recipe_matches(&self, id: &str, grid: &[Option<ItemStack>], size: usize) -> bool {
let name = id.trim_start_matches("minecraft:");
let Some((_, recipe)) = self.crafting.iter().find(|(n, _)| n == name) else {
return false;
};
let Some(input) = Input::of(grid, size) else {
return false;
};
let ctx = Context {
recipes: self,
map_scale: &|_| None,
};
Self::assemble_if_matches(&ctx, &input, recipe).is_some()
}
/// `Recipe.placementInfo` of a crafting or cooking recipe by id; None for
/// an unknown id, an empty placement when it is impossible to place.
pub fn placement(&self, id: &str) -> Option<Placement> {
let name = id.trim_start_matches("minecraft:");
if let Some(c) = self.cooking.iter().find(|c| c.id == name) {
return Some(Placement::of(vec![Some(c.ingredient.clone())], None));
}
let (_, recipe) = self.crafting.iter().find(|(n, _)| n == name)?;
Some(match recipe {
Recipe::Shaped {
width,
height,
cells,
..
} => Placement::of(cells.clone(), Some((*width, *height))),
Recipe::Shapeless { ingredients, .. } => {
Placement::of(ingredients.iter().cloned().map(Some).collect(), None)
}
Recipe::Special(Special::Transmute {
input,
material,
max,
..
}) => {
let mut list = vec![Some(input.clone())];
list.extend(std::iter::repeat_n(Some(material.clone()), *max));
Placement::of(list, None)
}
Recipe::Special(Special::Dye { target, dye, .. }) => {
Placement::of(vec![Some(target.clone()), Some(dye.clone())], None)
}
Recipe::Special(Special::Imbue {
source, material, ..
}) => Placement::of(
(0..9)
.map(|i| Some(if i == 4 { source } else { material }.clone()))
.collect(),
None,
),
// CustomRecipe.placementInfo: NOT_PLACEABLE.
Recipe::Special(_) => Placement::default(),
})
}
/// `Recipe.getRemainingItems` placed on the `size`x`size` grid: an item's
/// crafting remainder (buckets, bottles), the patterned banner a duplicate
/// keeps and the written book a clone keeps.
@@ -1434,7 +1534,7 @@ mod tests {
use crate::banner::BannerLayer;
fn ingredient(items: &[&str]) -> Ingredient {
Ingredient(items.iter().map(|s| (*s).to_owned()).collect())
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
}
fn patterned(item: &str) -> ItemStack {
let mut s = ItemStack::new(item, 1);
+9
View File
@@ -67,6 +67,15 @@ fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
}
}
}
impl DataPack {
/// The built-in `vanilla` pack: the game's own data directory.
pub fn builtin(root: &Path) -> Self {
DataPack {
id: "vanilla".into(),
source: Source::Dir(root.to_path_buf()),
}
}
}
/// The packs in `<world>/datapacks`, sorted by name.
pub fn discover(world: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else {
+114
View File
@@ -135,6 +135,16 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
_ => None,
}
}
/// What an omen's `applyEffectTick` decided.
#[derive(Clone, Debug, PartialEq)]
pub enum OmenTick {
/// It returned true: the effect counts down as usual.
Continue,
/// It returned false: the effect goes.
Remove,
/// It added this effect, then returned false.
Add(EffectInstance),
}
#[derive(Clone, Debug, Default)]
pub struct PlayerEffects {
pub list: Vec<EffectInstance>,
@@ -160,6 +170,71 @@ impl PlayerEffects {
pub fn clear(&mut self) {
self.list.clear();
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick). Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
/// `ConcurrentModificationException`, which `tickEffects` swallows. Bad
/// Omen stays (not counted down) and the effects after it are not
/// ticked; the next tick refreshes Raid Omen and drops Bad Omen.
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
let mut actions = Vec::new();
let mut added = false;
let mut i = 0;
while i < self.list.len() {
let e = &self.list[i];
if !(e.infinite() || e.duration > 0) {
self.list.remove(i);
continue;
}
let t = if e.infinite() {
self.tick_count
} else {
e.duration
};
if let Some(a) = periodic(e.id, t, e.amplifier) {
actions.push(a);
}
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
_ => OmenTick::Continue,
};
match answer {
OmenTick::Continue => {}
OmenTick::Remove => {
self.list.remove(i);
continue;
}
OmenTick::Add(effect) => {
if let Some(existing) = self.list.iter_mut().find(|x| x.id == effect.id) {
existing.update(&effect);
self.list.remove(i);
continue;
}
self.list.push(effect);
added = true;
break;
}
}
let e = &mut self.list[i];
e.tick_down();
e.downgrade();
if e.infinite() || e.duration > 0 {
i += 1;
} else {
self.list.remove(i);
}
}
(actions, added)
}
/// `LivingEntity.tickEffects` (server part): actions to apply, in order.
pub fn tick(&mut self) -> Vec<EffectAction> {
self.tick_count = self.tick_count.wrapping_add(1);
@@ -325,6 +400,45 @@ impl StatusFlags {
mod tests {
use super::*;
#[test]
fn bad_omen_in_a_village_becomes_raid_omen_over_two_ticks() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("bad_omen", 100, 1));
fx.add(EffectInstance::new("speed", 50, 0));
let mut convert = |e: &EffectInstance| match e.id {
"bad_omen" => OmenTick::Add(EffectInstance::new("raid_omen", 600, e.amplifier)),
_ => OmenTick::Continue,
};
// The first tick adds Raid Omen and stops there (the iterator's
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
let (_, added) = fx.tick_with(&mut convert);
assert!(added);
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
assert_eq!(fx.get("speed").unwrap().duration, 50);
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
// The second refreshes Raid Omen and drops Bad Omen.
let (_, added) = fx.tick_with(&mut convert);
assert!(!added);
assert!(fx.get("bad_omen").is_none());
assert_eq!(fx.get("speed").unwrap().duration, 49);
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
}
#[test]
fn raid_omen_acts_on_its_last_tick() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("raid_omen", 2, 0));
let mut seen = Vec::new();
let mut watch = |e: &EffectInstance| {
seen.push(e.duration);
OmenTick::Remove
};
fx.tick_with(&mut watch);
assert!(fx.get("raid_omen").is_some());
fx.tick_with(&mut watch);
assert!(fx.get("raid_omen").is_none());
assert_eq!(seen, vec![1]);
}
#[test]
fn update_keeps_stronger_and_hides_longer() {
let mut fx = PlayerEffects::default();
+52 -1
View File
@@ -11,6 +11,10 @@ struct Globals {
sun: vec4<f32>,
// x: sky fog end, y: cloud fog end
fog_ends: vec4<f32>,
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
end_portal_sprites: array<vec4<f32>, 2>,
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
time: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -27,11 +31,14 @@ struct Out {
@location(4) @interpolate(flat) overlay: u32,
// Overlay 6: the sprite's v origin and height, for v repeated inside it.
@location(6) @interpolate(flat) wrap: vec2<f32>,
// Overlays 9 and 10: projection_from_position(gl_Position), the End portal's texProj0.
@location(7) proj: vec4<f32>,
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>) -> Out {
var out: Out;
out.clip = globals.view_proj * vec4(position, 1.0);
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
out.uv = uv;
let l = vec2(f32(light.y), f32(light.x));
let lm = textureSampleLevel(lightmap, lightmap_sampler,
@@ -52,6 +59,41 @@ struct Out {
if overlay == 4u { out.spherical = length(position); }
return out;
}
// rendertype_end_portal.fsh: the layer colours.
const PORTAL_COLORS = array<vec3<f32>, 16>(
vec3(0.022087, 0.098399, 0.110818),
vec3(0.011892, 0.095924, 0.089485),
vec3(0.027636, 0.101689, 0.100326),
vec3(0.046564, 0.109883, 0.114838),
vec3(0.064901, 0.117696, 0.097189),
vec3(0.063761, 0.086895, 0.123646),
vec3(0.084817, 0.111994, 0.166380),
vec3(0.097489, 0.154120, 0.091064),
vec3(0.106152, 0.131144, 0.195191),
vec3(0.097721, 0.110188, 0.187229),
vec3(0.133516, 0.138278, 0.148582),
vec3(0.070006, 0.243332, 0.235792),
vec3(0.196766, 0.142899, 0.214696),
vec3(0.047281, 0.315338, 0.321970),
vec3(0.204675, 0.390010, 0.302066),
vec3(0.080955, 0.314821, 0.661491),
);
// textureProj on a repeating texture that lives in the atlas at `rect`.
fn portal_sample(rect: vec4<f32>, p: vec4<f32>) -> vec3<f32> {
let st = fract(p.xy / p.w);
return textureSampleLevel(atlas, atlas_sampler, rect.xy + st * rect.zw, 0.0).rgb;
}
// texProj0 * end_portal_layer(layer): the row vector through mat4(scale * rotate),
// translate and SCALE_TRANSLATE in turn.
fn portal_layer(p: vec4<f32>, layer: f32) -> vec4<f32> {
let a = radians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
let c = cos(a);
let s = sin(a);
let k = (4.5 - layer / 4.0) * 2.0;
var v = vec4(k * (c * p.x - s * p.y), k * (s * p.x + c * p.y), p.z, p.w);
v = vec4(v.x + 17.0 / layer * v.w, v.y + (2.0 + layer / 1.5) * (globals.time.x * 1.5) * v.w, v.z, v.w);
return vec4(0.5 * v.x + 0.25 * v.w, 0.5 * v.y + 0.25 * v.w, v.z, v.w);
}
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
if d <= start { return 0.0; }
if d >= end { return 1.0; }
@@ -61,7 +103,16 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
var color = textureSample(atlas, atlas_sampler, uv) * in.color;
if in.overlay == 4u {
if in.overlay == 9u || in.overlay == 10u {
// rendertype_end_portal: end_sky and PORTAL_LAYERS (15 for portals, 16
// for gateways) layers of end_portal.png projected from the screen.
let layers = select(15, 16, in.overlay == 10u);
var c = portal_sample(globals.end_portal_sprites[0], in.proj) * PORTAL_COLORS[0];
for (var i = 0; i < layers; i++) {
c += portal_sample(globals.end_portal_sprites[1], portal_layer(in.proj, f32(i + 1))) * PORTAL_COLORS[i];
}
color = vec4(c, 1.0);
} else if in.overlay == 4u {
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
}
+103 -26
View File
@@ -253,17 +253,37 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
} else {
map.insert("equipment".into(), Tag::compound(equipment));
}
let attr = |id: &str, v: f64| {
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
let modelled = [
("minecraft:max_health", f64::from(m.attrs.max_health)),
("minecraft:movement_speed", m.attrs.speed),
("minecraft:jump_strength", m.extra.vector[0]),
];
// AttributeMap.pack writes the instances that exist: the ones the
// entity was loaded with, or all three randomized by finalizeSpawn.
let attributes = match map.get(&"attributes".into()) {
Some(Tag::List(list)) => list
.iter()
.map(|a| {
let id = text(a.get("id"));
let value = modelled.iter().find(|(m, _)| Some(*m) == id.as_deref());
match (a, value) {
(Tag::Compound(c), Some((_, v))) => {
let mut c = c.clone();
c.insert("base".into(), Tag::Double(v.to_bits()));
Tag::Compound(c)
}
_ => a.clone(),
}
})
.collect(),
_ => modelled
.iter()
.map(|(id, v)| {
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
})
.collect(),
};
map.insert(
"attributes".into(),
Tag::List(vec![
attr("minecraft:max_health", f64::from(m.attrs.max_health)),
attr("minecraft:movement_speed", m.attrs.speed),
attr("minecraft:jump_strength", m.extra.vector[0]),
]),
);
map.insert("attributes".into(), Tag::List(attributes));
}
"minecraft:pig" | "minecraft:strider" => {
let mut equipment = match map.get(&"equipment".into()) {
@@ -387,7 +407,9 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
// Skeletons are spawned holding a bow (finalizeSpawn); a mob loaded
// from NBT keeps only its saved equipment.
"minecraft:skeleton" | "minecraft:stray" | "minecraft:bogged" if raw.is_none() => {
"minecraft:skeleton" | "minecraft:stray" | "minecraft:bogged"
if raw.is_none() && m.extra.main_hand.as_deref() == Some("minecraft:bow") =>
{
map.entry("equipment".into()).or_insert_with(|| {
Tag::compound([(
"mainhand",
@@ -410,6 +432,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry(key.into()).or_insert(value);
}
}
// CopperGolem.addAdditionalSaveData.
"minecraft:copper_golem" => {
map.insert(
"next_weather_age".into(),
Tag::Long(m.extra.vector[0] as i64),
);
map.insert(
"weather_state".into(),
string(crate::mobs::copper_golem::WEATHER[m.extra.variant.min(3) as usize]),
);
}
_ => {}
}
crate::mobs::raider::write_nbt(m, map);
@@ -717,6 +750,21 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if m.species() == crate::mobs::Species::Sniffer {
crate::mobs::sniffer::from_nbt(&mut m, tag);
}
if m.species() == crate::mobs::Species::CopperGolem {
// CopperGolem.readAdditionalSaveData: -1 (unset) when missing.
m.extra.vector[0] = match tag.get("next_weather_age") {
Some(Tag::Long(v)) => *v as f64,
_ => -1.0,
};
let weather = match tag.get("weather_state") {
Some(Tag::String(s)) => s.to_string_lossy(),
_ => String::new(),
};
m.extra.variant = crate::mobs::copper_golem::WEATHER
.iter()
.position(|w| *w == weather)
.unwrap_or(0) as u8;
}
m.extra.leash_pending = crate::mobs::leash::pending_from_nbt(tag.get("leash"));
if m.species() == crate::mobs::Species::Warden {
// The brain starts afresh: a minute before it digs back down.
@@ -926,7 +974,8 @@ pub fn falling_block_to_nbt(
state: Tag,
position: Vec3,
motion: Vec3,
time: u32,
time: i32,
drop_item: bool,
uuid: [i32; 4],
) -> Tag {
let base = Base {
@@ -941,8 +990,8 @@ pub fn falling_block_to_nbt(
let mut tag = base.tag(None);
for (key, value) in [
("BlockState", state),
("Time", Tag::Int(time as i32)),
("DropItem", Tag::Byte(1)),
("Time", Tag::Int(time)),
("DropItem", Tag::Byte(i8::from(drop_item))),
("HurtEntities", Tag::Byte(0)),
("CancelDrop", Tag::Byte(0)),
("FallHurtAmount", Tag::Float(0)),
@@ -953,15 +1002,23 @@ pub fn falling_block_to_nbt(
tag
}
/// (block state tag, position, motion, time)
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, u32)> {
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32)> {
Some((
tag.get("BlockState")?.clone(),
position(tag)?,
motion(tag),
number(tag.get("Time")).unwrap_or(0).max(0) as u32,
number(tag.get("Time")).unwrap_or(0) as i32,
))
}
/// `NoGravity` and `DropItem` (true when absent) of a saved falling block.
pub fn falling_block_flags(tag: &Tag) -> (bool, bool) {
(
flag(tag, "NoGravity"),
number(tag.get("DropItem")).is_none_or(|v| v != 0),
)
}
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`.
pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let (lo, hi) = p.bounds();
@@ -1089,6 +1146,25 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
{
put(&mut tag, "Health", Tag::Float(20f32.to_bits()));
}
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
if let Tag::Compound(map) = &mut tag {
for (key, value) in [
("HurtTime", Tag::Short(0)),
("DeathTime", Tag::Short(0)),
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
("attributes", Tag::List(Vec::new())),
(
"Brain",
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
),
("DisabledSlots", Tag::Int(0)),
("Pose", Tag::Compound(Compound::new())),
] {
map.entry(key.into()).or_insert(value);
}
}
tag
}
pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
@@ -1107,22 +1183,19 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
a.on_ground = flag(tag, "OnGround");
Some(a)
}
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_raft`).
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
/// chest boats keep their slots in `Items` (`ContainerHelper.saveAllItems`).
pub fn boat_to_nbt(
wood: &str,
id: &str,
position: Vec3,
motion: Vec3,
yaw: f32,
uuid: [i32; 4],
raw: Option<&Tag>,
items: Option<&[Option<crate::items::ItemStack>]>,
) -> Tag {
let id = if wood == "bamboo" {
"minecraft:bamboo_raft".to_owned()
} else {
format!("minecraft:{wood}_boat")
};
Base {
id: &id,
let mut tag = Base {
id,
uuid,
position,
motion,
@@ -1130,7 +1203,11 @@ pub fn boat_to_nbt(
on_ground: false,
fall_distance: 0.0,
}
.tag(raw)
.tag(raw);
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
map.insert("Items".into(), crate::save::encode_items(items));
}
tag
}
/// A vehicle's base entity tag (boats and minecarts).
pub fn vehicle_to_nbt(
+270
View File
@@ -0,0 +1,270 @@
//! Iteration order of the Java hash collections vanilla's scoreboard keeps
//! its names in: fastutil's `Object2ObjectOpenHashMap<String, _>` (open
//! addressing with linear probing, backward-shift deletion and rehashing at
//! the load factor) and `java.util.HashSet<String>` (chained buckets split on
//! resize). Commands list, and `scoreboard.dat` saves, in these orders.
/// The keys of a fastutil open hash map in their table slots.
#[derive(Clone, Debug, PartialEq)]
pub struct OpenOrder {
keys: Vec<Option<String>>,
size: usize,
min_n: usize,
load: f32,
}
impl Default for OpenOrder {
/// `new Object2ObjectOpenHashMap<>()`: 16 expected entries, load 0.75.
fn default() -> Self {
Self::new(16, 0.75)
}
}
/// `String.hashCode`.
pub fn java_hash(s: &str) -> i32 {
s.encode_utf16()
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(i32::from(c)))
}
/// `HashCommon.mix`.
fn mix(x: i32) -> usize {
let h = (x as u32).wrapping_mul(0x9E37_79B9);
(h ^ (h >> 16)) as usize
}
/// `HashCommon.arraySize`.
fn array_size(expected: usize, f: f32) -> usize {
let wanted = (expected as f64 / f64::from(f)).ceil() as usize;
wanted.max(2).next_power_of_two()
}
/// `HashCommon.maxFill`.
fn max_fill(n: usize, f: f32) -> usize {
((n as f64 * f64::from(f)).ceil() as usize).min(n - 1)
}
impl OpenOrder {
pub fn new(expected: usize, load: f32) -> Self {
let n = array_size(expected, load);
Self {
keys: vec![None; n],
size: 0,
min_n: n,
load,
}
}
pub fn len(&self) -> usize {
self.size
}
pub fn is_empty(&self) -> bool {
self.size == 0
}
fn slot(&self, key: &str) -> usize {
mix(java_hash(key)) & (self.keys.len() - 1)
}
pub fn contains(&self, key: &str) -> bool {
let mask = self.keys.len() - 1;
let mut pos = self.slot(key);
while let Some(k) = &self.keys[pos] {
if k == key {
return true;
}
pos = (pos + 1) & mask;
}
false
}
/// `put`: false when the key was already there.
pub fn insert(&mut self, key: &str) -> bool {
let mask = self.keys.len() - 1;
let mut pos = self.slot(key);
while let Some(k) = &self.keys[pos] {
if k == key {
return false;
}
pos = (pos + 1) & mask;
}
self.keys[pos] = Some(key.to_owned());
// `if (size++ >= maxFill) rehash(arraySize(size + 1, f))`.
let old = self.size;
self.size += 1;
if old >= max_fill(self.keys.len(), self.load) {
self.rehash(array_size(self.size + 1, self.load));
}
true
}
/// `remove`: false when the key was not there.
pub fn remove(&mut self, key: &str) -> bool {
let mask = self.keys.len() - 1;
let mut pos = self.slot(key);
loop {
match &self.keys[pos] {
None => return false,
Some(k) if k == key => break,
Some(_) => pos = (pos + 1) & mask,
}
}
self.size -= 1;
self.shift_keys(pos);
let n = self.keys.len();
if n > self.min_n && self.size < max_fill(n, self.load) / 4 && n > 16 {
self.rehash(n / 2);
}
true
}
fn shift_keys(&mut self, mut pos: usize) {
let mask = self.keys.len() - 1;
loop {
let last = pos;
pos = (last + 1) & mask;
loop {
let Some(curr) = self.keys[pos].clone() else {
self.keys[last] = None;
return;
};
let slot = self.slot(&curr);
let stop = if last <= pos {
last >= slot || slot > pos
} else {
last >= slot && slot > pos
};
if stop {
self.keys[last] = Some(curr);
break;
}
pos = (pos + 1) & mask;
}
}
}
fn rehash(&mut self, new_n: usize) {
let old = std::mem::replace(&mut self.keys, vec![None; new_n]);
let mask = new_n - 1;
// `for (int j = realSize(); j-- != 0;) { while (key[--i] == null); ... }`
for key in old.into_iter().rev().flatten() {
let mut pos = mix(java_hash(&key)) & mask;
while self.keys[pos].is_some() {
pos = (pos + 1) & mask;
}
self.keys[pos] = Some(key);
}
}
/// `iterator()` and `forEach`: from the last slot down.
pub fn iter(&self) -> impl Iterator<Item = &str> {
self.keys.iter().rev().flatten().map(String::as_str)
}
/// `spliterator()` (what `stream()` uses): from the first slot up.
pub fn stream(&self) -> impl Iterator<Item = &str> {
self.keys.iter().flatten().map(String::as_str)
}
}
/// The keys of a `java.util.HashSet<String>` in their buckets.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HashSetOrder {
/// Empty until the first insertion (`HashMap.resize` on first `put`).
table: Vec<Vec<String>>,
size: usize,
}
/// `HashMap.hash`.
fn spread(s: &str) -> usize {
let h = java_hash(s) as u32;
(h ^ (h >> 16)) as usize
}
impl HashSetOrder {
pub fn len(&self) -> usize {
self.size
}
pub fn is_empty(&self) -> bool {
self.size == 0
}
pub fn contains(&self, key: &str) -> bool {
if self.table.is_empty() {
return false;
}
let i = spread(key) & (self.table.len() - 1);
self.table[i].iter().any(|k| k == key)
}
pub fn insert(&mut self, key: &str) -> bool {
if self.table.is_empty() {
self.table = vec![Vec::new(); 16];
}
let i = spread(key) & (self.table.len() - 1);
if self.table[i].iter().any(|k| k == key) {
return false;
}
self.table[i].push(key.to_owned());
self.size += 1;
if self.size > self.table.len() * 3 / 4 {
self.resize();
}
true
}
/// Doubles the table; each bucket splits into its low and high halves in order.
fn resize(&mut self) {
let old = std::mem::take(&mut self.table);
let n = old.len() * 2;
self.table = vec![Vec::new(); n];
for bucket in old {
for key in bucket {
let i = spread(&key) & (n - 1);
self.table[i].push(key);
}
}
}
pub fn remove(&mut self, key: &str) -> bool {
if self.table.is_empty() {
return false;
}
let i = spread(key) & (self.table.len() - 1);
let Some(at) = self.table[i].iter().position(|k| k == key) else {
return false;
};
self.table[i].remove(at);
self.size -= 1;
true
}
pub fn iter(&self) -> impl Iterator<Item = &String> {
self.table.iter().flatten()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_and_order() {
assert_eq!(java_hash("harness"), {
"harness"
.chars()
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(c as i32))
});
let mut o = OpenOrder::new(16, 0.5);
for k in ["harness", "harness_h"] {
o.insert(k);
}
assert_eq!(o.iter().count(), 2);
let mut asc: Vec<&str> = o.stream().collect();
asc.reverse();
assert_eq!(asc, o.iter().collect::<Vec<_>>());
o.remove("harness");
assert_eq!(o.iter().collect::<Vec<_>>(), vec!["harness_h"]);
for i in 0..40 {
o.insert(&format!("k{i}"));
}
assert_eq!(o.iter().count(), 41);
for i in 0..40 {
o.remove(&format!("k{i}"));
}
assert_eq!(o.iter().collect::<Vec<_>>(), vec!["harness_h"]);
}
#[test]
fn hash_set_buckets() {
let mut s = HashSetOrder::default();
for i in 0..30 {
assert!(s.insert(&format!("p{i}")));
}
assert!(!s.insert("p3"));
assert_eq!(s.len(), 30);
assert!(s.remove("p3") && !s.contains("p3"));
// "Aa" and "BB" share a hash: insertion order within the bucket.
let mut t = HashSetOrder::default();
t.insert("BB");
t.insert("Aa");
assert_eq!(t.iter().collect::<Vec<_>>(), vec!["BB", "Aa"]);
}
}
+66
View File
@@ -451,6 +451,72 @@ impl Font {
.sum::<f32>()
.ceil()
}
/// `StringSplitter.splitLines`: each line and whether it continues a
/// wrapped one. A line breaks at a `\n`, or once a character overflows
/// `max_width` (never the first visible one) at the last space so far, or
/// before that character; the `\n` or space at the break is dropped.
pub fn split_lines(&self, t: &Text, max_width: f32) -> Vec<(Text, bool)> {
let max_width = max_width.max(1.0);
let chars: Vec<(char, &Style)> = t.chars().collect();
let n = chars.len();
let mut out = Vec::new();
let (mut start, mut wrapped, mut force_new_line) = (0, false, false);
loop {
// LineBreakFinder over what is left.
let (mut width, mut had_visible, mut last_space) = (0.0f32, false, None);
let mut split = None;
for (i, &(c, style)) in chars.iter().enumerate().skip(start) {
if c == '\n' {
split = Some(i);
break;
}
if c == ' ' {
last_space = Some(i);
}
let advance = self.advance(c, style);
width += advance;
if !had_visible || width <= max_width {
had_visible |= advance != 0.0;
} else {
split = Some(last_space.unwrap_or(i));
break;
}
}
let Some(at) = split else { break };
let new_line = chars[at].0 == '\n';
let skip = usize::from(new_line || chars[at].0 == ' ');
force_new_line = new_line;
out.push((t.slice(start, at), wrapped));
wrapped = !new_line;
start = at + skip;
}
if start < n {
out.push((t.slice(start, n), wrapped));
} else if force_new_line {
out.push((Text::empty(), false));
}
out
}
/// `ComponentRenderUtils.wrapComponents`: the split lines, wrapped ones
/// indented by an unstyled space; one empty line for empty text.
pub fn wrap_components(&self, t: &Text, max_width: i32) -> Vec<Text> {
let lines: Vec<Text> = self
.split_lines(t, max_width as f32)
.into_iter()
.map(|(line, wrapped)| {
if wrapped {
Text::literal(" ").append(line)
} else {
line
}
})
.collect();
if lines.is_empty() {
vec![Text::empty()]
} else {
lines
}
}
/// StringSplitter.splitLines: wrap at the last space that fits (or
/// mid-word when none does); `\n` always breaks. Styles are kept.
pub fn split(&self, t: &Text, max_width: f32) -> Vec<Text> {
File diff suppressed because it is too large. Load diff
+44 -20
View File
@@ -10,7 +10,7 @@ use horizon_client::{
};
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
collections::{BTreeMap, VecDeque},
sync::Arc,
time::Instant,
};
@@ -46,7 +46,6 @@ pub(super) struct AdvState {
pub(super) tree: Arc<Tree>,
pub(super) progress: Progress,
loaded: bool,
pub(super) recipes: BTreeSet<String>,
pub(super) toasts: VecDeque<Toast>,
/// Awards skip the toast (`/advancement grant ... everything`).
pub(super) quiet: bool,
@@ -168,6 +167,12 @@ fn now_text() -> String {
rem % 60
)
}
impl Game<'_> {
/// `ItemPredicate.test` (the `item_filter` of a filtered item modifier).
pub(super) fn loot_item_filter(&self, predicate: &Value, s: &ItemStack) -> bool {
adv::item(predicate, &item_view(s), &View(self))
}
}
pub(super) fn item_view(s: &ItemStack) -> ItemView {
ItemView {
id: s.item.to_string(),
@@ -214,13 +219,9 @@ impl Game<'_> {
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
.map(|v| Progress::from_json(&v))
.unwrap_or_default();
// Unlocked recipes come from the done recipe advancements' rewards.
// The known recipes are the player's own (`recipeBook`), not the
// rewards of done advancements: /recipe take sticks.
let tree = self.adv.tree.clone();
for a in &tree.list {
if progress.done(a) {
self.adv.recipes.extend(a.rewards.recipes.iter().cloned());
}
}
self.adv.progress = progress;
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
}
@@ -375,6 +376,17 @@ impl Game<'_> {
}
}
}
/// After `ServerRecipeBook.addRecipes`: the toast and the
/// `recipe_unlocked` trigger of each recipe now known.
pub(super) fn recipe_unlocked(&mut self, unlocked: &[String]) {
self.recipe_toast(unlocked);
for r in unlocked {
let r = r.clone();
self.trigger("minecraft:recipe_unlocked", move |c, _| {
c["recipes"].as_str() == Some(r.as_str())
});
}
}
/// `PlayerAdvancements.award` once done: rewards, toast, announcement.
fn award(&mut self, index: usize) {
let tree = self.adv.tree.clone();
@@ -383,12 +395,18 @@ impl Game<'_> {
if a.rewards.experience > 0 {
self.experience.give(a.rewards.experience);
}
let mut unlocked = Vec::new();
for r in &a.rewards.recipes {
if self.adv.recipes.insert(r.clone()) {
unlocked.push(r.clone());
}
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
let rewards: Vec<String> = a
.rewards
.recipes
.iter()
.filter(|r| self.res.stations.recipe_index.contains(r))
.cloned()
.collect();
let (_, unlocked) = self
.level
.recipe_book
.add_recipes(&self.res.stations.recipe_index, &rewards);
for table in &a.rewards.loot {
let items = self
.res
@@ -425,12 +443,7 @@ impl Game<'_> {
));
}
}
self.recipe_toast(&unlocked);
for r in unlocked {
self.trigger("minecraft:recipe_unlocked", |c, _| {
c["recipes"].as_str() == Some(r.as_str())
});
}
self.recipe_unlocked(&unlocked);
}
/// `InventoryChangeTrigger`, checked when the inventory changes.
pub(super) fn check_inventory_trigger(&mut self) {
@@ -505,6 +518,17 @@ impl Game<'_> {
return;
}
self.check_inventory_trigger();
// ServerPlayer.tick: PlayerTrigger.TICK every tick.
let me = self.player_entity();
self.trigger("minecraft:tick", |c, w| {
let ctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &ctx, w)
});
if self.ticks.is_multiple_of(20) {
let me = self.player_entity();
self.trigger("minecraft:location", |c, w| {
@@ -415,6 +415,8 @@ impl Game<'_> {
pose[5] = deg(rot[2]);
}
}
// Item layers pose from the hidden arms too (`visible` only skips drawing).
let posed = poses.clone();
let mut hide = |name: &str| {
if poses.has(name) {
let pose = poses.get(name);
@@ -446,6 +448,7 @@ impl Game<'_> {
0,
out,
);
self.armor_stand_items(a, model, base, &posed, packed, out);
// HumanoidArmorLayer: head, chest, legs, feet.
for (slot, layer) in [
(5usize, "helmet"),
@@ -562,3 +565,120 @@ impl Game<'_> {
true
}
}
impl Game<'_> {
/// `ItemInHandLayer` and `CustomHeadLayer` of an armour stand: the hand
/// items in the third-person contexts, and a non-armour head item on the
/// head (`translateToHead`: down a quarter, turned, 0.625 scale).
fn armor_stand_items(
&self,
a: &ArmorStand,
model: &Part,
base: glam::Mat4,
poses: &Poses,
light: u32,
out: &mut Vec<Vertex>,
) {
use horizon_assets::item_model::DisplayContext;
use horizon_client::mobs::render::part_matrix;
let owner = super::item_draw::owner_at(
[a.position.x, a.position.y, a.position.z],
a.yaw,
"minecraft:armor_stand",
);
// `useBabyOffset`: small stands use the baby hand offsets.
let (ox, oy, oz) = if a.small {
(0.0, 1.0, -4.5)
} else {
(1.0, 2.0, -10.0)
};
for (slot, left, context) in [
(0usize, false, DisplayContext::ThirdPersonRightHand),
(1, true, DisplayContext::ThirdPersonLeftHand),
] {
let Some(stack) = &a.equipment[slot] else {
continue;
};
let arm = if left { "left_arm" } else { "right_arm" };
let Some(hand) = part_matrix(model, base, poses, arm, 0.0) else {
continue;
};
let pose = hand
* glam::Mat4::from_rotation_x((-90.0f32).to_radians())
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
* glam::Mat4::from_translation(glam::Vec3::new(
if left { -ox } else { ox } / 16.0,
oy / 16.0,
oz / 16.0,
));
let seed = a.id as i32 + if left { 1 } else { 2 };
let state = self.resolve_item(stack, context, Some(&owner), seed);
self.submit_item_world(&state, pose, light, out);
}
// `CustomHeadLayer`: a worn skull draws the skull model.
if let Some(stack) = &a.equipment[5]
&& let Some(kind) = skull_kind(&stack.item)
&& let Some(head) = part_matrix(model, base, poses, "head", 0.0)
{
let pose = head * glam::Mat4::from_scale(glam::Vec3::splat(1.1875));
let special = horizon_assets::item_model::SpecialModel {
kind: if kind == "player" {
horizon_assets::item_model::SpecialKind::PlayerHead
} else {
horizon_assets::item_model::SpecialKind::Head {
kind: kind.to_owned(),
texture: None,
animation: 0.0,
}
},
};
let mut sink = horizon_client::item_render::WorldSink {
out,
light,
lights: Some(horizon_client::block_entity_render::diffuse_lights(
self.dim == super::Dim::Nether,
)),
};
self.res.item_renderer.submit_special(
&special,
&serde_json::Value::Null,
false,
pose,
&self.res.atlas.sprites,
&self.item_host(),
&mut sink,
);
return;
}
// A head item that is not worn armour.
if let Some(stack) = &a.equipment[5]
&& self
.res
.items
.get(&stack.item)
.is_none_or(|i| i.equip_asset.is_none())
&& let Some(head) = part_matrix(model, base, poses, "head", 0.0)
{
let pose = head
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
* glam::Mat4::from_scale(glam::Vec3::new(0.625, -0.625, -0.625));
// `updateForLiving`: seeded by the entity id plus HEAD's ordinal.
let state =
self.resolve_item(stack, DisplayContext::Head, Some(&owner), a.id as i32 + 5);
self.submit_item_world(&state, pose, light, out);
}
}
}
/// `AbstractSkullBlock.getType` of a skull item.
fn skull_kind(item: &str) -> Option<&'static str> {
Some(match item {
"minecraft:skeleton_skull" => "skeleton",
"minecraft:wither_skeleton_skull" => "wither_skeleton",
"minecraft:zombie_head" => "zombie",
"minecraft:creeper_head" => "creeper",
"minecraft:dragon_head" => "dragon",
"minecraft:piglin_head" => "piglin",
"minecraft:player_head" => "player",
_ => return None,
})
}
+2 -175
View File
@@ -2,14 +2,10 @@
//! banner item to the block when placed and back to the dropped item when
//! broken (`copy_components` in the banner loot tables).
use super::Game;
use glam::{EulerRot, Mat4, Quat, Vec3};
use horizon_client::{
banner::{self, BannerLayer},
block_entity_render::{Paint, draw_part},
containers::{Banner, BlockEntity},
hud::{HudBatch, rgba},
containers::BlockEntity,
items::ItemStack,
mesher::Vertex,
};
impl Game<'_> {
@@ -23,7 +19,7 @@ impl Game<'_> {
}
/// A placed banner takes the item's layers and name.
pub(super) fn fill_placed_banner(&mut self, pos: [i32; 3], stack: &ItemStack) {
self.pending_banner.remove(&pos);
self.pending.banner.remove(&pos);
if !stack.item.ends_with("_banner") {
return;
}
@@ -34,175 +30,6 @@ impl Game<'_> {
b.name = stack.custom_name().map(str::to_owned);
}
}
/// A banner in a slot: `item/template_banner`'s `gui` transform on the
/// banner model (pole, bar and flag with every layer), flat-lit and
/// depth-sorted. False when the stack is not a banner.
pub(super) fn draw_banner_icon(
&self,
batch: &mut HudBatch,
stack: &ItemStack,
x: f32,
y: f32,
) -> bool {
let Some(base) = banner::banner_color(&stack.item) else {
return false;
};
let Some((main, flag)) = self.res.block_entity_render.banner_models() else {
return true;
};
let banners = &self.res.stations.banners;
let sprites = &self.res.atlas.sprites;
// display.gui, then the item's special transformation (a banner is
// modelled upside down and 2/3 size around the block centre).
let m = Mat4::from_translation(Vec3::new(0.0, -3.25 / 16.0, 0.0))
* Mat4::from_quat(Quat::from_euler(
EulerRot::XYZ,
30f32.to_radians(),
20f32.to_radians(),
0.0,
))
* Mat4::from_scale(Vec3::splat(0.5325))
* Mat4::from_translation(Vec3::new(0.0, -0.5, 0.0))
* Mat4::from_scale(Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0));
let wave = |name: &str, p: &mut [f32; 9]| {
if name == "flag" {
p[3] = (-0.0125 + 0.01) * std::f32::consts::PI;
}
};
let none = |_: &str, _: &mut [f32; 9]| {};
let mut passes: Vec<(&str, u32, bool, String)> = vec![
("entity/banner/banner_base", 0xFFFFFF, true, String::new()),
(
"entity/banner/base",
banner::dye_rgb(base),
false,
String::new(),
),
];
for layer in banner::layers(stack).iter().take(16) {
passes.push((
"",
banner::dye_rgb(&layer.color),
false,
banners.banner_texture(&layer.pattern),
));
}
// (depth, pass, screen corners, uv, colour)
let mut faces: Vec<(f32, usize, [[f32; 2]; 4], [[f32; 2]; 4], u32)> = Vec::new();
for (index, (fixed, tint, with_pole, dynamic)) in passes.iter().enumerate() {
let name = if fixed.is_empty() {
dynamic.as_str()
} else {
fixed
};
let Some(sprite) = sprites.get(name).copied() else {
continue;
};
let paint = Paint {
sprite,
tint: 0xFFFFFF,
light: 0,
lights: (Vec3::ZERO, Vec3::ZERO),
};
let mut verts: Vec<Vertex> = Vec::new();
if *with_pole {
draw_part(main, "root", m, &none, paint, &mut verts);
}
draw_part(flag, "root", m, &wave, paint, &mut verts);
for quad in verts.as_chunks::<4>().0 {
let p = |i: usize| Vec3::from(quad[i].pos);
let normal = (p(1) - p(0)).cross(p(2) - p(0)).normalize_or_zero();
// Faces turned away from the viewer are culled.
if normal.z <= 0.0 {
continue;
}
let depth = (0..4).map(|i| p(i).z).sum::<f32>() / 4.0;
let shade = (0.6 + 0.4 * normal.z).min(1.0);
let c = |s: u32| (((tint >> s) & 255) as f32 * shade) as u8;
faces.push((
depth,
index,
std::array::from_fn(|i| {
[
x + 8.0 + quad[i].pos[0] * 16.0,
y + 8.0 - quad[i].pos[1] * 16.0,
]
}),
std::array::from_fn(|i| quad[i].uv),
rgba(c(16), c(8), c(0), 255),
));
}
}
faces.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
for (_, _, corners, uv, color) in faces {
batch.quad(corners, uv, color);
}
true
}
/// A banner in the first-person hand: the standing banner model under the
/// item's `firstperson_righthand` transform (`m`) and its special
/// transformation. False when the stack is not a banner.
pub(super) fn held_banner_geometry(
&self,
m: Mat4,
stack: &ItemStack,
packed: u32,
out: &mut Vec<Vertex>,
) -> bool {
let Some(base) = banner::banner_color(&stack.item) else {
return false;
};
let Some((main, flag)) = self.res.block_entity_render.banner_models() else {
return true;
};
let m = m
* Mat4::from_translation(Vec3::new(0.5, 0.0, 0.5))
* Mat4::from_scale(Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0));
let wave = |name: &str, p: &mut [f32; 9]| {
if name == "flag" {
p[3] = (-0.0125 + 0.01) * std::f32::consts::PI;
}
};
let none = |_: &str, _: &mut [f32; 9]| {};
let lights = horizon_client::block_entity_render::diffuse_lights(false);
let sprites = &self.res.atlas.sprites;
let paint = |name: &str, tint: u32| {
sprites.get(name).map(|&sprite| Paint {
sprite,
tint,
light: packed,
lights,
})
};
if let Some(p) = paint("entity/banner/banner_base", 0xFFFFFF) {
draw_part(main, "root", m, &none, p, out);
draw_part(flag, "root", m, &wave, p, out);
}
if let Some(p) = paint("entity/banner/base", banner::dye_rgb(base)) {
draw_part(flag, "root", m, &wave, p, out);
}
for layer in banner::layers(stack).iter().take(16) {
let texture = self.res.stations.banners.banner_texture(&layer.pattern);
if let Some(p) = paint(&texture, banner::dye_rgb(&layer.color)) {
draw_part(flag, "root", m, &wave, p, out);
}
}
true
}
/// The layers a banner dropped at `pos` carries.
pub(super) fn attach_banner_layers(&mut self, pos: [i32; 3], stack: &mut ItemStack) {
if !stack.item.ends_with("_banner") {
return;
}
if let Some(Banner { layers, name }) = self.pending_banner.remove(&pos) {
let data = stack.data_mut();
data.banner_patterns = layers;
if name.is_some() {
data.custom_name = name;
}
stack.tidy();
}
}
}
/// Texture names and tints of banner layers (`Sheets.getBannerSprite`).
+11 -72
View File
@@ -522,9 +522,6 @@ impl Game<'_> {
else {
return;
};
// The sprite's v range rides in the light bytes (entity shader overlay 6).
let q = |v: f32| (v * 65535.0).round().clamp(0.0, 65535.0) as u32;
let wrap = q(s.v0) | q(s.v1 - s.v0) << 16;
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
let scoping = self.scoping();
for (k, e) in &self.world.block_entities {
@@ -539,75 +536,17 @@ impl Game<'_> {
} else {
(distance / 96.0).max(1.0)
};
let o = (base - camera).as_vec3();
let mut start = 0i32;
for (i, &(color, height)) in b.sections.iter().enumerate() {
let height = if i == b.sections.len() - 1 {
2048
} else {
height
};
let end = start + height;
let scroll = -time;
let v_off = (scroll * 0.2 - (scroll * 0.1).floor()).rem_euclid(1.0);
let rgb = |a: u32| {
a << 24 | (color & 255) << 16 | (color >> 8 & 255) << 8 | (color >> 16 & 255)
};
// Solid beam: radius 0.2, rotated with time.
let r = 0.2 * scale;
let rot = glam::Mat3::from_rotation_y((time * 2.25 - 45.0).to_radians());
let corners = [
rot * glam::Vec3::new(0.0, 0.0, r),
rot * glam::Vec3::new(r, 0.0, 0.0),
rot * glam::Vec3::new(-r, 0.0, 0.0),
rot * glam::Vec3::new(0.0, 0.0, -r),
];
let v2 = -1.0 + v_off;
let v1 = height as f32 * (0.5 / r) + v2;
beam_part(out, o, start, end, corners, [v1, v2], rgb(255), s, wrap);
// Glow: radius 0.25, translucent.
let g = 0.25 * scale;
let corners = [
glam::Vec3::new(-g, 0.0, -g),
glam::Vec3::new(g, 0.0, -g),
glam::Vec3::new(-g, 0.0, g),
glam::Vec3::new(g, 0.0, g),
];
let v1 = height as f32 + v2;
beam_part(out, o, start, end, corners, [v1, v2], rgb(32), s, wrap);
start = end;
}
}
}
}
/// `renderPart`: four quads between the corners (wn, en, ws, es).
#[allow(clippy::too_many_arguments)]
fn beam_part(
out: &mut Vec<Vertex>,
o: glam::Vec3,
start: i32,
end: i32,
[wn, en, ws, es]: [glam::Vec3; 4],
[v1, v2]: [f32; 2],
color: u32,
s: horizon_client::hud::Rect,
wrap: u32,
) {
for (a, b) in [(wn, en), (es, ws), (en, es), (ws, wn)] {
let at = |c: glam::Vec3, y: i32| o + c + glam::Vec3::new(0.0, y as f32, 0.0);
for (p, uv) in [
(at(a, end), [s.u1, v1]),
(at(a, start), [s.u1, v2]),
(at(b, start), [s.u0, v2]),
(at(b, end), [s.u0, v1]),
] {
out.push(Vertex {
pos: p.to_array(),
uv,
layer: 6,
color,
light: wrap,
});
let origin = (glam::DVec3::new(f64::from(k.0), f64::from(k.1), f64::from(k.2))
- camera)
.as_vec3();
horizon_client::block_entity_render::beacon_beam(
origin,
&b.sections,
time,
scale,
s,
out,
);
}
}
}
@@ -0,0 +1,349 @@
//! The client side of animated block entities (`horizon_client::block_entity_anim`):
//! their client tickers (`Level.tickBlockEntities` on the client level) and
//! the block events that drive them (`ClientboundBlockEventPacket` reaching
//! `BlockEntity.triggerEvent`). The renderer reads the resulting state through
//! [`Game::block_anim`] and [`Game::chest_lid`].
use super::Game;
use horizon_client::{
block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker},
block_entity_render::BlockAnim,
collisions::{SceneCollisions, no_collision_empty},
particles::{ParticleOptions, ParticleType},
save::number,
};
use horizon_core::geometry::{Aabb, Vec3};
use std::collections::HashMap;
/// The handle of the local player among a bell's gathered entities.
const PLAYER: u64 = u64::MAX;
impl Game<'_> {
/// `BlockEntity.triggerEvent(a, b)` on the client copy of the block entity at `pos`.
pub(super) fn block_entity_event(&mut self, pos: [i32; 3], a: i32, b: i32) {
let state = self.world.block(pos[0], pos[1], pos[2]).0;
let time = self.world.game_time;
match self.be_anims.ticker(state) {
Some(Ticker::Lid) => {
self.be_anims
.chests
.entry(pos)
.or_default()
.trigger_event(a, b);
}
Some(Ticker::Shulker { .. }) => {
self.be_anims
.shulkers
.entry(pos)
.or_default()
.trigger_event(a, b);
}
Some(Ticker::Bell) => {
let gathered = self.bell_gather(pos);
self.be_anims
.bells
.entry(pos)
.or_default()
.trigger_event(a, b, time, || gathered);
}
Some(Ticker::Gateway) => {
let initial = self.gateway_loaded(pos);
self.be_anims
.gateways
.entry(pos)
.or_insert(initial)
.trigger_event(a, b);
}
Some(Ticker::Pot) => {
self.be_anims
.pots
.entry(pos)
.or_default()
.trigger_event(a, b, time);
}
_ => {}
}
}
/// A player opened (`open`) or closed the container at `key`: the opener
/// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity`
/// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes,
/// on both halves of a double chest (`CompoundContainer`).
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
// Spectators never count as openers.
if self.spectator() {
return;
}
let pos = [key.0, key.1, key.2];
let state = self.world.block(pos[0], pos[1], pos[2]).0;
match self.be_anims.ticker(state) {
Some(Ticker::Lid) => {
let count = i32::from(open);
self.block_entity_event(pos, 1, count);
if let Some(other) = self.chest_other_half(key) {
self.block_entity_event([other.0, other.1, other.2], 1, count);
}
}
Some(Ticker::Shulker { .. }) => {
let shulker = self.be_anims.shulkers.entry(pos).or_default();
let (count, _) = if open {
shulker.start_open()
} else {
shulker.stop_open()
};
self.block_entity_event(pos, 1, count);
}
_ => {}
}
}
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
/// box meets the bell's block box inflated by 48.
fn bell_gather(&self, pos: [i32; 3]) -> Vec<u64> {
let area = Aabb::new(
Vec3::new(
f64::from(pos[0]) - 48.0,
f64::from(pos[1]) - 48.0,
f64::from(pos[2]) - 48.0,
),
Vec3::new(
f64::from(pos[0]) + 49.0,
f64::from(pos[1]) + 49.0,
f64::from(pos[2]) + 49.0,
),
);
let mut out = Vec::new();
if self
.player_view()
.is_some_and(|p| p.bounds.intersects(area))
{
out.push(PLAYER);
}
for m in &self.mobs {
if !m.removed && m.bounds().intersects(area) {
out.push(u64::from(m.id));
}
}
out
}
/// `ShulkerBoxBlock.canOpen`: a closed box opens only when its lid has
/// room to rise (`level.noCollision` of the half-open lid box).
pub(super) fn shulker_can_open(&self, pos: [i32; 3]) -> bool {
let state = self.world.block(pos[0], pos[1], pos[2]).0;
let Some(Ticker::Shulker { facing }) = self.be_anims.ticker(state) else {
return true;
};
let closed = self
.be_anims
.shulkers
.get(&pos)
.is_none_or(|s| s.status == ShulkerStatus::Closed);
if !closed {
return true;
}
let b = Shulker::lid_box(pos, facing);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
};
no_collision_empty(
&mut scene,
Aabb::new(Vec3::new(b[0], b[1], b[2]), Vec3::new(b[3], b[4], b[5])),
)
}
/// The client tickers of the loaded animated block entities, once per
/// client tick (`ClientLevel.tickBlockEntities`).
pub(super) fn tick_block_entity_anims(&mut self) {
let Some(r) = &self.renderer else {
return;
};
let present: HashMap<[i32; 3], Ticker> = r
.block_entities()
.filter_map(|(p, s)| Some((*p, self.be_anims.ticker(*s)?)))
.collect();
self.be_anims.retain_present(&present);
let players: Vec<[f64; 3]> = match &self.player {
Some(p) if !self.spectator() => vec![[p.position.x, p.position.y, p.position.z]],
_ => Vec::new(),
};
let raiders = self
.res
.stations
.enchantments
.entity_tags
.get("minecraft:raiders")
.cloned()
.unwrap_or_default();
let frames = self
.res
.items
.block_tags
.get("minecraft:conduit_effect_block")
.cloned()
.unwrap_or_default();
let mut positions: Vec<([i32; 3], Ticker)> = present.into_iter().collect();
positions.sort_unstable_by_key(|(p, _)| *p);
for (pos, ticker) in positions {
match ticker {
Ticker::Lid => self.be_anims.chests.entry(pos).or_default().tick(),
Ticker::Book => {
let anims = &mut self.be_anims;
anims
.books
.entry(pos)
.or_default()
.tick(pos, &players, &mut anims.book_random);
}
Ticker::Shulker { facing } => {
// The lid's push of entities in its way (moveCollidedEntities)
// and the neighbour updates are left to the level.
self.be_anims
.shulkers
.entry(pos)
.or_default()
.tick(pos, facing);
}
Ticker::Skull { powered } => {
self.be_anims.skulls.entry(pos).or_default().tick(powered);
}
Ticker::Bell => {
let player = self.player_view().map(|p| BellEntity {
position: [p.position.x, p.position.y, p.position.z],
alive: p.alive,
raider: false,
});
let mobs = &self.mobs;
let entity = |h: u64| {
if h == PLAYER {
return player;
}
let m = mobs.iter().find(|m| u64::from(m.id) == h)?;
Some(BellEntity {
position: [m.position.x, m.position.y, m.position.z],
alive: m.alive() && !m.removed,
raider: raiders.contains(&m.kind.id),
})
};
let tick = self
.be_anims
.bells
.entry(pos)
.or_default()
.tick(pos, entity);
let centre = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
if tick.resonate {
// The server's copy plays the resonance for everyone.
self.audio
.play("minecraft:block.bell.resonate", Some(centre), 1.0, 1.0);
}
if let Some((particles, glow)) = tick.ended {
for (p, color) in particles {
// ColorParticleOption.create(ENTITY_EFFECT, color): ARGB.
let c = color as u32;
let channel = |shift: u32| ((c >> shift) & 255) as f32 / 255.0;
let options = ParticleOptions {
color: [channel(16), channel(8), channel(0), channel(24)],
..ParticleOptions::simple(ParticleType::EntityEffect)
};
self.particles.add(&options, p, [0.0; 3]);
}
// makeRaidersGlow.
for m in self.mobs.iter_mut() {
if glow.contains(&u64::from(m.id)) {
m.add_effect("minecraft:glowing", 60, 0);
}
}
}
}
Ticker::Conduit => {
let world = &self.world;
let facts = &self.res.facts;
let registry = self.res.assets.registry;
let is_water = |p: [i32; 3]| {
facts.get(world.block(p[0], p[1], p[2])).fluid
== horizon_client::blocks::Fluid::Water
};
let is_frame = |p: [i32; 3]| {
registry
.state(world.block(p[0], p[1], p[2]))
.is_some_and(|s| frames.contains(&s.name))
};
let time = world.game_time;
let anims = &mut self.be_anims;
let particles = anims.conduits.entry(pos).or_default().client_tick(
pos,
time,
is_water,
is_frame,
None,
self.particles.random(),
);
let nautilus = ParticleOptions::simple(ParticleType::Nautilus);
for (p, v) in particles {
self.particles.add(&nautilus, p, v);
}
}
Ticker::Pot => {}
Ticker::Gateway => {
// The server copy's portalTick runs first; its block event
// reaches the client copy before the client ticks it.
let initial = self.gateway_loaded(pos);
let anims = &mut self.be_anims;
let server = anims.gateway_servers.entry(pos).or_insert(initial);
let triggered = server.portal_tick();
let client = anims.gateways.entry(pos).or_insert(initial);
if triggered {
client.trigger_event(1, 0);
}
client.tick();
}
}
}
}
/// `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 {
let initial = self.gateway_loaded(pos);
let entered = self
.be_anims
.gateway_servers
.entry(pos)
.or_insert(initial)
.enter();
if entered {
self.block_entity_event(pos, 1, 0);
}
entered
}
/// The End gateway at `pos` as its chunk was loaded: `Age` from the saved
/// block entity (`getUpdateTag`), or a new gateway.
fn gateway_loaded(&self, pos: [i32; 3]) -> EndGateway {
let age = self
.world
.chunks
.get(&(pos[0] >> 4, pos[2] >> 4))
.and_then(|c| c.saved.as_deref())
.and_then(|saved| {
saved.block_entities.iter().find(|t| {
[t.get("x"), t.get("y"), t.get("z")]
.map(number)
.eq(&pos.map(|v| Some(i64::from(v))))
})
})
.and_then(|t| number(t.get("Age")));
EndGateway {
age: age.unwrap_or(0),
teleport_cooldown: 0,
}
}
/// The renderer's view of the block entity at `pos`.
pub(super) fn block_anim(&self, pos: [i32; 3], partial: f32) -> BlockAnim {
self.be_anims
.block_anim(pos, self.world.game_time, partial, Default::default())
}
/// `ChestLidController.getOpenness(partial)` at `pos`.
pub(super) fn chest_lid(&self, pos: [i32; 3], partial: f32) -> f32 {
self.be_anims.lid(pos, partial)
}
}
+96 -17
View File
@@ -48,7 +48,12 @@ pub(super) struct Boat {
/// left, right, up, down
pub input: [bool; 4],
pub on_ground: bool,
/// `ChestBoat`: a chest with 27 slots in the back seat.
pub chest: bool,
pub items: Vec<Option<ItemStack>>,
}
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
@@ -76,8 +81,16 @@ impl Boat {
paddling: [false; 2],
input: [false; 4],
on_ground: false,
chest: false,
items: Vec::new(),
}
}
/// A chest boat or chest raft.
pub fn with_chest(mut self) -> Self {
self.chest = true;
self.items = vec![None; CHEST_SLOTS];
self
}
pub fn bounds(&self) -> Aabb {
let (w, p) = (WIDTH / 2.0, self.position);
Aabb::new(
@@ -95,6 +108,30 @@ impl Boat {
}
}
/// Item id -> (boat entity id, wood).
impl Boat {
/// The vanilla entity type id (`oak_boat`, `bamboo_chest_raft`), also
/// the item it drops.
pub(super) fn entity_type(&self) -> String {
let chest = if self.chest { "chest_" } else { "" };
if &*self.wood == "bamboo" {
format!("minecraft:bamboo_{chest}raft")
} else {
format!("minecraft:{}_{chest}boat", self.wood)
}
}
}
/// The wood of a boat or chest boat item or entity type, and whether it
/// carries a chest.
pub(super) fn any_boat_wood(item: &str) -> Option<(&str, bool)> {
let name = item.strip_prefix("minecraft:")?;
if let Some(wood) = name
.strip_suffix("_chest_boat")
.or_else(|| name.strip_suffix("_chest_raft"))
{
return Some((wood, true));
}
boat_wood(item).map(|w| (w, false))
}
pub(super) fn boat_wood(item: &str) -> Option<&str> {
let name = item.strip_prefix("minecraft:")?;
name.strip_suffix("_boat")
@@ -103,10 +140,20 @@ pub(super) fn boat_wood(item: &str) -> Option<&str> {
}
pub(super) struct BoatModels {
pub models: HashMap<String, Part>,
pub chest_models: HashMap<String, Part>,
}
impl BoatModels {
/// Whether a boat of this wood (with or without a chest) can be drawn.
pub fn has(&self, wood: &str, chest: bool) -> bool {
if chest {
self.chest_models.contains_key(wood)
} else {
self.models.contains_key(wood)
}
}
pub fn load(root: &std::path::Path) -> Self {
let mut models = HashMap::new();
let mut chest_models = HashMap::new();
if let Ok(bytes) = std::fs::read(root.join("entity-models.json"))
&& let Ok(serde_json::Value::Object(all)) =
serde_json::from_slice::<serde_json::Value>(&bytes)
@@ -119,9 +166,19 @@ impl BoatModels {
{
models.insert(wood.to_owned(), part);
}
if let Some(wood) = key
.strip_prefix("minecraft:chest_boat/")
.and_then(|k| k.strip_suffix("#main"))
&& let Some(part) = horizon_client::mobs::load_model(root, key)
{
chest_models.insert(wood.to_owned(), part);
}
}
}
Self { models }
Self {
models,
chest_models,
}
}
}
impl Game<'_> {
@@ -457,10 +514,10 @@ impl Game<'_> {
}
/// `BoatItem.use`: aim at a block or the water surface.
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some(wood) = boat_wood(&stack.item) else {
let Some((wood, chest)) = any_boat_wood(&stack.item) else {
return false;
};
if !self.boat_models.models.contains_key(wood) {
if !self.boat_models.has(wood, chest) {
return false;
}
let Some(eye) = self.eye_position() else {
@@ -507,12 +564,15 @@ impl Game<'_> {
(None, Some(w)) => w.0,
(None, None) => return false,
};
let boat = Boat::new(
let mut boat = Boat::new(
self.next_entity + 1,
Arc::from(wood),
Vec3::new(hit[0], hit[1], hit[2]),
self.yaw,
);
if chest {
boat = boat.with_chest();
}
let bb = boat.bounds();
let (lo, hi) = (bb.min_position(), bb.max_position());
let e = 1.0e-7;
@@ -575,11 +635,24 @@ impl Game<'_> {
return false;
};
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
if !self.before_block(d)
|| sneaking
|| self.boats[i].out_of_control >= 60.0
|| self.riding.is_some()
{
if !self.before_block(d) {
return false;
}
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
let b = &self.boats[i];
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
self.world.block_entities.insert(
key,
horizon_client::containers::BlockEntity::Chest(b.items.clone()),
);
self.container = Some(key);
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
return true;
}
if sneaking || self.boats[i].out_of_control >= 60.0 || self.riding.is_some() {
return false;
}
self.riding = Some(self.boats[i].id);
@@ -601,13 +674,13 @@ impl Game<'_> {
let destroyed = self.creative || b.damage > 40.0;
if destroyed {
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
if !self.creative && self.ui.rules.bool("entity_drops") {
let item = if &*b.wood == "bamboo" {
"minecraft:bamboo_raft".to_owned()
} else {
format!("minecraft:{}_boat", b.wood)
};
let at = [b.position.x, b.position.y, b.position.z];
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
for stack in b.items.iter().flatten() {
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
}
let item = b.entity_type();
self.spawn_item(
ItemStack {
item: Arc::from(item.as_str()),
@@ -628,14 +701,20 @@ impl Game<'_> {
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let a = alpha as f32;
for b in &self.boats {
let Some(model) = self.boat_models.models.get(&*b.wood) else {
let models = if b.chest {
&self.boat_models.chest_models
} else {
&self.boat_models.models
};
let Some(model) = models.get(&*b.wood) else {
continue;
};
let folder = if b.chest { "chest_boat" } else { "boat" };
let Some(uv) = self
.res
.atlas
.sprites
.get(&format!("entity/boat/{}", b.wood))
.get(&format!("entity/{folder}/{}", b.wood))
.map(|r| [r.u0, r.v0, r.u1, r.v1])
else {
continue;
+16 -59
View File
@@ -4,10 +4,9 @@ use super::{
commands::{R, err},
};
use horizon_client::{
bossbar::BossBars,
bossbar::{self, BossBars},
command::{Arg, Context},
hud::{HudBatch, rgba},
save,
hud_overlays, save,
scoreboard::{self as sb, Comp, comp_text},
scoreboard_cmd::format_list,
text::Text,
@@ -334,26 +333,10 @@ impl Game<'_> {
Ok(self.resolve_component(value))
}
/// How many boss bars the dragon and withers already show.
pub(super) fn builtin_bar_count(&self) -> usize {
let dragon = usize::from(self.dragon.is_some() && self.dim == super::Dim::End);
let withers = self.player.as_ref().map_or(0, |p| {
self.mobs
.iter()
.filter(|m| {
m.species() == horizon_client::mobs::Species::Wither
&& m.alive()
&& m.position.distance_squared(p.position) <= 64.0 * 64.0
})
.count()
});
dragon + withers
}
// ---- drawing ------------------------------------------------------------------
/// `BossHealthOverlay`: the visible custom bars under the built-in ones.
pub(super) fn draw_custom_bossbars(&self, batch: &mut HudBatch, mut y: f32) {
let atlas = &self.res.atlas;
/// The visible custom bars (`/bossbar`), after the built-in ones.
pub(super) fn custom_bossbars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
for b in self
.bosses
.bars
@@ -361,44 +344,18 @@ impl Game<'_> {
.iter()
.filter(|b| b.visible && b.player)
{
if y >= batch.height / 3.0 {
break;
}
let x = (batch.width / 2.0 - 91.0).floor();
let sprite = |kind: &str| format!("gui/sprites/boss_bar/{kind}");
batch.sprite(atlas, &sprite(&format!("{}_background", b.color)), x, y);
if b.progress() > 0.0 {
batch.sprite_part(
atlas,
&sprite(&format!("{}_progress", b.color)),
x,
y,
b.progress(),
);
}
if b.style != "progress" {
batch.sprite(atlas, &sprite(&format!("{}_background", b.style)), x, y);
if b.progress() > 0.0 {
batch.sprite_part(
atlas,
&sprite(&format!("{}_progress", b.style)),
x,
y,
b.progress(),
);
}
}
let name = comp_text(&b.name);
let w = atlas.font.width_text(&name);
batch.text_styled(
atlas,
&name,
(batch.width / 2.0 - w / 2.0).floor(),
y - 9.0,
rgba(255, 255, 255, 255),
true,
);
y += 19.0;
bars.push(hud_overlays::BossBar {
name: comp_text(&b.name),
progress: b.progress(),
color: bossbar::COLORS
.iter()
.position(|c| *c == b.color)
.unwrap_or(0),
overlay: bossbar::STYLES
.iter()
.position(|s| *s == b.style)
.unwrap_or(0),
});
}
}
}
+35 -11
View File
@@ -282,11 +282,22 @@ impl Game<'_> {
out.sort();
out
};
// The mob effect registry: every effect `/effect` and the
// `mob_effect` resource arguments accept.
reg.effects = horizon_client::brewing::EFFECTS
.iter()
.map(|e| format!("minecraft:{}", e.id))
.collect();
reg.effects.sort();
reg.enchantments = list("data/minecraft/enchantment");
reg.structures = list("data/minecraft/worldgen/structure");
reg.biomes = list("data/minecraft/worldgen/biome");
reg.clocks = list("data/minecraft/world_clock");
reg.dimensions = vec!["minecraft:overworld".into(), "minecraft:the_nether".into()];
reg.dimensions = vec![
"minecraft:overworld".into(),
"minecraft:the_nether".into(),
"minecraft:the_end".into(),
];
reg.players = vec![PLAYER_NAME.into()];
// Timelines of the overworld clock and their time markers.
self.ui.markers.clear();
@@ -324,6 +335,15 @@ impl Game<'_> {
);
}
}
// Vanilla's built-in registries (mob effects, components, dialogs...).
for dir in [&self.options.resources, &self.ui.vanilla_root] {
if let Ok(bytes) = std::fs::read(dir.join("command-registries.json"))
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
{
self.ui.registries.fill_from_export(&v);
break;
}
}
let reg = &mut self.ui.registries;
reg.timelines = self.ui.timelines.keys().cloned().collect();
reg.time_markers = self
@@ -368,7 +388,7 @@ impl Game<'_> {
/// 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() {
capture.push(text.plain());
capture.push(text);
return;
}
let focused = self.screen == Screen::Chat;
@@ -397,7 +417,7 @@ impl Game<'_> {
return;
};
let atlas = &self.res.atlas;
let d = vanilla::dispatcher();
let d = vanilla::game_dispatcher();
if screen.suggestion_key(atlas, d, &self.ui.registries, key, shift) {
self.ui.chat_screen = Some(screen);
return;
@@ -511,11 +531,11 @@ impl Game<'_> {
return;
};
screen.resize(batch.width, batch.height);
let bg = self.ui.chat.options.background_opacity;
let bg = self.ui.chat.options.background_opacity as f32;
screen.draw(
batch,
&self.res.atlas,
vanilla::dispatcher(),
vanilla::game_dispatcher(),
&self.ui.registries,
self.cursor,
bg,
@@ -655,17 +675,21 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return;
};
let below_world = p.position.y < f64::from(horizon_client::chunk::MIN_Y - 64);
let name = self.player_display_text();
let message = match cause {
Some(key) => Text::translate(&key, &[Text::literal(PLAYER_NAME)]),
None if p.position.y < f64::from(horizon_client::chunk::MIN_Y - 64) => {
Text::translate("death.attack.outOfWorld", &[Text::literal(PLAYER_NAME)])
}
Some(key) => Text::translate(&key, &[name]),
None if below_world => Text::translate("death.attack.outOfWorld", &[name]),
None => self.combat_death_message(),
};
self.ui.death_text = Some(message.clone());
self.system_message(message);
// ServerPlayer.die: a team that hides death messages keeps it out of
// the dying player's own chat (the death screen still shows it).
if horizon_client::scoreboard::own_death_message_shown(self.player_team()) {
self.system_message(message);
}
}
/// F1 hides the HUD (and hand); the screenshot key still works.
/// F1 hides the HUD (see `hud`); the screenshot key still works.
pub(super) fn toggle_hide_gui(&mut self) {
self.ui.hide_gui = !self.ui.hide_gui;
}
+3
View File
@@ -25,6 +25,9 @@ pub(super) struct CombatTracker {
pub(super) last_hurt_mob: Option<(u32, i64)>,
/// The mob whose projectile is hitting the player now.
pub(super) projectile_owner: Option<u32>,
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
/// The player's fall distance before this tick's movement (as of the
/// end of the last one).
fall_before: f64,
@@ -0,0 +1,137 @@
//! 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
//! 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`
//! waits until the terrain around the player is loaded.
use super::Game;
use horizon_client::{Result, text::Text};
use serde_json::{Value, json};
use std::{
path::Path,
time::{Duration, Instant},
};
/// A message as `[text, style]` runs with adjacent equal styles merged; the
/// style lists colour, bold, italic, underlined, strikethrough, obfuscated
/// and the suggested command.
pub(super) fn spans(text: &Text) -> Value {
let mut out: Vec<(String, Value)> = Vec::new();
for span in &text.spans {
if span.text.is_empty() {
continue;
}
let s = &span.style;
let style = json!([
s.color,
s.bold == Some(true),
s.italic == Some(true),
s.underlined == Some(true),
s.strikethrough == Some(true),
s.obfuscated == Some(true),
]);
match out.last_mut() {
Some((t, last)) if *last == style => t.push_str(&span.text),
_ => out.push((span.text.clone(), style)),
}
}
Value::Array(out.into_iter().map(|(t, s)| json!([t, s])).collect())
}
impl Game<'_> {
/// 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))) {
return Ok(());
}
}
if std::env::var_os("HORIZON_HARNESS_DEBUG").is_some() {
eprintln!(
"settle: player {:?} center {:?} chunks {} pending {}",
self.player.as_ref().map(|p| p.position),
self.streaming.center,
self.world.chunks.len(),
self.streaming.pending()
);
}
if Instant::now() > deadline {
return Err("command harness: terrain did not load".into());
}
std::thread::sleep(Duration::from_millis(2));
}
}
pub(super) fn run_command_harness(&mut self, script: &Path, output: &Path) -> Result<()> {
let deadline = Instant::now()
+ Duration::from_secs(
std::env::var("HORIZON_HARNESS_TIMEOUT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(600),
);
self.harness_settle(deadline)?;
let mut out = String::new();
for line in std::fs::read_to_string(script)?.lines() {
if line.trim().is_empty() {
continue;
}
let op: Value = serde_json::from_str(line)?;
match op["op"].as_str().unwrap_or("") {
"cmd" => {
let command = op["cmd"].as_str().unwrap_or("");
self.chat_capture = Some(Vec::new());
self.cmds.results = Some(Vec::new());
self.run_command(command.strip_prefix('/').unwrap_or(command));
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) {
let record = json!({
"cmd": command,
"messages": messages.iter().map(spans).collect::<Vec<_>>(),
"results": results.iter().map(|(s, v)| json!([s, v])).collect::<Vec<_>>(),
});
out.push_str(&record.to_string());
out.push('\n');
}
}
"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;
}
}
let messages = self.chat_capture.take().unwrap_or_default();
if op["record"].as_bool() == Some(true) {
let record = json!({
"cmd": "#tick",
"messages": messages.iter().map(spans).collect::<Vec<_>>(),
"results": [],
});
out.push_str(&record.to_string());
out.push('\n');
}
}
"settle" => self.harness_settle(deadline)?,
other => return Err(format!("command harness: unknown op {other}").into()),
}
}
std::fs::write(output, out)?;
Ok(())
}
}
Loaded 100 of 453 files, more files were not shown because too many files have changed in this diff. Show more