Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 7311870047 Pages: inactive, half and footer buttons, text rows and space (spec #14167)
Generic row options a page drawn as its own screen needs (Play with Friends'
page, #14167 step 2a): Row::button(..).active(false)/.half()/.footer(),
Row::text(..).quiet()/.groups(), Row::Space. The options shell shows text as
notes, pairs half buttons and ignores space; Extras shows active buttons only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:55:32 +01:00
Ludovic Marie 12578352dc Merge pull request #14203 from Evan-lhm/rt/ray-query-opt-in
🤖 Agent: EXPERIMENTAL_RAY_QUERY is requested only with HORIZON_RAY_TRACING=1 in a horizon-extras build; without it the device request is main's.
2026-11-09 20:41:33 +01:00
Ludovic Marie 11ebc3f199 Merge pull request #13960 from horizon-rust/layers/packs-emissive
🤖 Agent: the emissive suffix is read once per pack load through PackFormats::emissive_suffix; atlas building only compares a stored string per sprite as before. layer plan M6 step 3.
2026-11-09 20:41:27 +01:00
Ludovic Marie bc08885276 Merge pull request #14142 from MSedgeMC/fix/shulker-face-bytes
🤖 Agent: AttachFace wraps through from3DDataValue and Peek/Color stay raw bytes; texture() reads the colour signed, so -1 draws white as DyeColor.byId's ZERO strategy does.
2026-11-09 20:37:15 +01:00
Ludovic Marie bc7b8889de Merge pull request #14126 from Joansitoh/fix/9965-warden-save
🤖 Agent: the LAN owner's warden tracker now goes into g.saved before playerdata::store rewrites player_extra from it, so warning level and cooldown survive a save; with_warden keeps lan.rs (over 3000 lines) from growing.
2026-11-09 20:37:11 +01:00
Ludovic Marie 7180a52bf2 Merge pull request #14014 from oloion/fix/elytra-wall-sound-repeat
🤖 Agent: handleFallFlyingCollisions now compares lastSpeed (before updateFallFlyingMovement) with the delta Entity.move leaves, collided axes zeroed, so a glider pressed on a wall stops replaying the crash sound.
2026-11-09 20:37:05 +01:00
Ludovic Marie 8e37c4a4b7 Merge pull request #14215 from DUzzL/fix/14176-lod-mesh-samples
🤖 Agent: bring_back now calls sync_sample for a returning kept tile, so check_mesh_samples sees it.
2026-11-09 20:32:42 +01:00
Ludovic Marie 94ba80f90d Merge pull request #14074 from DUzzL/fix/14034-glass-data-move
🤖 Agent: the glass About card now builds location_row (Move…, confirm, retry/default), and the row keeps the Open button beside Move (agent fix eb3157d; needs the launcher job green: no local launcher build).
2026-11-09 20:32:38 +01:00
Ludovic Marie 238fc99add Merge pull request #14129 from NikitaMGrimm/hdr-windows-calibrate
🤖 Agent: only os_reported (Windows) sets peak_guessed, so Wayland and macOS keep their labels; main merged, the docs paragraph re-applied on main's text, and main's ui_nits test given the field.
2026-11-09 20:32:33 +01:00
Ludovic Marie ac96d3fa35 Merge pull request #13959 from horizon-rust/layers/packs-colors
🤖 Agent: pack colours (OptiFine color.properties, Polytone colors) are now read through pack_hooks::PackFormats once per pack load; the engine keeps a plain PackColors table, so per-block tint lookups stay table reads. layer plan M6 step 2. Merging main moved its new Polytone damage-bar colours behind PackFormats::item_bars, test moved with them.
2026-11-09 20:26:44 +01:00
Ludovic Marie ceff25fca1 Merge pull request #14188 from vnsmoreira/packs/polytone-post-depth
🤖 Agent: the world depth is kept whenever post effects run (use_depth_buffer reads it), only in that case; move_off_page moved out unchanged so gfx.rs doesn't grow.
2026-11-09 20:23:18 +01:00
Ludovic Marie 36d0695742 Merge pull request #14169 from MSedgeMC/port_map/trapdoor-and-dismount
🤖 Agent: both rows match main: Mob::on_climbable has the trapdoor-over-ladder rule and Game::mount_events posts entity_dismount.
2026-11-09 20:23:14 +01:00
Ludovic Marie 94484675a8 Merge pull request #14044 from Ragbie/refactor/minecarts-split
🤖 Agent: checked line by line: every removed line reappears in minecarts/nbt.rs or minecarts/index.rs (pure move, exempt from the 500-line count); the new head adds only main merges (same tree as the reviewed head merged with main).
2026-11-09 20:23:10 +01:00
Ludovic Marie 9097d2973d Merge pull request #13921 from MSedgeMC/port_map/bow-release-file
🤖 Agent: confirmed on main: the player's bow is Game::release_bow at game/use_hand.rs:599; game.rs no longer has it.
2026-11-09 20:23:06 +01:00
Ludovic Marie 00e573cf87 Merge pull request #13911 from Skr1zzzz/split-menu-clicks
🤖 Agent: pure move; reset_inventory_clicks and player_menu_sound have no caller outside game/, so pub(super) from menu_clicks.rs reaches them all.
2026-11-09 20:23:02 +01:00
Evan-lhm 3f1a50ccb2 Merge remote-tracking branch 'origin/main' into rt/ray-query-opt-in
# Conflicts:
#	crates/horizon-client/Cargo.toml
2026-11-09 19:25:47 +01:00
Horizon agent 3dcfd7276b Merge remote-tracking branch 'rv3n/14142' into rv3-fix-14142 2026-11-09 19:21:20 +01:00
Horizon agentandClaude Opus 5.5 bfbf06a439 Fall flying wall hit: compare the speed before updateFallFlyingMovement
LivingEntity.travelFallFlying takes lastSpeed before the glide update, as
move_h does; the post-update speed left move_h unused (verify failed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:19:28 +01:00
Evan-lhmandClaude Opus 5.5 75071c8b9b gfx.rs may not grow: ray_query is declared beside its one user
Its `mod` line moves from gfx.rs (over 3000 lines, docs/layers.md#file-size) into
surface_setup.rs, the device request that reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:37 +01:00
DUzzL 971f341977 Keep lod.rs from growing 2026-11-09 19:17:51 +01:00
Evan-lhmandClaude Opus 5.5 63a6a92bef Ray tracing on request is a Horizon extra (#13780)
As ludovic111 asked on #13780: asking the device for ray queries, and the log line
saying why it is on or off, happen only in a `horizon-extras` build
(horizon-client's feature now enables horizon-client-render's). A plain build
requests the device it requested before, whatever HORIZON_RAY_TRACING says
(`only_an_extras_build_asks_for_ray_tracing`, run with and without the feature).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:07:35 +01:00
Evan-lhmandClaude Opus 5.5 cf93d0dd3a Renderer: ask for hardware ray queries only with HORIZON_RAY_TRACING=1 (#13780)
Step 1 of #13780 (carrying on the #1180 spike): the device requests wgpu's
EXPERIMENTAL_RAY_QUERY when the variable is set and the adapter has it, and
logs why it stays off otherwise. Nothing draws with it yet; without the
variable the device opens exactly as before. The unsafe opt-in lives in
horizon-hdr, which already holds the GPU calls the game crates can't make.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:03:49 +01:00
DUzzL 21aabee4c0 Sync far-terrain mesh samples when kept tiles return 2026-11-09 19:00:58 +01:00
MSedgeMC 7be13f1e9a Merge upstream/main 2026-11-09 18:00:20 +00:00
Horizon agentandClaude Opus 5.5 ff83eccf6a AttachFace: unsigned_abs on the remainder, as clippy asks, within the line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:59:17 +01:00
Horizon agentandClaude Opus 5.5 e39ec11e59 Merge branch 'layers/packs-colors' into layers/packs-emissive
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:56:37 +01:00
Horizon agentandClaude Opus 5.5 b5f396461e Merge origin/main into layers/packs-colors
Main's Polytone item bar colours (#fb354374e3) move behind the new
PackFormats::item_bars hook (glue pack_formats/item_bars.rs, test moved with
them), and environment.horizon_height joins the engine's Environment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:55:39 +01:00
Horizon agentandClaude Opus 5.5 e963d24058 Give main's new UI-nits test its peak_guessed field
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:54:18 +01:00
Horizon agentandClaude Opus 5.5 3eec96a8b1 Format the shulker load and its test; abs() keeps the line within entity_nbt.rs's size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:54:15 +01:00
Horizon agentandClaude Opus 5.5 249e8db13c Keep lan.rs from growing: with_warden takes the tracker and gives the guest back
lan.rs is over 3000 lines and may not grow (tools/check_file_size.py): the
tracker now goes in on the line that picks the owner's guest, and the
comment moves to playerdata::with_warden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:53:53 +01:00
Evrim Cicek c992f24496 Merge remote-tracking branch 'origin/refactor/minecarts-split' into refactor/minecarts-split 2026-11-09 20:49:53 +03:00
Evrim Cicek 005249b984 Merge remote-tracking branch 'upstream/main' into refactor/minecarts-split
# Conflicts:
#	crates/horizon-client/src/game/minecarts.rs
2026-11-09 20:48:40 +03:00
Horizon agentandClaude Opus 5.5 eb3157d251 Launcher: the Data folder row keeps its Open button beside Move
The glass About card's row had Open; building location_row there dropped
it. location_row's action is now Open and Move side by side (only the
glass page builds it: the old settings page is unreachable).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:44:54 +01:00
MSedgeMC d577d49a78 port_map: the trapdoor ladder and the dismount event rows are ports
`Mob::on_climbable` takes an open trapdoor over a matching ladder, as
`climbing::on_climbable` does for the player, and `Game::mount_events`
posts `entity_dismount` for players and mobs alike. Both rows still
described the old gaps.
2026-11-09 17:44:34 +00:00
Horizon agent 8a9bd15a3d Merge remote-tracking branch 'origin/main' into rv3-fix-14129
# Conflicts:
#	docs/graphics.md
2026-11-09 18:43:16 +01:00
Horizon agentandClaude Opus 5.5 a40f3d82c1 A shulker with a negative colour byte draws white, as DyeColor.byId does
Shulker.getColor treats the byte as signed: 16 and above are undyed, and a
negative id reaches DyeColor.byId, whose OutOfBoundsStrategy.ZERO gives
white. Keeping the raw byte (255 for -1) sent it to the undyed texture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:42:45 +01:00
Vinícius MoreiraandClaude Opus 5.5 af3c22b9f1 Post effects: a pack's post chain reads the world's depth
The world pass discarded its depth unless a shader pack, TAA, OIT or a
native mod read it, so a resource pack's post chain that samples
minecraft:main with use_depth_buffer read cleared depth (0, the sky)
everywhere. The freepack's sun glare tests the sky around the sun in
that depth, so it shone through hills and walls. The depth is now kept
whenever post effects run, as GameRenderer hands them the main target.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:39:31 -03:00
Vinícius MoreiraandClaude Opus 5.5 182eec2cb7 gfx: move_off_page moves to gfx/off_page.rs
A pure move that makes room for the next commit: `gfx.rs` is over 3000 lines and
may not grow (docs/layers.md "File size"). `move_off_page` leaves it unchanged, imported back
under the same path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:39:30 -03:00
MSedgeMC 59d958c09e port_map: the shulker's save reads wrap and narrow like vanilla
`tools/port_map/game2-002.tsv` said `Shulker.readAdditionalSaveData` reads
`AttachFace`, `Peek` and `Color`; the row now names the codec's wrapping and
the two raw bytes the port keeps.
2026-11-09 17:22:12 +00:00
MSedgeMC 1a2015e305 test: a shulker's saved face and lid bytes
`mob_from_nbt` is what `Game::load_mob` goes through. A shulker with
`AttachFace: 7b`, `Peek: -1b` and `Color: -1b` loads with `variant3 == 1`
(`abs(7 % 6)` is up), `counter == -1` and `color == 255`.

Without the coercion they load clamped: `left: 5`, `right: 1` for the face,
`left: 0`, `right: -1` for the peek and `left: 16`, `right: 255` for the
colour.
2026-11-09 17:22:12 +00:00
MSedgeMC 197172ef3c fix: a shulker reads its saved face and lid bytes as vanilla does
`Shulker.readAdditionalSaveData` reads three fields with different rules:

- `AttachFace` is `Direction.LEGACY_ID_CODEC`, whose `from3DDataValue` wraps
  the byte through `abs(id % 6)`;
- `Peek` is `getByteOr("Peek", 0)`, a raw byte put straight on the synched
  data;
- `Color` is `getByteOr("Color", 16)`, a raw byte too.

Horizon clamped all three to the setters' ranges (`0..=5`, `0..=100`,
`0..=16`): an `AttachFace` of 7 became east (5) instead of up (1), a negative
`Peek` became 0 instead of the byte vanilla stores, and a negative `Color`
became 0 instead of 255.

The load now wraps the face and narrows `Peek` and `Color` to bytes, exactly
as the codec and `getByteOr` do.
2026-11-09 17:22:12 +00:00
JoansitohandClaude Sonnet 5.5 9d6d292348 Keep the owner's warden spawn tracker through a save
store_owner wrote the tracker into level.player_extra, then playerdata::store
replaced that with the guest's loaded snapshot, losing the warning level and
cooldown. Put the tracker into the owner guest's saved compound first.

Part of #9965

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 18:18:08 +01:00
rdbtCVSandClaude Sonnet 5.5 358456bb02 Merge main into refactor/minecarts-split
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 16:52:32 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 aa741b3570 HDR settings: on Windows, the HDR page asks to calibrate
On Windows, Auto takes the display's peak from DXGI's MaxLuminance, which is
mostly the display's EDID figure and often runs high. The HDR page still said
the display was already set up ("Calibrate Anyway..."), though calibration's
Peak step works there, since PQ passes through unchanged.

Now the Windows reading marks its peak as a guess (`Reported::peak_guessed`),
and the page's Calibrate row reads "Calibrate..." and says the reported peak is
often too high, while Peak Brightness and Paper White stay on Auto. Auto still
uses the reported values, which beat the 1000-nit default. Once the player has
calibrated or set either value, the row offers "Use Automatic Setup" as before.
There the Peak Brightness row no longer says it is set up for the display:
its Auto is the guess. Paper White, the player's own slider, still does.
Compositor reports (Wayland) and macOS, which reports no nits, are unchanged.

The setup decision moves next to `Reported` (`Graphics::hdr_setup`), out of
graphics_ui.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:47:16 +01:00
Horizon agent 9fd16efb45 Merge branch 'layers/packs-colors' into layers/packs-emissive 2026-11-09 17:34:46 +01:00
Horizon agent 34b5df1397 Merge branch 'layers/packs-sky' into layers/packs-colors
# Conflicts:
#	crates/horizon-client-render/src/lib.rs
#	crates/horizon-client/src/client_lib.rs
2026-11-09 17:34:35 +01:00
DUzzL 72c6784172 Launcher: give the glass Settings Data folder row the Move button 2026-11-09 17:21:46 +01:00
Evrim CicekandClaude Opus 5.5 443a42f97a Move the minecart's save/load and cart index out of minecarts.rs
A pure move, for the file-size rule: `impl Minecart`'s to_nbt/load go to
game/minecarts/nbt.rs, `CartIndex` and `cart_at` to game/minecarts/index.rs.
Only visibility changes so minecarts.rs still reaches them. minecarts.rs
goes from 3230 counted lines to 2967.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:56:04 +03:00
Ludovic Marie 93ecc8d0c1 Merge branch 'layers/packs-colors' into layers/packs-emissive 2026-11-09 16:46:39 +01:00
Horizon agentandClaude Opus 5.5 230b167744 Keep PackColors::entity_shadows a method, as the shadow ownership check reads it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:36 +01:00
Horizon agentandClaude Opus 5.5 2e45a0750d rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:26 +01:00
Horizon agent 8ad2c758ad Merge branch 'layers/packs-colors' into layers/packs-emissive 2026-11-09 16:27:26 +01:00
Horizon agentandClaude Opus 5.5 c71978372d rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:24 +01:00
Horizon agentandClaude Opus 5.5 cbba6ead3a Emissive overlays' suffix through the pack-format hook
layer plan M6 (pack formats), step 3: the terrain's emissive overlays take
the pack's suffix from pack_hooks (OptiFine's emissive.properties, _e by
default); with no pack formats installed a pack has none. atlas_sources
checks a texture path stays in the pack root itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:39:02 +01:00
Horizon agentandClaude Opus 5.5 2a44a86383 Pack colours through the engine's pack-format hook
layer plan M6 (pack formats), step 2: the colours a pack replaces (dyes,
sheep, redstone, effects, the experience number, entity shadows, the
effect fogs) are an engine table (pack_palette::PackColors) that
Polytone's colors.json fills through pack_hooks; the polytone_colors
re-export goes away.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:37:31 +01:00
MSedgeMC de9ab4646d port_map: the bow shooter is release_bow in game/use_hand.rs
The row's note still said the bow was fired from game.rs. release_bow is in
game/use_hand.rs after the hand split.
2026-11-09 14:35:58 +00:00
Skr1zzzzandClaude Opus 5.5 2d2a2621f2 Move resetting inventory clicks and menu click sounds out of game.rs into game/menu_clicks.rs
Part of #11857. reset_inventory_clicks and player_menu_sound join game/menu_clicks.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:34 +03:00
Oloion e0b91d71bf Compare pre- and post-collision delta movement in fall flying
LivingEntity.handleFallFlyingCollisions in vanilla 26.3 compares the
horizontal movement vector passed to move() with the delta movement left
after move() (where restitution zeroes out colliding axes), not the
position delta travelled vs initial velocity.

Using post-move velocity prevents the crash sound from repeating on
subsequent ticks while pressed against a wall, while still playing the
single impact sound in both survival and creative mode.

Fixes #12304
2026-11-09 16:41:16 +03:00
48 changed files with 1389 additions and 671 deletions

No files matched your search

+4
View File
@@ -76,6 +76,10 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
# the variable.
horizon-extras = []
[lints]
workspace = true
+2 -2
View File
@@ -382,8 +382,8 @@ impl RenderAssets {
};
// After the connected tiles: a tile identical to a block shares its layer, and the
// overlays skip shared layers.
if draws {
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
}
sprites.save_disk_cache();
if draws {
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
}
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
let rel = format!("assets/{namespace}/{directory}/{path}.png");
root.join(if horizon_optifine::paths::valid(&rel) {
&rel
} else {
NOWHERE
})
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
let stays = rel
.split('/')
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
root.join(if stays { &rel } else { NOWHERE })
}
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
@@ -112,7 +112,11 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
};
Row::new(id, lang::get(label), value)
}
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
added::Row::Button {
label,
active: true,
..
} => Row::new(id, lang::get(label), Value::Press),
added::Row::Key { label, key } => {
let key = crate::keybinds::display_name(&key).plain();
Row::new(id, lang::get(label), Value::Read(key))
@@ -490,9 +494,7 @@ mod tests {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Button {
label: "test.elsewhere.button",
});
rows.push(added::Row::button("test.elsewhere.button"));
}
fn act(&self, _: usize, _: Act) {}
}
+4 -4
View File
@@ -231,8 +231,8 @@ pub struct FogInputs<'a> {
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
/// The packs' Polytone `environment` fog settings.
pub environment: horizon_polytone::colors::Environment,
/// The packs' `environment` fog settings (Polytone's).
pub environment: crate::pack_palette::Environment,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -581,7 +581,7 @@ mod tests {
let tables = FogTables {
water_fog_end: vec![1.0],
};
let environment = horizon_polytone::colors::Environment {
let environment = crate::pack_palette::Environment {
powder_snow_fog: Some(0xFF0000),
blindness_fog_distance: 40.0,
water_fog_brightening: 0.0,
@@ -613,7 +613,7 @@ mod tests {
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: horizon_polytone::colors::Environment,
environment: crate::pack_palette::Environment,
) -> FogInputs<'a> {
FogInputs {
environment,
+14 -61
View File
@@ -239,6 +239,7 @@ mod reuse;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
mod off_page;
pub mod submit;
mod sunrise_fan;
mod timing;
@@ -250,6 +251,7 @@ mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
mod world_warp;
use off_page::move_off_page;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -726,62 +728,6 @@ impl Part {
}
}
}
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
/// Starts moving a page of a large arena far larger than its ranges in use
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
@@ -10116,7 +10062,11 @@ impl Renderer {
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
// after the world pass, as `GameRenderer.render` hands them the main target's.
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
let keep_depth = pack_reads_world
|| post_reads_depth
|| (taa && self.world_post.taa)
|| oit_on
|| native_render::render_needs().world_depth
@@ -13714,6 +13664,7 @@ mod tests {
Some(Reported {
paper_white: 240,
peak: 998,
peak_guessed: true,
})
);
assert_eq!(
@@ -13721,6 +13672,7 @@ mod tests {
Some(Reported {
paper_white: 480,
peak: 480,
peak_guessed: true,
})
);
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
@@ -13729,6 +13681,7 @@ mod tests {
Some(Reported {
paper_white: 456,
peak: 456,
peak_guessed: true,
})
);
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
@@ -15419,11 +15372,9 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
});
}
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
/// white level the brightness slider sets), which is HDR output's scale there: SDR
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
/// the settings' ranges. The OS's figures often come from the display's EDID and may
/// run optimistic; the player's own value always wins.
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
let luminance = info.luminance?;
let usable = |n: f32| n.is_finite() && n > 0.0;
@@ -15435,6 +15386,7 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
// Windows' SDR brightness slider can set white above the reported peak (480 against
// a calibrated 380): the highlights then stay at white instead of dropping below it.
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
peak_guessed: true,
})
}
@@ -15470,6 +15422,7 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
peak_guessed: false,
}
}
@@ -0,0 +1,59 @@
//! `move_off_page`, moved out of `gfx.rs` unchanged.
use super::*;
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
pub(super) fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
@@ -300,6 +300,7 @@ pub(crate) mod tests {
luminance: Some(Reported {
paper_white: 400,
peak: 1000,
peak_guessed: false,
}),
hdr: system,
},
@@ -650,6 +651,7 @@ pub(crate) mod tests {
window_reading.luminance = Some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
});
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
assert_eq!(
@@ -549,6 +549,7 @@ mod tests {
reading.luminance = Some(Reported {
paper_white: 250,
peak: 600,
peak_guessed: false,
});
// 150 nits under the reported paper white of 250: 0.6 of it.
settings.hdr_ui_nits = 150;
@@ -584,6 +585,7 @@ mod tests {
reading.luminance = Some(Reported {
paper_white,
peak: 1000,
peak_guessed: false,
});
let next = candidate(&settings, caps, reading, &published);
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
@@ -729,6 +731,7 @@ mod tests {
luminance: (hdr == Some(true)).then_some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
}),
hdr,
};
@@ -0,0 +1,79 @@
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
//! extra, docs/layers.md), the device is opened exactly as before.
/// The ray query feature.
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
fn requested() -> bool {
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
}
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
/// build.
fn asked(var: Option<&str>) -> bool {
cfg!(feature = "horizon-extras") && var == Some("1")
}
/// The features to request on `adapter`: ray queries, when asked for and offered.
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
let features = pick(requested(), adapter.features());
if requested() {
let info = adapter.get_info();
if features.is_empty() {
log::warn!(
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
queries; it stays off",
info.name,
info.backend
);
} else {
log::info!(
"Horizon renderer: ray queries on ({} on {:?})",
info.name,
info.backend
);
}
}
features
}
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
if requested {
offered & RAY_QUERY
} else {
wgpu::Features::empty()
}
}
/// The device's opt-in to `features`' experimental ones.
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
if features.contains(RAY_QUERY) {
horizon_hdr::experimental_features()
} else {
wgpu::ExperimentalFeatures::disabled()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_an_extras_build_asks_for_ray_tracing() {
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
assert!(!asked(None));
assert!(!asked(Some("0")));
}
#[test]
fn ray_queries_only_when_asked_and_offered() {
let all = wgpu::Features::all();
assert_eq!(pick(true, all), RAY_QUERY);
assert!(pick(false, all).is_empty());
assert!(pick(true, all - RAY_QUERY).is_empty());
assert!(!experimental(wgpu::Features::empty()).is_enabled());
}
}
@@ -5,6 +5,9 @@ use super::{
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
timing,
};
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
#[path = "ray_query.rs"]
mod ray_query;
use std::sync::Arc;
use winit::window::Window;
@@ -134,6 +137,7 @@ pub(super) async fn open_adapter(
let features = timing::GpuTimer::features(adapter)
| cull::Cull::features(adapter)
| oit::features(adapter)
| ray_query::features(adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
@@ -166,6 +170,7 @@ pub(super) async fn open_adapter(
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
memory_hints: wgpu::MemoryHints::MemoryUsage,
experimental_features: ray_query::experimental(features),
..Default::default()
};
#[cfg(not(target_os = "macos"))]
@@ -30,6 +30,22 @@ fn marker_driver(text: &str) -> &str {
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
pub peak_guessed: bool,
}
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrSetup {
/// The system says nothing: the player calibrates.
Unknown,
/// The system says, but only guesses the peak: the player calibrates.
Guessed,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
@@ -1207,6 +1223,15 @@ impl Graphics {
n => n,
}
}
/// How HDR is set up for the display the system `reported`.
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
match reported {
None => HdrSetup::Unknown,
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
Some(r) if r.peak_guessed => HdrSetup::Guessed,
Some(_) => HdrSetup::Automatic,
}
}
/// The black level, in nits. Not the system's: files from before saved 0 for that
/// (Auto), and a reported black would lift every display's shadows by default.
pub fn black_level_nits(&self) -> f32 {
@@ -1320,6 +1345,7 @@ mod tests {
let reported = Some(Reported {
paper_white: 300,
peak: 1000,
peak_guessed: false,
});
assert_eq!(g.ui_nits(reported), 300);
g.hdr_paper_white = 120;
@@ -1328,6 +1354,33 @@ mod tests {
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
}
#[test]
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
let wayland = Reported {
paper_white: 203,
peak: 1015,
peak_guessed: false,
};
let windows = Reported {
paper_white: 400,
peak: 1000,
peak_guessed: true,
};
let mut g = Graphics::default();
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
// Auto still takes the guess, over the 1000-nit default.
assert_eq!(g.peak_nits(Some(windows)), 1000);
assert_eq!(g.paper_white_nits(Some(windows)), 400);
for (peak, paper_white) in [(800, 0), (0, 240)] {
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
}
}
#[test]
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
+11 -23
View File
@@ -8,7 +8,8 @@ use crate::{
gfx::EffectiveOutput,
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
Preset, Upscaling,
},
graphics_style::Cost,
lang,
@@ -675,28 +676,9 @@ const LOD_STEPS: [u32; 24] = [
crate::lod::MAX_CHUNKS,
];
/// Whether the system set HDR up for the display, and whether the player kept it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Setup {
/// The system says nothing: the player calibrates.
Unknown,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
setup_of(&s.horizon, out.reported.is_some())
}
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
if !reported {
Setup::Unknown
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
Setup::Automatic
} else {
Setup::Changed
}
s.horizon.hdr_setup(out.reported)
}
/// What the HDR page says while HDR output is held back on this window's graphics driver
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
@@ -714,13 +696,19 @@ fn description(
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
"Your display's reported peak is often too high: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
"Already set up for your display. Only calibrate if HDR looks wrong."
}
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
{
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
@@ -944,7 +932,7 @@ impl H {
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", g.hdr.label()),
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
Setup::Unknown => "Calibrate...",
Setup::Unknown | Setup::Guessed => "Calibrate...",
Setup::Automatic => "Calibrate Anyway...",
Setup::Changed => "Use Automatic Setup",
})),
@@ -120,14 +120,86 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
}
/// The setup rows' label and description for each kind of report: none, a compositor's
/// (Wayland), and Windows', whose peak is the display's own guess.
#[test]
fn hdr_setup_follows_what_the_system_reported() {
let mut g = crate::graphics::Graphics::default();
assert_eq!(setup_of(&g, false), Setup::Unknown);
assert_eq!(setup_of(&g, true), Setup::Automatic);
g.hdr_paper_white = 80;
assert_eq!(setup_of(&g, true), Setup::Changed);
assert_eq!(setup_of(&g, false), Setup::Unknown);
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
let trusted = offering(true, Some(true));
let mut guessed = trusted;
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
peak_guessed: true,
..r
});
let rows = |s: &Settings, out: &EffectiveOutput| {
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
let item = Item::Horizon(h);
(item.label(s, out).plain(), description(item, s, out, "-"))
})
};
let calibrate = "Calibrate...";
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let none = (
calibrate.into(),
"Your display didn't say how bright it gets: calibrate to set it up.",
);
assert_eq!(
rows(&s, &NONE),
[
none.clone(),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (203)".into(), "-"),
]
);
assert_eq!(
rows(&s, &guessed),
[
(
calibrate.into(),
"Your display's reported peak is often too high: calibrate to set it up."
),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (400)".into(), "-"),
]
);
assert_eq!(
rows(&s, &trusted),
[
(
"Calibrate Anyway...".into(),
"Already set up for your display. Only calibrate if HDR looks wrong."
),
("Peak Brightness: Auto (1000)".into(), set_up),
("Paper White: Auto (400)".into(), set_up),
]
);
// Calibrated or set by hand, either report offers its automatic setup back.
s.horizon.hdr_peak = 800;
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
let changed = |peak| {
[
(
"Use Automatic Setup".into(),
"Puts Peak Brightness and Paper White back to your display's setup.",
),
("Peak Brightness: 800".into(), peak),
("Paper White: Auto (400)".into(), set_up),
]
};
assert_eq!(rows(&s, &guessed), changed("-"));
assert_eq!(rows(&s, &trusted), changed(set_up));
assert_eq!(rows(&s, &NONE)[0], none);
let mut ui = ui();
ui.screen.output = guessed;
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
assert_eq!(
press(&mut ui, &mut s, at(calibrate)),
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
);
}
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
-1
View File
@@ -67,7 +67,6 @@ mod jei_sprites;
pub mod jigsaw_block;
pub mod keypad;
pub mod terrain_model;
pub use horizon_polytone::colors as polytone_colors;
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
pub use horizon_world::json_folder;
pub mod level_loading;
+2 -2
View File
@@ -2593,8 +2593,7 @@ impl Lod {
self.returned_at = std::time::Instant::now();
let nodes = &self.plan.nodes;
self.mesh_samples.retain(|key| nodes.contains(key));
// Queued tiles the view left are not sampled (a running one is
// dropped when it finishes, `finish_sample`).
// Queued tiles the view left are not sampled; a running one is dropped (`finish_sample`).
drop_queued_samples(&self.shared, &mut self.pending, |key| {
self.plan.nodes.contains(key)
});
@@ -2680,6 +2679,7 @@ impl Lod {
}
if let Some((columns, mask)) = self.kept.take(&key) {
self.ready.insert(key, columns);
self.sync_sample(key);
if let Some(mask) = mask {
self.detail_masks.insert(key, mask);
}
@@ -1,6 +1,7 @@
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
use crate::sprites::Sprites;
use std::{collections::BTreeMap, path::Path};
@@ -35,14 +36,12 @@ fn tile_file(name: &str) -> Option<&str> {
name.strip_suffix(".png")
}
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// the table). A pack without such files adds nothing.
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
let suffix = horizon_optifine::emissive::suffix(root);
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
/// (native mods keep their share of the table). A pack without such files adds nothing.
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
// base would glow on every block that shares it, so it gets no overlay.
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
@@ -56,8 +55,8 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
sharing[layer] == 1
&& (name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
})
.map(|(name, _)| name.clone())
.collect();
@@ -110,7 +109,7 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
pub const CUTOUT: u32 = 1 << 31;
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
pub fn table<'a>(
layers: &BTreeMap<String, u32>,
@@ -204,7 +203,7 @@ mod tests {
// Items draw from the HUD atlas, not the terrain: no pair.
sprites.insert(&root, "minecraft:item/stick").unwrap();
// glowstone isn't loaded: its overlay pairs with nothing.
let pairs = load_optifine(&root, &mut sprites);
let pairs = load_overlays(&root, "_e", &mut sprites);
assert_eq!(
pairs,
[(
@@ -215,9 +214,6 @@ mod tests {
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
// The pack's own suffix.
let props = root.join("assets/minecraft/optifine/emissive.properties");
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
// Connected-texture tiles pair with the overlay beside them.
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
png(&tiles.join("0.png"), 230);
@@ -234,7 +230,7 @@ mod tests {
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
.unwrap();
}
let pairs = load_optifine(&root, &mut sprites);
let pairs = load_overlays(&root, "_glow", &mut sprites);
assert_eq!(
pairs,
// Blocks first, then tiles.
@@ -162,10 +162,38 @@ impl OptionsUi {
};
let mut rows = Vec::new();
registered.rows(&mut rows);
rows.into_iter()
.enumerate()
.map(|(r, row)| self.added_row(page, r, row))
.collect()
let mut out = Vec::new();
// The row a half-width button started, waiting for the next to sit beside it.
let mut half_open = false;
for (r, row) in rows.into_iter().enumerate() {
match row {
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
added::Row::Text { text, .. } => {
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
}
added::Row::Space(_) => continue,
added::Row::Button {
label,
active,
half: true,
..
} => {
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
if !active {
half = inactive(half);
}
match out.last_mut() {
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
_ => out.push(Row::Buttons(half, None)),
}
half_open = !half_open;
continue;
}
row => out.push(self.added_row(page, r, row)),
}
half_open = false;
}
out
}
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
@@ -177,7 +205,10 @@ impl OptionsUi {
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
added::Row::Button { label } => wide(tr(label)),
added::Row::Button { label, active, .. } => {
let wide = button(0.0, 0.0, 310.0, tr(label), act);
Row::Buttons(if active { wide } else { inactive(wide) }, None)
}
added::Row::Key { label, key } => {
let mut message = keybinds::display_name(&key);
if self.selected_key == Some(PAGE_KEY)
@@ -461,4 +492,84 @@ mod tests {
Some(tr("test.sample.switch.tooltip").plain())
);
}
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
struct Shaped(Mutex<Vec<usize>>);
impl added::Page for Shaped {
fn id(&self) -> &'static str {
"test.shaped"
}
fn title(&self) -> &'static str {
"test.shaped.title"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
rows.push(added::Row::text("grey").quiet());
rows.push(added::Row::Space(6));
rows.push(added::Row::button("test.shaped.copy").half());
rows.push(added::Row::button("test.shaped.stop").half().active(false));
rows.push(added::Row::button("test.shaped.help").footer());
}
fn act(&self, row: usize, _: added::Act) {
self.0.lock().unwrap().push(row);
}
}
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
#[test]
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
let mut s = Settings::default();
TEST_PAGES.set(Some(&SHAPED_PAGES));
let ui = OptionsUi::default();
let rows = ui.added_rows(0);
// The two texts as notes (space shows nothing here), the halves on one row, the
// footer button below them (the settings shell has no footer of its own).
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
panic!("the halves sit side by side: {:?}", rows.len());
};
assert_eq!((copy.w, stop.w), (150.0, 150.0));
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
assert_eq!(rows.len(), 4);
let mut ui = ui;
ui.show(Page::Added(0));
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
let stop = widgets
.iter()
.find(|w| {
matches!(
&w.kind,
WKind::Button {
act: Act::PageRow(0, 4),
..
}
)
})
.expect("Stop is drawn");
let atlas = super::super::tests::atlas();
ui.click(
&mut s,
&atlas,
stop.x + 1.0,
stop.y + 1.0,
(480.0, 240.0),
[480, 240],
false,
false,
);
assert_eq!(
*SHAPED.0.lock().unwrap(),
[3, 5],
"the inactive half heard nothing"
);
}
}
@@ -0,0 +1,67 @@
//! Polytone's custom colours (`horizon_polytone::colors`, `colors.json` or `color.properties`)
//! as the engine's pack colours ([`crate::pack_palette::PackColors`]).
use std::path::Path;
use horizon_polytone::colors::PackColors;
use crate::block_entity_render::DYES;
use crate::pack_palette::{self, Environment};
/// The colours of every namespace's file under the pack overlay at `root`.
pub fn read(root: &Path) -> pack_palette::PackColors {
let colors = PackColors::read(root);
let by_dye = |f: &dyn Fn(&str) -> Option<u32>| std::array::from_fn(|i| f(DYES[i].0));
let env = colors.env;
pack_palette::PackColors {
dye_diffuse: by_dye(&|name| colors.dye(name, "diffuse")),
dye_text: by_dye(&|name| colors.dye(name, "text")),
sheep: by_dye(&|name| colors.sheep(name)),
redstone: std::array::from_fn(|i| colors.redstone(i as u8)),
effects: colors.effects(),
entity_shadows: colors.entity_shadows(),
xp_bar: colors.xp_bar(),
env: Environment {
powder_snow_fog: env.powder_snow_fog,
blindness_fog_distance: env.blindness_fog_distance,
darkness_fog_distance: env.darkness_fog_distance,
boss_fog_distance: env.boss_fog_distance,
water_fog_brightening: env.water_fog_brightening,
water_fog_brightening_time: env.water_fog_brightening_time,
horizon_height: env.horizon_height,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pack_palette::{diffuse, redstone, sheep_wool, text};
#[test]
fn a_pack_recolours_only_what_it_names() {
// Dyes no other test draws: the table is process-wide.
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
let polytone = dir.join("assets/obs/polytone");
std::fs::create_dir_all(&polytone).unwrap();
std::fs::write(
polytone.join("colors.json"),
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
)
.unwrap();
crate::pack_palette::set(&read(&dir));
let (cyan, pink, purple) = (9, 6, 10);
assert_eq!(diffuse(cyan), 0x00ff00);
assert_eq!(text(cyan), 0x0000ff);
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
assert_eq!(sheep_wool(cyan as u8), 0x107575);
assert_eq!(diffuse(purple), 0x8932B8);
assert_eq!(text(purple), 0xA020F0);
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
crate::pack_palette::set(&Default::default());
assert_eq!(redstone(7), None);
assert_eq!(diffuse(cyan), 0x169C9C);
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -0,0 +1,146 @@
//! Polytone's item modifiers' durability bar colours, on the engine's
//! [`crate::pack_palette::ItemBars`].
use std::path::Path;
use std::sync::Arc;
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
struct DamageBar {
targets: horizon_polytone::Targets,
row: Vec<u32>,
}
/// The packs' damage bars, the last read first.
struct Bars(Vec<DamageBar>);
impl crate::pack_palette::ItemBars for Bars {
fn color(&self, item: &str, damage: f32) -> Option<u32> {
let bar = self
.0
.iter()
.rev()
.find(|b| b.targets.matches(item, |_| false))?;
let last = (bar.row.len() - 1) as f32;
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
}
}
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read. `None` without any.
pub fn read(root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
let mut bars = Vec::new();
for namespace in std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten()
{
let base = namespace.path().join("polytone/item_modifiers");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Some(bar) = item_bar(root, &base, &path) {
bars.push(bar);
}
}
}
}
(!bars.is_empty()).then(|| Arc::new(Bars(bars)) as _)
}
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
let name = path.file_name()?.to_str()?;
let row = |png: &Path| {
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
let row: Vec<u32> = rgba[..width as usize * 4]
.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
.collect();
(!row.is_empty()).then_some(row)
};
if let Some(stem) = name.strip_suffix("_bar.png") {
// A json of the same name owns its png.
if path.with_file_name(format!("{stem}.json")).exists() {
return None;
}
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
return Some(DamageBar {
targets,
row: row(path)?,
});
}
let stem = name.strip_suffix(".json")?;
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
if json.get("bar_color")?.as_str()? != "damage" {
return None;
}
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
.into_iter()
.map(|png| path.with_file_name(png))
.find(|png| png.exists())?;
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
let targets =
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
Some(DamageBar {
targets,
row: row(&png)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::fs::File::create(path).unwrap();
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
encoder.set_color(png::ColorType::Rgba);
let data: Vec<u8> = pixels
.iter()
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&data)
.unwrap();
}
#[test]
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
std::fs::write(
modifiers.join("tools.json"),
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
)
.unwrap();
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
let bars = read(&dir).unwrap();
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
assert_eq!(
bars.color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
Some(0xFF0000)
);
assert_eq!(
bars.color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
Some(0xFFFF00)
);
assert_eq!(bars.color("minecraft:iron_axe", 0.9), Some(0x040506));
assert_eq!(bars.color("minecraft:diamond_shovel", 0.5), None);
assert!(read(&dir.join("none")).is_none());
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -3,7 +3,9 @@
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
//! moves to a platform crate unchanged, and nothing else in the crate names it.
mod colors;
mod custom_sky;
mod item_bars;
use std::path::Path;
use std::sync::Arc;
@@ -14,10 +16,22 @@ use crate::pack_hooks::PackFormats;
pub struct Formats;
impl PackFormats for Formats {
fn colors(&self, root: &Path) -> Option<crate::pack_palette::PackColors> {
Some(colors::read(root))
}
fn emissive_suffix(&self, root: &Path) -> Option<String> {
Some(horizon_optifine::emissive::suffix(root))
}
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
let sky = custom_sky::CustomSky::load(root);
(!sky.is_empty()).then(|| Arc::new(sky) as _)
}
fn item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
item_bars::read(root)
}
}
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
@@ -10,10 +10,26 @@ use std::sync::{Arc, OnceLock};
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
/// (its folder at `root`); the default is "nothing in this format".
pub trait PackFormats: Send + Sync {
/// The colours the overlay replaces where vanilla hardcodes them.
fn colors(&self, _root: &Path) -> Option<crate::pack_palette::PackColors> {
None
}
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
/// if its format has them.
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
None
}
/// The sky layers the overlay draws over the vanilla sky, if it has any.
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
None
}
/// The durability bar colours the overlay sets by item, if any.
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
None
}
}
/// No format beyond vanilla's.
+113 -184
View File
@@ -1,14 +1,87 @@
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
//! The colours a pack replaces where vanilla hardcodes them (Polytone's `colors.json`, read by the
//! platform's pack formats, [`crate::pack_hooks`]): dye, sheep and redstone colours, for drawing
//! only (the pack recolours collars, tropical fish, banners, wool, sign text and redstone wire and
//! dust, while gameplay keeps vanilla's `DyeColor`), mob effect colours, the experience number's,
//! entity shadows, the effect fogs' settings and durability bar colours. Lock-free: the draw paths
//! and the mesher's workers read the dye, sheep and redstone tables per face.
use std::path::Path;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::sync::{Arc, RwLock};
use crate::block_entity_render::DYES;
use crate::signs::DYE_TEXT;
use horizon_polytone::colors::PackColors;
/// The fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Environment {
/// The fog colour inside powder snow (RGB).
pub powder_snow_fog: Option<u32>,
/// The fog ends of Blindness (5), Darkness (15) and bosses that add world fog (96).
pub blindness_fog_distance: f32,
pub darkness_fog_distance: f32,
pub boss_fog_distance: f32,
/// How much the underwater fog colour washes out (1 vanilla, 0 none).
pub water_fog_brightening: f32,
/// The ticks under water it takes (vanilla's 600).
pub water_fog_brightening_time: f32,
/// Below it the sky draws its dark disc (vanilla's 63; flat worlds keep their floor).
pub horizon_height: f32,
}
impl Default for Environment {
fn default() -> Self {
Self {
powder_snow_fog: None,
blindness_fog_distance: 5.0,
darkness_fog_distance: 15.0,
boss_fog_distance: 96.0,
water_fog_brightening: 1.0,
water_fog_brightening_time: 600.0,
horizon_height: 63.0,
}
}
}
/// The packs' colours; `None` (and the defaults) where they keep vanilla's.
#[derive(Clone, Debug, PartialEq)]
pub struct PackColors {
/// By dye id (`DyeColor` order): `getTextureDiffuseColor`, `getTextColor`, the sheep's wool.
pub dye_diffuse: [Option<u32>; 16],
pub dye_text: [Option<u32>; 16],
pub sheep: [Option<u32>; 16],
/// `RedStoneWireBlock.COLORS` by power.
pub redstone: [Option<u32>; 16],
/// Mob effects' colours, by effect id without vanilla's namespace.
pub effects: Vec<(String, u32)>,
/// False hides every entity's round shadow, whatever the video option says.
pub entity_shadows: bool,
/// The experience level number's colour and outline.
pub xp_bar: [Option<u32>; 2],
/// The effect fogs' settings: the fog reads them every frame.
pub env: Environment,
}
impl Default for PackColors {
fn default() -> Self {
Self {
dye_diffuse: [None; 16],
dye_text: [None; 16],
sheep: [None; 16],
redstone: [None; 16],
effects: Vec::new(),
entity_shadows: true,
xp_bar: [None; 2],
env: Environment::default(),
}
}
}
impl PackColors {
/// Whether entities draw their round shadow, as far as the packs say.
pub fn entity_shadows(&self) -> bool {
self.entity_shadows
}
}
/// 0 is unset, else `1 << 32 | rgb`.
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
@@ -16,6 +89,30 @@ static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
/// The colours of the pack overlay at `root`, from the installed pack formats; their dye, sheep
/// and redstone colours go to the draw paths' tables ([`set`]).
pub fn read(root: &Path) -> PackColors {
let formats = crate::pack_hooks::formats();
let colors = formats.colors(root).unwrap_or_default();
set(&colors);
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = formats.item_bars(root);
colors
}
/// The packs' durability bar colours by item (Polytone's item modifiers' `bar_color`).
pub trait ItemBars: Send + Sync {
/// The bar colour (RGB) of `item` at this damage (0 to 1), when a pack sets one.
fn color(&self, item: &str, damage: f32) -> Option<u32>;
}
static ITEM_BARS: RwLock<Option<Arc<dyn ItemBars>>> = RwLock::new(None);
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
bars.as_ref()?.color(item, damage)
}
/// Takes the colours of the packs just loaded (vanilla's where they set none).
pub fn set(colors: &PackColors) {
let store = |table: &[AtomicU64; 16], i: usize, color: Option<u32>| {
@@ -24,129 +121,39 @@ pub fn set(colors: &PackColors) {
Relaxed,
);
};
for (i, (name, _)) in DYES.iter().enumerate() {
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
store(&TEXT, i, colors.dye(name, "text"));
store(&SHEEP, i, colors.sheep(name));
store(&REDSTONE, i, colors.redstone(i as u8));
for i in 0..16 {
store(&DIFFUSE, i, colors.dye_diffuse[i]);
store(&TEXT, i, colors.dye_text[i]);
store(&SHEEP, i, colors.sheep[i]);
store(&REDSTONE, i, colors.redstone[i]);
}
}
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
struct DamageBar {
targets: horizon_polytone::Targets,
row: Vec<u32>,
}
static ITEM_BARS: RwLock<Vec<DamageBar>> = RwLock::new(Vec::new());
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read.
pub fn set_item_bars(root: &Path) {
let mut bars = Vec::new();
for namespace in std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten()
{
let base = namespace.path().join("polytone/item_modifiers");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Some(bar) = item_bar(root, &base, &path) {
bars.push(bar);
}
}
}
}
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = bars;
}
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
let name = path.file_name()?.to_str()?;
let row = |png: &Path| {
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
let row: Vec<u32> = rgba[..width as usize * 4]
.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
.collect();
(!row.is_empty()).then_some(row)
};
if let Some(stem) = name.strip_suffix("_bar.png") {
// A json of the same name owns its png.
if path.with_file_name(format!("{stem}.json")).exists() {
return None;
}
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
return Some(DamageBar {
targets,
row: row(path)?,
});
}
let stem = name.strip_suffix(".json")?;
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
if json.get("bar_color")?.as_str()? != "damage" {
return None;
}
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
.into_iter()
.map(|png| path.with_file_name(png))
.find(|png| png.exists())?;
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
let targets =
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
Some(DamageBar {
targets,
row: row(&png)?,
})
}
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
let bar = bars
.iter()
.rev()
.find(|b| b.targets.matches(item, |_| false))?;
let last = (bar.row.len() - 1) as f32;
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
}
fn read(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
fn get(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
let v = table[i & 15].load(Relaxed);
(v != 0).then_some(v as u32)
}
/// `DyeColor.getTextureDiffuseColor` (RGB) as the packs draw it.
pub fn diffuse(dye: usize) -> u32 {
read(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
get(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
}
/// `DyeColor.getTextColor` (RGB) as the packs draw it.
pub fn text(dye: usize) -> u32 {
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
get(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
}
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
pub fn redstone(power: u8) -> Option<u32> {
read(&REDSTONE, usize::from(power))
get(&REDSTONE, usize::from(power))
}
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
pub fn sheep_wool(dye: u8) -> u32 {
let i = usize::from(dye & 15);
if let Some(color) = read(&SHEEP, i) {
if let Some(color) = get(&SHEEP, i) {
return color;
}
if i == 0 {
@@ -156,81 +163,3 @@ pub fn sheep_wool(dye: u8) -> u32 {
let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255);
f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pack_recolours_only_what_it_names() {
// Dyes no other test draws: the table is process-wide.
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
let polytone = dir.join("assets/obs/polytone");
std::fs::create_dir_all(&polytone).unwrap();
std::fs::write(
polytone.join("colors.json"),
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
)
.unwrap();
set(&PackColors::read(&dir));
let (cyan, pink, purple) = (9, 6, 10);
assert_eq!(diffuse(cyan), 0x00ff00);
assert_eq!(text(cyan), 0x0000ff);
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
assert_eq!(sheep_wool(cyan as u8), 0x107575);
assert_eq!(diffuse(purple), 0x8932B8);
assert_eq!(text(purple), 0xA020F0);
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
set(&PackColors::default());
assert_eq!(redstone(7), None);
assert_eq!(diffuse(cyan), 0x169C9C);
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
}
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::fs::File::create(path).unwrap();
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
encoder.set_color(png::ColorType::Rgba);
let data: Vec<u8> = pixels
.iter()
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&data)
.unwrap();
}
#[test]
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
std::fs::write(
modifiers.join("tools.json"),
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
)
.unwrap();
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
set_item_bars(&dir);
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
assert_eq!(
item_bar_color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
Some(0xFF0000)
);
assert_eq!(
item_bar_color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
Some(0xFFFF00)
);
assert_eq!(item_bar_color("minecraft:iron_axe", 0.9), Some(0x040506));
assert_eq!(item_bar_color("minecraft:diamond_shovel", 0.5), None);
set_item_bars(&dir.join("none"));
assert_eq!(item_bar_color("minecraft:diamond_pickaxe", 0.5), None);
std::fs::remove_dir_all(&dir).ok();
}
}
+3 -2
View File
@@ -119,10 +119,11 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
@@ -441,6 +441,7 @@
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
+5 -5
View File
@@ -21,11 +21,11 @@ pub use horizon_client_render::{
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
polytone_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
providers, pvp, pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask,
server_terrain, settings, shader_options, shader_pack_list, signs, skins, social, sound_engine,
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
streaming, structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
+3 -3
View File
@@ -2666,9 +2666,9 @@ pub fn mob_from_nbt_with_random(
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
m.extra.color = number(tag.get("Color")).map_or(16, |c| c.clamp(0, 16) as u8);
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
m.anim.v[0] = m.extra.counter as f32 * 0.01;
m.anim.vo[0] = m.anim.v[0];
// Shulker.setPos: square on its block.
+2 -28
View File
@@ -2028,7 +2028,7 @@ pub struct PackResources {
/// The packs' Polytone post chains (#11486), when a pack has some.
polytone_post: Option<Arc<horizon_client::polytone_post::PolytonePost>>,
/// The packs' Polytone custom colours (`polytone/colors.json`).
polytone_colors: Arc<horizon_client::polytone_colors::PackColors>,
polytone_colors: Arc<horizon_client::pack_palette::PackColors>,
}
/// A quick craft drag as the screen draws it: each dragged slot's count and
/// whether the slot caps it (None: hidden), and what stays in hand.
@@ -6142,7 +6142,7 @@ impl Game<'_> {
let atlas = &g.gui.atlas;
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
let draws = hud_layout::experience_colored(
g.pack.polytone_colors.xp_bar(),
g.pack.polytone_colors.xp_bar,
wi,
hi,
g.experience.progress,
@@ -6475,18 +6475,6 @@ impl Game<'_> {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
// `Gui.setScreen` clears the old screen's focus before it goes: the
// key that closed the container (Tab bound to the inventory) has
@@ -6530,20 +6518,6 @@ impl Game<'_> {
}
self.close_ender_chest();
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
+1 -1
View File
@@ -1820,7 +1820,7 @@ impl Game<'_> {
// from carries those, as `save_guest` gives them on leaving. The map
// stays their one owner.
let live = self.guest_attachments.get(&uuid).cloned();
let g = &mut host.hub.guests[i];
let g = playerdata::with_warden(&mut host.hub.guests[i], self.warden_tracker_tag());
let held = live.map(|a| std::mem::replace(&mut g.attachments, a));
playerdata::store(g, &mut self.level, vehicle);
if let Some(held) = held {
@@ -899,4 +899,32 @@ impl Game<'_> {
f.input_clicked(world_recipes!(self), before);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
}
+4 -267
View File
@@ -28,6 +28,10 @@ use horizon_core::{
};
use horizon_world::registry::BlockStateId;
use std::{collections::HashMap, sync::Arc};
mod index;
mod nbt;
pub(super) use index::CartIndex;
use index::cart_at;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum CartKind {
@@ -187,102 +191,6 @@ pub(super) const TNT_PRIME_EVENT: i8 = 70;
mod display;
use display::{cart_render_bounds, display_offset, shown_unless_air};
pub(super) use display::{default_display_offset, forget_unreadable_display};
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
///
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
/// rebuilt once the list's length changed (a cart gone or added), and dropped
/// after a command, which can move or replace any cart
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
/// unseen, so with mods loaded both scans stay.
#[derive(Default)]
pub(super) struct CartIndex {
carts: Option<(SeenGrid, Vec<u32>)>,
/// The grid, and the carts a mob rides.
mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
near: Vec<u32>,
}
impl CartIndex {
fn usable() -> bool {
horizon_client::mod_api::mods().is_empty()
}
/// The cart in slot `i` has these bounds now.
fn moved(&mut self, i: usize, bounds: Aabb) {
if let Some((grid, ids)) = &mut self.carts
&& i < ids.len()
{
grid.update(i, bounds);
}
}
fn forget_carts(&mut self) {
self.carts = None;
}
/// The slots of the carts whose box may meet `area`, for the cart in slot
/// `i` (just moved); false: scan them all.
fn carts_near(&mut self, carts: &[Minecart], i: usize, area: Aabb, out: &mut Vec<u32>) -> bool {
if !Self::usable() {
return false;
}
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
self.carts = Some((
SeenGrid::new(carts.iter().map(Minecart::bounds)),
carts.iter().map(|c| c.id).collect(),
));
}
let Some((grid, ids)) = &mut self.carts else {
return false;
};
grid.update(i, carts[i].bounds());
if !grid.candidates(area, out) {
return false;
}
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
// A cart was replaced in place: start over next time.
self.carts = None;
return false;
}
true
}
/// The slots of the mobs whose box may meet `area` into `out`, and
/// whether a mob rides cart `cart`; None: scan them all.
fn mobs_near(
&mut self,
mobs: &JoinList<Mob>,
cart: u32,
area: Aabb,
out: &mut Vec<u32>,
) -> Option<bool> {
if !Self::usable() {
return None;
}
if !self
.mobs
.as_ref()
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
{
self.mobs = Some((
mobs.stamp(),
SeenGrid::new(mobs.iter().map(Mob::bounds)),
mobs.iter().filter_map(|m| m.extra.mount).collect(),
));
}
let (_, grid, mounted) = self.mobs.as_ref()?;
grid.candidates(area, out).then(|| mounted.contains(&cart))
}
}
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
/// the push the cart loses (the entity gets a quarter of it back).
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
@@ -2984,177 +2892,6 @@ impl Game<'_> {
.render_state(&scene, state.0, m, light, out);
}
}
/// Saving and loading.
impl Minecart {
pub fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
use horizon_world::nbt::{JavaString, Tag};
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
self.kind.id(),
self.position,
self.velocity,
[self.yaw, self.pitch],
(self.on_ground, self.fall_distance),
uuid,
raw,
);
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
if let Tag::Compound(map) = &mut tag {
// ContainerEntity.addChestVehicleSaveData belongs only to the
// container subclasses, never to a plain or specialized cart.
if self.kind.slots() == 0 {
map.remove(&JavaString::from("Items"));
}
if self.kind == CartKind::CommandBlock {
for key in ["LastOutput", "LastExecution"] {
map.remove(&JavaString::from(key));
}
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
// addAdditionalSaveData: the offset only off its default, the state only when set.
if let Some(offset) = self.custom_offset
&& f64::from(offset) != default_display_offset(self.kind)
{
put("DisplayOffset", Tag::Int(offset));
}
if let Some(state) = &self.custom_display {
put("DisplayState", state.clone());
}
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
if self.kind == CartKind::Hopper {
put("Enabled", Tag::Byte(i8::from(self.enabled)));
}
}
CartKind::Furnace => {
put("PushX", Tag::Double(self.push.x.to_bits()));
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
put(key, value);
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
{
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
}
} else {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let mut c = Self::new(id, kind, position);
c.load(tag)?;
Some(c)
}
/// `Entity.load` on this cart: the saved fields are read over the live
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
/// `None`, with nothing changed, when the compound has no position.
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
use horizon_world::nbt::Tag;
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let kind = self.kind;
let c = self;
c.position = position;
c.previous = position;
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
c.previous_pitch = c.pitch;
// ValueInput's numeric getters take any NumericTag: getIntOr is its
// intValue, getShortOr the low 16 bits of it, getBooleanOr
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
let int = |k: &str| {
tag.get(k)
.and_then(horizon_client_core::nbt_path::int_value)
};
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
let double = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => f64::from(*v),
Some(Tag::Short(v)) => f64::from(*v),
Some(Tag::Int(v)) => f64::from(*v),
Some(Tag::Long(v)) => *v as f64,
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
Some(Tag::Double(v)) => f64::from_bits(*v),
_ => 0.0,
};
// Entity.load: OnGround and fall_distance, as saved.
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
c.flipped = flag("FlippedRotation", false);
c.first_tick = flag("HasTicked", false);
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
c.enabled = flag("Enabled", true);
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
if kind == CartKind::CommandBlock {
c.command = horizon_client::command_block::CommandBlock::load(tag);
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
if c.synced_command != c.command.command {
c.synced_command.clone_from(&c.command.command);
c.command.set_command(&c.synced_command);
}
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(())
}
}
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
/// TNT cart.
@@ -0,0 +1,105 @@
//! The carts' and mobs' boxes for a tick's cart pushes ([`CartIndex`]).
use super::*;
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
///
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
/// rebuilt once the list's length changed (a cart gone or added), and dropped
/// after a command, which can move or replace any cart
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
/// unseen, so with mods loaded both scans stay.
#[derive(Default)]
pub(in crate::game) struct CartIndex {
pub(super) carts: Option<(SeenGrid, Vec<u32>)>,
/// The grid, and the carts a mob rides.
pub(super) mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
pub(super) near: Vec<u32>,
}
impl CartIndex {
pub(super) fn usable() -> bool {
horizon_client::mod_api::mods().is_empty()
}
/// The cart in slot `i` has these bounds now.
pub(super) fn moved(&mut self, i: usize, bounds: Aabb) {
if let Some((grid, ids)) = &mut self.carts
&& i < ids.len()
{
grid.update(i, bounds);
}
}
pub(super) fn forget_carts(&mut self) {
self.carts = None;
}
/// The slots of the carts whose box may meet `area`, for the cart in slot
/// `i` (just moved); false: scan them all.
pub(super) fn carts_near(
&mut self,
carts: &[Minecart],
i: usize,
area: Aabb,
out: &mut Vec<u32>,
) -> bool {
if !Self::usable() {
return false;
}
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
self.carts = Some((
SeenGrid::new(carts.iter().map(Minecart::bounds)),
carts.iter().map(|c| c.id).collect(),
));
}
let Some((grid, ids)) = &mut self.carts else {
return false;
};
grid.update(i, carts[i].bounds());
if !grid.candidates(area, out) {
return false;
}
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
// A cart was replaced in place: start over next time.
self.carts = None;
return false;
}
true
}
/// The slots of the mobs whose box may meet `area` into `out`, and
/// whether a mob rides cart `cart`; None: scan them all.
pub(super) fn mobs_near(
&mut self,
mobs: &JoinList<Mob>,
cart: u32,
area: Aabb,
out: &mut Vec<u32>,
) -> Option<bool> {
if !Self::usable() {
return None;
}
if !self
.mobs
.as_ref()
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
{
self.mobs = Some((
mobs.stamp(),
SeenGrid::new(mobs.iter().map(Mob::bounds)),
mobs.iter().filter_map(|m| m.extra.mount).collect(),
));
}
let (_, grid, mounted) = self.mobs.as_ref()?;
grid.candidates(area, out).then(|| mounted.contains(&cart))
}
}
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
@@ -0,0 +1,173 @@
//! Saving and loading a minecart.
use super::*;
/// Saving and loading.
impl Minecart {
pub fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
use horizon_world::nbt::{JavaString, Tag};
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
self.kind.id(),
self.position,
self.velocity,
[self.yaw, self.pitch],
(self.on_ground, self.fall_distance),
uuid,
raw,
);
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
if let Tag::Compound(map) = &mut tag {
// ContainerEntity.addChestVehicleSaveData belongs only to the
// container subclasses, never to a plain or specialized cart.
if self.kind.slots() == 0 {
map.remove(&JavaString::from("Items"));
}
if self.kind == CartKind::CommandBlock {
for key in ["LastOutput", "LastExecution"] {
map.remove(&JavaString::from(key));
}
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
// addAdditionalSaveData: the offset only off its default, the state only when set.
if let Some(offset) = self.custom_offset
&& f64::from(offset) != default_display_offset(self.kind)
{
put("DisplayOffset", Tag::Int(offset));
}
if let Some(state) = &self.custom_display {
put("DisplayState", state.clone());
}
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
if self.kind == CartKind::Hopper {
put("Enabled", Tag::Byte(i8::from(self.enabled)));
}
}
CartKind::Furnace => {
put("PushX", Tag::Double(self.push.x.to_bits()));
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
put(key, value);
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
{
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
}
} else {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let mut c = Self::new(id, kind, position);
c.load(tag)?;
Some(c)
}
/// `Entity.load` on this cart: the saved fields are read over the live
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
/// `None`, with nothing changed, when the compound has no position.
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
use horizon_world::nbt::Tag;
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let kind = self.kind;
let c = self;
c.position = position;
c.previous = position;
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
c.previous_pitch = c.pitch;
// ValueInput's numeric getters take any NumericTag: getIntOr is its
// intValue, getShortOr the low 16 bits of it, getBooleanOr
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
let int = |k: &str| {
tag.get(k)
.and_then(horizon_client_core::nbt_path::int_value)
};
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
let double = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => f64::from(*v),
Some(Tag::Short(v)) => f64::from(*v),
Some(Tag::Int(v)) => f64::from(*v),
Some(Tag::Long(v)) => *v as f64,
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
Some(Tag::Double(v)) => f64::from_bits(*v),
_ => 0.0,
};
// Entity.load: OnGround and fall_distance, as saved.
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
c.flipped = flag("FlippedRotation", false);
c.first_tick = flag("HasTicked", false);
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
c.enabled = flag("Enabled", true);
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
if kind == CartKind::CommandBlock {
c.command = horizon_client::command_block::CommandBlock::load(tag);
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
if c.synced_command != c.command.command {
c.synced_command.clone_from(&c.command.command);
c.command.set_command(&c.synced_command);
}
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(())
}
}
@@ -292,10 +292,8 @@ fn pack_particles(
}
/// The packs' custom colours; their dye and sheep colours go to the draw paths' table.
fn read_colors(root: &std::path::Path) -> horizon_client::polytone_colors::PackColors {
let colors = horizon_client::polytone_colors::PackColors::read(root);
horizon_client::pack_palette::set(&colors);
horizon_client::pack_palette::set_item_bars(root);
horizon_client::brewing::set_pack_colors(colors.effects());
fn read_colors(root: &std::path::Path) -> horizon_client::pack_palette::PackColors {
let colors = horizon_client::pack_palette::read(root);
horizon_client::brewing::set_pack_colors(colors.effects.clone());
colors
}
+11 -4
View File
@@ -359,12 +359,19 @@ impl Game<'_> {
self.sculk.tracker = WardenSpawnTracker::from_nbt(tag);
}
}
/// Write the tracker into the player data being saved.
/// The tracker as saved, once it has been loaded.
pub(super) fn warden_tracker_tag(&self) -> Option<horizon_world::nbt::Tag> {
self.sculk
.tracker_loaded
.then(|| self.sculk.tracker.to_nbt())
}
/// Write the tracker into the player data being saved. With a LAN owner,
/// `store_owner` rewrites that from the owner's guest, so it puts the
/// tracker there as well (`playerdata::with_warden`).
pub(super) fn save_warden_tracker(&mut self) {
if !self.sculk.tracker_loaded {
let Some(tag) = self.warden_tracker_tag() else {
return;
}
let tag = self.sculk.tracker.to_nbt();
};
let extra = self
.level
.player_extra
@@ -53,6 +53,27 @@ pub fn exists(world: &Path, uuid: u128) -> bool {
path(world, uuid).is_file()
}
/// The key `ServerPlayer.addAdditionalSaveData` stores the `WardenSpawnTracker` under.
pub const WARDEN_TRACKER_KEY: &str = "warden_spawn_tracker";
/// Put a player's `warden_spawn_tracker` into the compound `store` writes from
/// (`guest.saved`), so a save keeps it rather than the older one loaded with
/// the file (the LAN owner's tracker lives in `Game`, `save_warden_tracker`).
/// Other saved keys are left as they are; no tracker (not loaded yet) changes
/// nothing.
pub fn with_warden(guest: &mut Guest, tracker: Option<Tag>) -> &mut Guest {
let Some(tracker) = tracker else {
return guest;
};
let saved = guest
.saved
.get_or_insert_with(|| Tag::Compound(Default::default()));
if let Tag::Compound(map) = saved {
map.insert(WARDEN_TRACKER_KEY.into(), tracker);
}
guest
}
/// `PlayerDataStorage.load`: a guest's saved state, if they have been here
/// before. The player's file, else its `.dat_old` (what a save cut off
/// between its two renames leaves, `write_dat`).
@@ -651,6 +672,42 @@ mod tests {
)
}
/// #9965: the owner's warden warning level and cooldown reach the file on
/// a save, instead of the ones the file was loaded with.
#[test]
fn a_stored_warden_tracker_replaces_the_loaded_one() {
let mut g = guest();
g.saved = Some(Tag::compound(vec![
(
"warden_spawn_tracker",
Tag::compound(vec![
("warning_level", Tag::Int(2)),
("cooldown_ticks", Tag::Int(77)),
("ticks_since_last_warning", Tag::Int(5)),
]),
),
("unrelated_key", Tag::Int(9)),
]));
let tracker = crate::sculk::WardenSpawnTracker {
ticks_since_last_warning: 0,
warning_level: 3,
cooldown_ticks: 200,
};
with_warden(&mut g, Some(tracker.to_nbt()));
let (tag, _) = round_trip(&g);
let mut back = guest();
let mut level = Level::default();
level.read_player(&tag);
level.player_extra = Some(tag);
apply(&mut back, &level);
let saved = back.saved.as_ref().unwrap();
let read =
crate::sculk::WardenSpawnTracker::from_nbt(saved.get("warden_spawn_tracker").unwrap());
assert_eq!((read.warning_level, read.cooldown_ticks), (3, 200));
assert_eq!(read.ticks_since_last_warning, 0);
assert!(matches!(saved.get("unrelated_key"), Some(Tag::Int(9))));
}
#[test]
fn a_new_player_faces_the_world_spawn_angle() {
let mut g = guest();
+48 -3
View File
@@ -855,9 +855,11 @@ pub fn texture(m: &Mob) -> String {
"red",
"black",
];
match COLORS.get(m.extra.color as usize) {
Some(c) => format!("entity/shulker/shulker_{c}"),
None => "entity/shulker/shulker".into(),
// Shulker.getColor: the saved byte is signed; 16 and above are undyed, and
// DyeColor.byId sends a negative id to white (OutOfBoundsStrategy.ZERO).
match m.extra.color as i8 {
16.. => "entity/shulker/shulker".into(),
c => format!("entity/shulker/shulker_{}", COLORS[c.max(0) as usize]),
}
}
/// `attachFace.getOpposite().getRotation()` by attach face: the open
@@ -1144,3 +1146,46 @@ mod tests {
assert!((h - 1.0).abs() < 1e-9, "{h}");
}
}
#[cfg(test)]
mod saved_bytes_tests {
use super::*;
/// `Shulker.readAdditionalSaveData`: `AttachFace` is
/// `Direction.LEGACY_ID_CODEC` (`from3DDataValue` wraps the id through
/// `abs(id % 6)`), while `Peek` and `Color` are raw `getByteOr` bytes. The
/// load clamped all three to their setters' ranges instead.
#[test]
fn a_shulker_reads_its_face_and_lid_bytes_like_vanilla() {
use crate::entity_nbt::mob_from_nbt;
use horizon_world::nbt::Tag;
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = std::sync::Arc::make_mut(&mut kind);
k.id = "minecraft:shulker".into();
k.species = Species::Shulker;
let types = [("minecraft:shulker".into(), kind)].into();
let tag = Tag::compound([
("id", Tag::String("minecraft:shulker".into())),
(
"Pos",
Tag::List(vec![
Tag::Double(0.0f64.to_bits()),
Tag::Double(64.0f64.to_bits()),
Tag::Double(0.0f64.to_bits()),
]),
),
("AttachFace", Tag::Byte(7)),
("Peek", Tag::Byte(-1)),
("Color", Tag::Byte(-1)),
]);
let m = mob_from_nbt(&tag, &types, 1, None).unwrap();
assert_eq!(m.extra.variant3, 1, "abs(7 % 6) is up");
assert_eq!(m.extra.counter, -1, "the peek byte is kept");
assert_eq!(m.extra.color, 255, "the color byte is kept");
assert_eq!(
texture(&m),
"entity/shulker/shulker_white",
"DyeColor.byId(-1)"
);
}
}
+15 -17
View File
@@ -2148,18 +2148,16 @@ impl MotionWorld<'_, '_, '_> {
let gravity = living_motion::effective_gravity(self)?;
self.player.velocity = fall_flying_movement(v, self.player.pitch, self.player.yaw, gravity);
let movement = self.player.velocity;
let before = self.player.position;
self.move_self(movement)?;
// LivingEntity.handleFallFlyingCollisions: the fall-damage sound plays
// on both sides (a glider's own travel is client-side, and the sound
// must reach its own ears, #8906), only the server hurts, and it
// compares the delta before the move with the one after it, which
// `Entity.move` leaves as the movement it resolved. `move_entity` does
// not write that back, so the distance actually travelled stands in.
// compares the horizontal speed before `updateFallFlyingMovement`
// with the delta `Entity.move` leaves (the collided axes zeroed).
if self.player.collision.horizontal {
let travelled = self.player.position.subtract(before);
let new_h = (travelled.x * travelled.x + travelled.z * travelled.z).sqrt();
let dmg = fly_into_wall_damage(move_h, new_h);
let v = self.player.velocity;
let after_h = (v.x * v.x + v.z * v.z).sqrt();
let dmg = fly_into_wall_damage(move_h, after_h);
if dmg > 0.0 {
let first = self.player.events.len();
// getFallDamageSound((int) dmg)
@@ -3148,6 +3146,7 @@ mod tests {
assert_eq!(fly_into_wall_damage(0.3, 0.0), 0.0);
assert!((fly_into_wall_damage(0.5, 0.1) - 1.0).abs() < 1e-5);
assert!((fly_into_wall_damage(1.2, 0.0) - 9.0).abs() < 1e-5);
assert!(fly_into_wall_damage(0.01, 0.0) < 0.0);
}
use super::*;
@@ -4195,17 +4194,16 @@ mod tests {
health: &resources.health,
};
w.travel_fall_flying().unwrap();
assert!(
w.player.events.iter().any(|e| matches!(
let is_fall = |e: &_| {
matches!(
e,
MotionEvent::Fall(crate::landing::FallEvent::Sound {
sound: crate::landing::SceneSound::SmallFall,
..
})
)),
"{:?}",
w.player.events
);
MotionEvent::Fall(crate::landing::FallEvent::Sound { .. })
)
};
assert_eq!(w.player.events.iter().filter(|e| is_fall(e)).count(), 1);
// The sound must play only once on impact, not repeat on subsequent ticks (#12304):
w.travel_fall_flying().unwrap();
assert_eq!(w.player.events.iter().filter(|e| is_fall(e)).count(), 1);
assert!(
!w.player
.hurt_log
+9
View File
@@ -47,6 +47,15 @@ pub fn open_vulkan_device(
Some(unsafe { adapter.create_device_from_hal(open, desc) })
}
/// wgpu's opt-in to its `EXPERIMENTAL_*` features, which the crates that forbid unsafe code
/// can't give: hardware ray queries, asked for only when the player turns ray tracing on
/// (`gfx/ray_query.rs` in horizon-client-render).
pub fn experimental_features() -> wgpu::ExperimentalFeatures {
// Safety: wgpu warns that its experimental features may have bugs that reach undefined
// behaviour through safe calls. Horizon accepts that only on request, for ray queries.
unsafe { wgpu::ExperimentalFeatures::enabled() }
}
/// Tells the compositor, through `VK_EXT_hdr_metadata`, that the swapchain's
/// content is BT.709, peaks at `peak` nits and is no darker than `black`. Needs a
/// device from `open_vulkan_device` and a configured surface; does nothing otherwise.
+82 -2
View File
@@ -61,13 +61,93 @@ pub enum Row {
value: f32,
text: Label,
},
/// A button that does something.
Button { label: &'static str },
/// A button that does something ([`Row::button`]). An inactive one is drawn greyed and
/// never pressed; two half-width ones in a row sit side by side; a footer one sits at the
/// bottom beside Done on a page drawn as its own screen, and with the others elsewhere.
/// Horizon Settings' Extras shows only active ones.
#[non_exhaustive]
Button {
label: &'static str,
active: bool,
half: bool,
footer: bool,
},
/// Text shown as it is, wrapped to the page's width ([`Row::text`]): white, or grey when
/// `quiet` (secondary); `groups` breaks it only between its `-`-separated groups (a code).
#[non_exhaustive]
Text {
text: String,
quiet: bool,
groups: bool,
},
/// Empty space, in GUI pixels, on a page drawn as its own screen; nothing elsewhere.
Space(u8),
/// A key binding, as Key Binds shows one: `key` is its key name (`key.keyboard.z`,
/// `key.mouse.left`). A press waits for the next key or mouse button ([`Act::Bind`]).
Key { label: &'static str, key: String },
}
impl Row {
/// An active, full-width button in the page's body.
pub fn button(label: &'static str) -> Self {
Self::Button {
label,
active: true,
half: false,
footer: false,
}
}
/// White text in the page's body, wrapped at spaces.
pub fn text(text: impl Into<String>) -> Self {
Self::Text {
text: text.into(),
quiet: false,
groups: false,
}
}
/// A button greyed out (`false`): drawn, never pressed.
pub fn active(mut self, on: bool) -> Self {
if let Self::Button { active, .. } = &mut self {
*active = on;
}
self
}
/// A button half as wide: two in a row sit side by side.
pub fn half(mut self) -> Self {
if let Self::Button { half, .. } = &mut self {
*half = true;
}
self
}
/// A button in the footer, beside Done, on a page drawn as its own screen.
pub fn footer(mut self) -> Self {
if let Self::Button { footer, .. } = &mut self {
*footer = true;
}
self
}
/// Text in grey: secondary.
pub fn quiet(mut self) -> Self {
if let Self::Text { quiet, .. } = &mut self {
*quiet = true;
}
self
}
/// Text broken only between its `-`-separated groups, never inside one (a code).
pub fn groups(mut self) -> Self {
if let Self::Text { groups, .. } = &mut self {
*groups = true;
}
self
}
}
/// A value shown on a row: a translation key, or text shown as it is (a number, a name).
#[derive(Clone, Debug, PartialEq)]
pub enum Label {
+1 -1
View File
@@ -45,7 +45,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
**Translucent batching** (Advanced, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' luminance readings often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Line truncated
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' SDR white is the player's own slider, but its peak is mostly the display's EDID figure, which often runs high: Auto still uses it, over the 1000-nit default, but the HDR Line truncated
With `HORIZON_HDR_FRAME=float`, HDR on and no shader pack or a 0.2 one, devices with independent blending draw the frame in layers ([hdr-linear-light.md](design/hdr-linear-light.md), The HUD and menus). The world's shaders write their own light into the frame instead of a mark (`hdr_written`, `hdr_light.wgsl`), a whole frame at a time, so glass, water, clouds and particles each lay their own light over the light behind them. Improved Transparency's layers write their own light too, before they are weighted and composited. No float frame holds marks: under a 0.2 pack Horizon's own draws keep their colour, alpha 1 where opaque and coverage where blended, HDR output lifts nothing, and the pack's `[tonemap]` takes the tone curve's place below (one or the other runs, never both); without the HUD layer (HDR off, or a device without independent blending) its float frame finishes on the swapchain's 8 bits before the camera overlays, which then draw with the HUD as in SDR, and HDR output stays off. The camera overlays (vignette, pumpkin, powder snow, portal, nausea), post effects and entity outlines draw on light. After the post effects and before the camera overlays, the tone curve eases that light into the peak (`Hdr::tonemap`) in place of HDR lighting's own easing and the light sources' cap at the peak: up to 60% of the peak light stays as it is, and above it ITU-R BT.2390's EETF in PQ rolls it off, on its brightest channel so it keeps its hue. The HUD and menus then draw on an 8-bit target of their own, the world clipped to white, as they found the frame before (each draw clips at white as there), and write how much of the world they hide into an R8 coverage target. The last pass lays the HUD over the world's light by that coverage, in gamma as the HUD blends: light behind see-through HUD shows as vanilla weights the HUD over it, and none shows through what covers it. The menu blur blurs that picture, light above white included, and the menu draws over the result. UI Brightness scales the HUD's own part, not the world under it. A screenshot maps the world's light into SDR, unchanged up to 0.6 of paper white in linear light and smoothly rolled towards white above it, with one scale for all RGB channels to keep their hue (where Horizon lifts nothing, with HDR Lighting off or under a 0.2 pack, the world is clipped at white as SDR draws it); the HUD's own part stays as drawn, whatever UI Brightness is, over the mapped world by its coverage. The world's icon uses the same mapping without the HUD; the `-hdr.png` beside a screenshot keeps the whole HDR picture. `HORIZON_HDR_DEBUG=coverage` tints the coverage magenta for inspection.
+7 -1
View File
@@ -379,8 +379,10 @@ pub enum Row {
Toggle { label: &'static str, on: bool },
Cycle { label: &'static str, value: Label },
Slider { label: &'static str, value: f32, text: Label }, // value in 0..=1
Button { label: &'static str },
Button { label: &'static str, active: bool, half: bool, footer: bool }, // Row::button(..).half()...
Key { label: &'static str, key: String }, // a binding: `key.keyboard.z`
Text { text: String, quiet: bool, groups: bool }, // Row::text(..): white or grey, wrapped
Space(u8), // GUI pixels, on a page drawn as a screen
}
pub enum Label { Key(&'static str), Value(String) }
pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
@@ -390,6 +392,10 @@ pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
button on the title screen or the pause menu that opens the page. These buttons exist only when something
registered, so the vanilla menus keep vanilla's look. The page is drawn with the same widgets, tooltips,
narration, controller focus and `horizon-ctl` ids as the engine's pages.
- `Button` and `Text` are built with `Row::button` and `Row::text` and their options: `.active(false)` greys a
button, `.half()` puts two side by side, `.footer()` sets one beside Done on a page drawn as its own screen,
`.quiet()` greys text, `.groups()` breaks it only between `-`-separated groups (an invite code). In the options
shell, text is notes and space is nothing.
- Rows are rebuilt when the page is drawn or acted on, like the engine's own pages: no cost while no page is
open.
- The engine keeps native mods' pages (`Page::NativeMod`) as they are. Both kinds sit in one module that
+1 -11
View File
@@ -462,17 +462,7 @@ impl Launcher {
.into_any_element()
};
let about = card()
.child(index_row(
0,
tr("Data folder"),
Some(mono(short(&backend::data_dir()))),
Some(
button("about-open-data", "Open", Kind::Secondary, &t)
.on_click(|_, _, cx| crate::glass_toast::open(cx, &backend::data_dir()))
.into_any_element(),
),
&t,
))
.child(self.location_row(0, cx).mb(px(0.)))
.child(index_row(
0,
tr("Logs"),
+12 -1
View File
@@ -209,7 +209,18 @@ impl Launcher {
n,
tr("Data folder"),
Some(div().font_family(theme::MONO).child(short(&backend::data_dir())).into_any_element()),
Some(move_button.into_any_element()),
Some(
div()
.flex()
.gap(px(8.))
.child(
button("about-open-data", "Open", Kind::Secondary, &t).on_click(
|_, _, cx| crate::glass_toast::open(cx, &backend::data_dir()),
),
)
.child(move_button)
.into_any_element(),
),
&t,
))
.child(text(theme::CAPTION).text_color(t.muted).child(tr(
+1 -1
View File
@@ -46,7 +46,7 @@ Entity.dropAllLeashConnections port crates/horizon-client/src/game/leads.rs::Gam
Entity.canCollideWith inline crates/horizon-client/src/game/ghasts.rs::Game::player_entity_collisions gap: player: the entities a player cannot walk through are boats and happy ghasts only; a live shulker (canBeCollidedWith) is not solid, so the player walks through it
Entity.getDefaultPassengerAttachmentPoint inline crates/horizon-client/src/mobs/riders.rs::passenger_attachment one point per vehicle, so the passenger index is always 0; default is the top of the box
Entity.ejectPassengers port crates/horizon-client/src/game/boat_passengers.rs::Game::eject_boat_mobs gap: minor: boats place riders at the dismount spot; when a mob vehicle dies sync_riders only lets go and the rider stays where it was seated
Entity.removeVehicle inline crates/horizon-client/src/game/commands/world_cmds.rs::Game::dismount_mob gap: minor: no entity_dismount game event, so sculk sensors and wardens never hear a dismount
Entity.removeVehicle port crates/horizon-client/src/game/sculk.rs::Game::mount_events `startRiding`/`removeVehicle`: `entity_mount` at the new vehicle and `entity_dismount` at the old one, derived from the vehicle the rider had last tick, for players and mobs
Entity.moveOrInterpolateTo port crates/horizon-world/src/stepped_interpolation.rs::SteppedInterpolation::interpolate_to living entities from a server; boats and other objects use a three tick Lerp
Entity.getHandHoldingItemAngle port crates/horizon-client/src/game/fireworks.rs::Game::rocket_hand_angle ticked in #3514; `Entity.getHandHoldingItemAngle` is cited at crates/horizon-client/src/game/fireworks.rs:241
Entity.teleportToPortalDestination inline crates/horizon-client/src/game/nether_portal.rs::Game::send_through_portal may_enter checks isAllowedToEnterPortal; the entity is saved and waits for its dimension
1 Entity.<init> port crates/horizon-client/src/mobs.rs::Mob::new mobs only; players and projectiles are built by their own structs and the synched defaults (flags, air, name, pose) are plain fields
46 Entity.canCollideWith inline crates/horizon-client/src/game/ghasts.rs::Game::player_entity_collisions gap: player: the entities a player cannot walk through are boats and happy ghasts only; a live shulker (canBeCollidedWith) is not solid, so the player walks through it
47 Entity.getDefaultPassengerAttachmentPoint inline crates/horizon-client/src/mobs/riders.rs::passenger_attachment one point per vehicle, so the passenger index is always 0; default is the top of the box
48 Entity.ejectPassengers port crates/horizon-client/src/game/boat_passengers.rs::Game::eject_boat_mobs gap: minor: boats place riders at the dismount spot; when a mob vehicle dies sync_riders only lets go and the rider stays where it was seated
49 Entity.removeVehicle inline port crates/horizon-client/src/game/commands/world_cmds.rs::Game::dismount_mob crates/horizon-client/src/game/sculk.rs::Game::mount_events gap: minor: no entity_dismount game event, so sculk sensors and wardens never hear a dismount `startRiding`/`removeVehicle`: `entity_mount` at the new vehicle and `entity_dismount` at the old one, derived from the vehicle the rider had last tick, for players and mobs
50 Entity.moveOrInterpolateTo port crates/horizon-world/src/stepped_interpolation.rs::SteppedInterpolation::interpolate_to living entities from a server; boats and other objects use a three tick Lerp
51 Entity.getHandHoldingItemAngle port crates/horizon-client/src/game/fireworks.rs::Game::rocket_hand_angle ticked in #3514; `Entity.getHandHoldingItemAngle` is cited at crates/horizon-client/src/game/fireworks.rs:241
52 Entity.teleportToPortalDestination inline crates/horizon-client/src/game/nether_portal.rs::Game::send_through_portal may_enter checks isAllowedToEnterPortal; the entity is saved and waits for its dimension
+1 -1
View File
@@ -62,7 +62,7 @@ LivingEntity.dropFromShearingLootTable port crates/horizon-client/src/game/creat
LivingEntity.getFallDamageSound inline crates/horizon-client/src/mobs/landing.rs::MobLanding::living_fall_sound a monster gets entity.hostile.big_fall and entity.hostile.small_fall, anything else the generic pair; a player's comes from landing.rs
LivingEntity.getHitbox missing minor: a rider's hitbox is not cut at its seat height; spear and melee tests use the full box
LivingEntity.postPiercingAttack port crates/horizon-client/src/game/enchant_effects.rs::Game::post_piercing_attack Lunge's dash after a jab
LivingEntity.trapdoorUsableAsLadder port crates/horizon-world/src/climbing.rs::on_climbable gap: minor: the player only; a mob's ai.rs::on_climbable checks the climbable tag at its feet, no open trapdoor over a ladder
LivingEntity.trapdoorUsableAsLadder port crates/horizon-world/src/climbing.rs::on_climbable the climbable tag, or an open trapdoor directly over a ladder with the same facing: `climbing::on_climbable` for the player and `Mob::on_climbable` for mobs
LivingEntity.isAlive port crates/horizon-client/src/mobs.rs::Mob::alive not removed and health above zero
LivingEntity.setIgnoreFallDamageFromCurrentImpulse port crates/horizon-world/src/living_fall.rs::ImpulseContext::set_ignore_fall_damage 40 ticks of grace
LivingEntity.getDamageAfterArmorAbsorb inline crates/horizon-client/src/mobs.rs::Mob::damage_with the player's armor wear is health.rs hurt_armor; the mob path also scales armor effectiveness (breach)
1 Interaction.defineSynchedData port crates/horizon-client/src/game/interactions.rs::RemoteInteraction::new width 1.0, height 1.0, response false
62 LivingEntity.getFallDamageSound inline crates/horizon-client/src/mobs/landing.rs::MobLanding::living_fall_sound a monster gets entity.hostile.big_fall and entity.hostile.small_fall, anything else the generic pair; a player's comes from landing.rs
63 LivingEntity.getHitbox missing minor: a rider's hitbox is not cut at its seat height; spear and melee tests use the full box
64 LivingEntity.postPiercingAttack port crates/horizon-client/src/game/enchant_effects.rs::Game::post_piercing_attack Lunge's dash after a jab
65 LivingEntity.trapdoorUsableAsLadder port crates/horizon-world/src/climbing.rs::on_climbable gap: minor: the player only; a mob's ai.rs::on_climbable checks the climbable tag at its feet, no open trapdoor over a ladder the climbable tag, or an open trapdoor directly over a ladder with the same facing: `climbing::on_climbable` for the player and `Mob::on_climbable` for mobs
66 LivingEntity.isAlive port crates/horizon-client/src/mobs.rs::Mob::alive not removed and health above zero
67 LivingEntity.setIgnoreFallDamageFromCurrentImpulse port crates/horizon-world/src/living_fall.rs::ImpulseContext::set_ignore_fall_damage 40 ticks of grace
68 LivingEntity.getDamageAfterArmorAbsorb inline crates/horizon-client/src/mobs.rs::Mob::damage_with the player's armor wear is health.rs hurt_armor; the mob path also scales armor effectiveness (breach)
+1 -1
View File
@@ -116,7 +116,7 @@ Ravager.strongKnockback inline crates/horizon-client/src/mobs/ravager.rs::roar t
Ravager.addRoarParticleEffects inline crates/horizon-client/src/mobs/ravager.rs::roar the poof particles are emitted in roar
Shulker.registerGoals port crates/horizon-client/src/mobs/shulker.rs::register gap: minor: no defense target goal (priority 3)
Shulker.defineSynchedData skip:other attach face, peek and colour are Mob extra fields
Shulker.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt AttachFace, Peek and Color are read there
Shulker.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt AttachFace wraps through Direction.from3DDataValue, Peek and Color are raw getByteOr bytes
Shulker.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt AttachFace, Peek and Color are written there
Shulker.findNewAttachment inline crates/horizon-client/src/mobs/shulker.rs::tick keeps its face or teleports in tick, through attachable and teleport
Shulker.makeBoundingBox port crates/horizon-client/src/mobs/shulker.rs::progress_aabb
1 ItemFrame.defineSynchedData skip:other entity data defaults; ItemFrame starts with item None and rotation 0 in place_item_frame
116 Ravager.addRoarParticleEffects inline crates/horizon-client/src/mobs/ravager.rs::roar the poof particles are emitted in roar
117 Shulker.registerGoals port crates/horizon-client/src/mobs/shulker.rs::register gap: minor: no defense target goal (priority 3)
118 Shulker.defineSynchedData skip:other attach face, peek and colour are Mob extra fields
119 Shulker.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt AttachFace, Peek and Color are read there AttachFace wraps through Direction.from3DDataValue, Peek and Color are raw getByteOr bytes
120 Shulker.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt AttachFace, Peek and Color are written there
121 Shulker.findNewAttachment inline crates/horizon-client/src/mobs/shulker.rs::tick keeps its face or teleports in tick, through attachable and teleport
122 Shulker.makeBoundingBox port crates/horizon-client/src/mobs/shulker.rs::progress_aabb
+1 -1
View File
@@ -31,7 +31,7 @@ Projectile.onAboveBubbleColumn port crates/horizon-client/src/projectiles.rs::Pr
Projectile.onInsideBubbleColumn port crates/horizon-client/src/projectiles.rs::Projectile::bubble_column_push unclamped 0.06 up (0.03 down) inside a column; arrows in arrows.rs::Ticker::bubble_column
Projectile.canBreakBlockInAdventureMode port crates/horizon-client/src/game/creatures.rs::Game::projectile_breaks_block gap: minor: the shooter's gamemode and kind decide it; a stack's `can_break` predicate is not consulted
Projectile.spawnProjectileFromRotation port crates/horizon-client/src/projectiles.rs::Projectile::shoot_from_rotation gap: minor: the aim and inherited velocity only; the add-to-world step is the caller's
Projectile.spawnProjectile inline crates/horizon-client/src/game/creatures.rs::Game::throw_item each shooter adds its own projectile (throw_item, shoot_crossbow, shoot_projectile_item, the bow in game.rs); throw_item is the player throw path
Projectile.spawnProjectile inline crates/horizon-client/src/game/creatures.rs::Game::throw_item each shooter adds its own projectile (throw_item, shoot_crossbow, shoot_projectile_item, the bow in game/use_hand.rs::release_bow); throw_item is the player throw path
Projectile.applyOnProjectileSpawned port crates/horizon-client/src/game/enchant_effects.rs::Game::ignite_spawned_arrow gap: minor: Flame ignite only; Piercing and Punch go through Projectile::set_bonus
Projectile.hitTargetOrDeflectSelf port crates/horizon-client/src/game/creatures.rs::Game::tick_projectiles gap: minor: deflection is checked for breezes only; the onHit dispatch is creatures.rs projectile_hit
Projectile.onRedirectProjectile port crates/horizon-client/src/game/deflect.rs::Game::deflect gap: minor: the player's attack turns the projectile; a projectile hitting another redirectable one is not modelled
1 FireworkRocketEntity.<init> port crates/horizon-client/src/game/fireworks.rs::Game::new_rocket launch velocity triangle and lifetime 10*(1+flight)+randoms; the owner and attached target are set by the callers
31 Projectile.onInsideBubbleColumn port crates/horizon-client/src/projectiles.rs::Projectile::bubble_column_push unclamped 0.06 up (0.03 down) inside a column; arrows in arrows.rs::Ticker::bubble_column
32 Projectile.canBreakBlockInAdventureMode port crates/horizon-client/src/game/creatures.rs::Game::projectile_breaks_block gap: minor: the shooter's gamemode and kind decide it; a stack's `can_break` predicate is not consulted
33 Projectile.spawnProjectileFromRotation port crates/horizon-client/src/projectiles.rs::Projectile::shoot_from_rotation gap: minor: the aim and inherited velocity only; the add-to-world step is the caller's
34 Projectile.spawnProjectile inline crates/horizon-client/src/game/creatures.rs::Game::throw_item each shooter adds its own projectile (throw_item, shoot_crossbow, shoot_projectile_item, the bow in game.rs); throw_item is the player throw path each shooter adds its own projectile (throw_item, shoot_crossbow, shoot_projectile_item, the bow in game/use_hand.rs::release_bow); throw_item is the player throw path
35 Projectile.applyOnProjectileSpawned port crates/horizon-client/src/game/enchant_effects.rs::Game::ignite_spawned_arrow gap: minor: Flame ignite only; Piercing and Punch go through Projectile::set_bonus
36 Projectile.hitTargetOrDeflectSelf port crates/horizon-client/src/game/creatures.rs::Game::tick_projectiles gap: minor: deflection is checked for breezes only; the onHit dispatch is creatures.rs projectile_hit
37 Projectile.onRedirectProjectile port crates/horizon-client/src/game/deflect.rs::Game::deflect gap: minor: the player's attack turns the projectile; a projectile hitting another redirectable one is not modelled