Merge origin/main into fix/villager-stand-up

Keeps the villager wake cases and #9775's used-stat cases as two
separate streams in the use_item_on harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-05 14:03:09 +01:00
commit 8bbedb4d4d
255 files changed
+10942 -989

No files matched your search

+12
View File
@@ -242,6 +242,18 @@ jobs:
$output = & $launcher --version 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
Write-Output ($output -join "`n")
- name: Build a native-mod game with the launcher's managed toolchain
working-directory: launcher
shell: pwsh
env:
HORIZON_LAUNCHER_HOME: ${{ runner.temp }}\horizon-native-arm64-toolchain
HORIZON_NATIVE_TARGET_DIR: ${{ runner.temp }}\horizon-native-arm64-toolchain\target
run: |
$ErrorActionPreference = 'Stop'
cargo test --release --locked --target aarch64-pc-windows-msvc --lib rust_toolchain::tests::managed_arm64_toolchain_builds_the_game -- --ignored --exact --nocapture
$client = Join-Path $env:HORIZON_NATIVE_TARGET_DIR 'fast/horizon-client.exe'
python ../tools/check_pe_arch.py arm64 $client
python ../tools/check_imports.py $client
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
+15 -2
View File
@@ -165,11 +165,15 @@ jobs:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
- name: Sign the checksums for the nightly channel (SHA256SUMS.sig) and for this build (SHA256SUMS.v2.sig)
# SHA256SUMS.sig is for launchers from before v2 (signed for "nightly"); SHA256SUMS.v2.sig is signed for
# nightly-<date>-<commit>, the version the release title names, which current launchers and servers
# require (docs/security.md, "Nightly signatures"). The publish step titles the release from the same values.
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
ALSO_FOR: nightly-${{ needs.pick.outputs.date }}-${{ needs.pick.outputs.short }}
run: |
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
@@ -208,12 +212,17 @@ jobs:
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
- name: The signatures, checked as launchers check them
env:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
DATE: ${{ needs.pick.outputs.date }}
SHORT: ${{ needs.pick.outputs.short }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
# The version's own signature, which current launchers require: the title below names the same version.
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "nightly-${DATE}-${SHORT}" \
--signature SHA256SUMS.v2.sig --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
# only reports what a real run would do (that is how a blocker's fix is tried).
@@ -230,12 +239,16 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
SHORT: ${{ needs.pick.outputs.short }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
[[ -f dist/SHA256SUMS.v2.sig ]] && signature+=(dist/SHA256SUMS.v2.sig)
# The title is what launchers parse (strictly) into the version SHA256SUMS.v2.sig is signed for.
[[ "${SHA:0:7}" == "$SHORT" ]] || { echo "::error::the build's commit isn't the one signed"; exit 1; }
# The ARM64 archive only when its build succeeded (the sign job left it out of SHA256SUMS otherwise).
arm64=()
if [[ -f dist/horizon-windows-arm64.zip ]]; then
+4 -2
View File
@@ -9,8 +9,10 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
folders in `HORIZON_HOME` (logs, crash reports, telemetry consent, recordings; not saves), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Saves, `options.txt` and
caches go in the game folder, by default the working directory: start the game from a scratch folder with
`--game-dir` pointing at it, or they land in the repo root, hidden by `.gitignore`. For the
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
+14 -33
View File
@@ -206,7 +206,7 @@ Manage a server from a console, ban and pardon players, and drive it from other
<details><summary>Technical</summary>
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The last pieces merged: bans edited while the server is stopped ([#9767](https://github.com/horizon-rust/horizon/issues/9767)) and the MCP tools' contracts pinned by a test ([#9748](https://github.com/horizon-rust/horizon/issues/9748)). The panel's later features and vanilla's management protocol are their own item (server-panel-management). [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
@@ -396,6 +396,16 @@ A Datapacks tab beside Mods and Resource packs: install into a chosen world's da
### Servers and multiplayer
#### Server panel extras and vanilla's management protocol
More of a server managed from its web panel (bans, worlds, logs and crash reports, modpacks), and vanilla's own server-management tools working with a Horizon server.
<details><summary>Technical</summary>
The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. Split from server-admin. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
@@ -600,16 +610,6 @@ An output device list in Music & Sounds, falling back to the system default. [#1
</details>
#### Play in your language
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn.
</details>
### Rendering
#### A light default shader pack
@@ -860,30 +860,11 @@ More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/pe
</details>
### Gameplay parity
#### End city banners keep their pattern
Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526). The templates' block entities now keep their patterns ([#1928](https://github.com/horizon-rust/horizon/issues/1928)).
</details>
#### Enchanted loot in End cities
Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660). The loot's enchantment functions now reach every table that rolls ([#1911](https://github.com/horizon-rust/horizon/issues/1911), [#2037](https://github.com/horizon-rust/horizon/issues/2037)).
</details>
## Recently shipped
- **Enchanted loot in End cities** (v0.2.73): Armour and tools in End city chests should come enchanted, as in vanilla. [#1437](https://github.com/horizon-rust/horizon/issues/1437) · [#1660](https://github.com/horizon-rust/horizon/issues/1660) · [#1911](https://github.com/horizon-rust/horizon/issues/1911) · [#2037](https://github.com/horizon-rust/horizon/issues/2037)
- **End city banners keep their pattern** (v0.2.67): Banners in End cities show as plain magenta instead of with their pattern. [#1438](https://github.com/horizon-rust/horizon/issues/1438) · [#526](https://github.com/horizon-rust/horizon/issues/526) · [#1928](https://github.com/horizon-rust/horizon/issues/1928)
- **Play in your language** (v0.2.65): Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English. [#1806](https://github.com/horizon-rust/horizon/issues/1806) · [#1594](https://github.com/horizon-rust/horizon/issues/1594) · [#1672](https://github.com/horizon-rust/horizon/issues/1672)
- **Chickens flap while they fall** (v0.2.67): Chickens should flap their wings while they fall, not stand still in the air. [#1706](https://github.com/horizon-rust/horizon/issues/1706) · [#1892](https://github.com/horizon-rust/horizon/issues/1892)
- **Ctrl+Backspace and scroll bars in menus** (v0.2.67): Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable. [#1764](https://github.com/horizon-rust/horizon/issues/1764) · [#1782](https://github.com/horizon-rust/horizon/issues/1782) · [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
- **A pointing hand over buttons** (v0.2.67): The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3. [#1347](https://github.com/horizon-rust/horizon/issues/1347) · [#1897](https://github.com/horizon-rust/horizon/issues/1897)
+5 -4
View File
@@ -174,12 +174,13 @@ BlueMap is licensed under the MIT license:
## JOML
`crates/horizon-client-render/src/camera.rs` (`joml::Mat4::rotation`, `invert_orthonormal`) and
`crates/horizon-client-render/src/gfx/pack_uniforms.rs` (`invert_generic`, the formula of
`pack_projection`) adapt the float arithmetic of `Matrix4f.rotation(Quaternionfc)`,
`invertOrthonormal`, `invertGenericNonThis` and `setPerspective` from
`crates/horizon-client-render/src/gfx/pack_uniforms.rs` (`invert_generic`, `invert_affine`, the
formulas of `pack_projection` and `shadow_ortho`) adapt the float arithmetic of
`Matrix4f.rotation(Quaternionfc)`, `invertOrthonormal`, `invertGenericNonThis`, `invertAffine`,
`setPerspective` and `setOrtho` from
[JOML](https://github.com/JOML-CI/JOML/tree/faf9eef2511242122beb6055fe3da92daed214c5) 1.10.9, the
library vanilla 26.3 uses, so that shader packs get the same matrices to the last bit. Changes:
ported to Rust; `invert_generic` and `pack_projection` drop JOML's property flags, and
ported to Rust; the pack_uniforms.rs functions drop JOML's property flags, and
`pack_projection` works the formula in doubles. The copyright line and license notice also head
both files.
+1 -1
View File
@@ -93,7 +93,7 @@ pub fn kind_of(path: &Path) -> Option<Kind> {
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"zip" => Some(horizon_mods::index::zip_kind(path)),
"hshader" => Some(Kind::Shader),
_ => None,
}
+25 -3
View File
@@ -90,9 +90,20 @@ pub fn effect_lines<'a>(
);
}
let instant = effect(e).is_some_and(|d| d.instant);
let scaled = (duration as f32 * scale) as i32;
if !instant && scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
if !instant {
if duration == -1 {
// MobEffectInstance.isInfiniteDuration: formatDuration says
// the infinite sign, not a time.
line = format!(
"{line} ({})",
translate("effect.duration.infinite").unwrap_or_else(|| "∞".to_owned())
);
} else {
let scaled = (duration as f32 * scale) as i32;
if scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
}
}
}
(line, effect(e).map_or(Category::Neutral, |d| d.category))
})
@@ -562,4 +573,15 @@ mod tests {
assert!(lines[0].0.starts_with("Замедление IV ("), "{lines:?}");
assert!(lines[1].0.starts_with("Сопротивление III ("), "{lines:?}");
}
#[test]
fn infinite_effects_say_the_infinite_sign() {
// MobEffectInstance.isInfiniteDuration: a -1 duration says the
// infinite sign, not a time; a short one still says nothing.
let translate = |key: &str| (key == "effect.duration.infinite").then(|| "∞".to_owned());
let lines = effect_lines([("minecraft:resistance", -1, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance (∞)");
let lines = effect_lines([("minecraft:resistance", 15, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance");
}
}
+58
View File
@@ -307,6 +307,15 @@ fn world_pass_after_culling<'e>(
..Default::default()
})
}
/// The point a `shaders/` pack's terrain is drawn relative to: the camera with the fraction of
/// each coordinate rounded to the nearest 1/65536 block. Read back with a probe pack of ours,
/// Iris 1.11.7's terrain `gl_Vertex` is the block's position minus this, for cameras from 13
/// to 40000 blocks out (#8464); `cameraPosition` and the matrices keep the exact camera. Off by
/// up to 2^-17 blocks, a fragment on a texel boundary of the shadow map reads the other texel.
fn pack_terrain_camera(camera: DVec3) -> DVec3 {
let whole = camera.floor();
whole + ((camera - whole) * 65536.0).round() / 65536.0
}
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
@@ -7665,6 +7674,21 @@ impl Renderer {
height(b).total_cmp(&height(a))
});
}
// A `shaders/` pack that draws the terrain, or its shadow, sees it as Iris does.
let shaders_pack = !frame.title
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_terrain() || p.has_shadow());
if shaders_pack {
let from = pack_terrain_camera(camera);
for v in visible.iter_mut().chain(shadow_visible.iter_mut()) {
v.origin = resident::origin(v.key, self.sections.min_y(), from)
.as_vec3()
.to_array();
}
}
if self.animated_textures {
self.active_animated_layers.fill(0);
for v in &visible {
@@ -11142,6 +11166,13 @@ impl Renderer {
self.land_frame();
self.wait_frames();
}
/// The frame on the submit thread submitted: a surface reconfigure waits for the
/// device and breaks if a frame is submitted while it waits, and the compositor
/// copies views after them. (The end of a frame already lands the frames of the
/// turn before; this is the turn's own.)
pub fn wait_submissions(&mut self) {
self.queue.wait();
}
fn wait_frames(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
@@ -12325,6 +12356,33 @@ mod tests {
assert_eq!(chunk_visibility(0, 0), 1.0, "Off: whole at once");
assert_eq!(chunk_visibility(u64::MAX, 750), 1.0);
}
/// Iris 1.11.7's terrain `gl_Vertex`, read back by a probe pack of ours, is each block's
/// position minus the camera with its fraction rounded to 1/65536 block: these are the
/// fractions (in 65536ths) it implied, for eyes at y 69.62 and 82.92, at x 100.3 and
/// 40000.3, at z -13.7 and 20000.7, and off the grid by a fraction of a 65536th.
#[test]
fn a_shader_pack_s_terrain_is_drawn_from_the_camera_in_65536ths() {
let eye = |x: f64, feet: f64, z: f64| glam::DVec3::new(x, feet + f64::from(1.62f32), z);
let cases = [
(eye(13.5, 68.0, -13.5), [32768.0, 40632.0, 32768.0]),
(eye(13.5, 81.3, -13.5), [32768.0, 60293.0, 32768.0]),
(eye(100.3, 90.0, -13.7), [19661.0, 40632.0, 19661.0]),
(eye(40000.3, 120.0, 20000.7), [19661.0, 40632.0, 45875.0]),
(
eye(
13.0 + 0.7 / 8192.0,
81.0 + 0.3 / 8192.0,
-13.0 - 0.6 / 8192.0,
),
[6.0, 40635.0, 65531.0],
),
];
for (camera, sixty_fifths) in cases {
let from = super::pack_terrain_camera(camera);
let fraction = (from - camera.floor()) * 65536.0;
assert_eq!(fraction.to_array(), sixty_fifths, "{camera}");
}
}
#[test]
fn section_instance_carries_the_fade_in_the_step() {
use super::section_instance;
@@ -1614,7 +1614,7 @@ file = \"b.wgsl\"
mapped_at_creation: false,
});
// What the material stage stores, as the bits of floats: the normal +x (octahedral
// (1, 0), two snorm16), smoothness 0.5, data 0.25 and the fragment's depth 0.5.
// (1, 0), two snorm16), smoothness 0.75, data 0.25 and the fragment's depth 0.5.
let writer = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
@@ -1623,7 +1623,7 @@ file = \"b.wgsl\"
}
@fragment fn fs() -> @location(0) vec4<u32> {
return bitcast<vec4<u32>>(vec4<f32>(
bitcast<f32>(pack2x16snorm(vec2<f32>(1.0, 0.0))), 0.5, 0.25, 0.5));
bitcast<f32>(pack2x16snorm(vec2<f32>(1.0, 0.0))), 0.75, 0.25, 0.5));
}"
.into(),
),
@@ -1723,8 +1723,9 @@ file = \"b.wgsl\"
buffer.unmap();
pixel
};
// Present where the world's depth is the stored one: +x, 0.5, 0.25 as 8-bit.
assert_eq!(read(0.5), [255, 128, 64, 255]);
// Present where the world's depth is the stored one: +x, 0.75, 0.25 as 8-bit. No value
// here sits halfway between two 8-bit levels, where drivers round either way.
assert_eq!(read(0.5), [255, 191, 64, 255]);
// Hidden by something nearer drawn since: the stored values no longer show.
assert_eq!(read(0.25), [0, 0, 0, 0]);
}
@@ -159,9 +159,9 @@ const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
/// shadow, and ends past the build height and the slant across the map below the camera.
/// the next cell. Its depth starts 356 blocks toward the light (256 further than `shaders/`
/// packs' default), so the ground over a deep cave still casts its shadow, and ends past the
/// build height and the slant across the map below the camera.
pub fn camera(settings: &Settings) -> Shadow {
Shadow {
resolution: settings.resolution,
@@ -169,8 +169,8 @@ pub fn camera(settings: &Settings) -> Shadow {
render_mul: 1.0,
interval: SNAP,
euclidean_snap: true,
near: 256.0,
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
near: Some(-356.0),
far: Some(2.0 * settings.distance + 384.0),
..Shadow::default()
}
}
@@ -607,12 +607,16 @@ impl PackAtlas {
} else {
"1.0"
};
// A vertex's place in its section goes down to the 32768th of a block below it: read back
// with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` has that for every position
// we read (water tops at 8/9 - 0.001, plants' offsets), on all three axes (#8464). A fit,
// not Iris's code. Whole and half blocks, and every 1/16, are on that grid already.
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
{cell}\
hz_scale = vec2({:?}, {:?}) * {side};\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_Vertex = vec4(floor(hz_v_pos * 32768.0) / 32768.0 + hz_origin.xyz, 1.0);\n \
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
hz_mid = vec2(0.5);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_uv_scale, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
@@ -674,6 +678,7 @@ impl PackAtlas {
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: false,
}
}
}
@@ -171,13 +171,98 @@ fn flat_draws_map_uvs_onto_a_stitched_sprite() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Read back with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` (camera on the block
/// grid) puts a water top, 8/9 - 0.001 of a block up, at 29094/32768, and plants offset by 13/15
/// and 0.1 of a block at 28398/32768 and 3276/32768 (#8464): each 32768th below the position.
/// Whole blocks and sixteenths stay where they are.
#[test]
fn terrain_vertices_go_down_to_the_32768th_below_them() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 16).unwrap();
queue.submit([encoder.finish()]);
let water = 8.0f32 / 9.0 - 0.001;
let cases = [
([0.1, water, 13.0 / 15.0], [3276.0, 29094.0, 28398.0]),
([13.0 / 15.0, 0.1, water], [28398.0, 3276.0, 29094.0]),
([1.0, 0.5, 15.9375], [32768.0, 16384.0, 522240.0]),
];
let vertices: Vec<_> = cases
.iter()
.map(|&(pos, _)| TerrainVertex::new(pos, [0.0, 0.0], 0x8000, u32::MAX, 0))
.collect();
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
let mut failures = Vec::new();
for flat in [true, false] {
for shadow in [false, true] {
let read = sample_probe(
&device,
&queue,
&atlas,
&vertices,
origin,
[flat, shadow],
"gl_Vertex",
);
for ((pos, expected), got) in cases.iter().zip(&read) {
let got = [got[0], got[1], got[2]].map(|v| v * 32768.0);
if got != *expected {
failures.push(format!(
"{pos:?}, flat={flat} shadow={shadow}: {got:?} != {expected:?}"
));
}
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
vertices: &[TerrainVertex],
scale: f32,
flags: [bool; 2],
) -> Vec<[f32; 4]> {
sample_probe(
device,
queue,
atlas,
vertices,
scale,
flags,
"vec4(mc_midTexCoord, gl_MultiTexCoord0.xy)",
)
}
/// What the pack's vertex stage sets `probe` to (`expression`), for each of `vertices`.
fn sample_probe(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
vertices: &[TerrainVertex],
scale: f32,
[flat, shadow]: [bool; 2],
expression: &str,
) -> Vec<[f32; 4]> {
let pp = |code| horizon_shaders::Preprocessed {
code,
@@ -185,7 +270,7 @@ fn sample(
};
let vertex = pp(format!(
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
probe = vec4(mc_midTexCoord, gl_MultiTexCoord0.xy);\n\
probe = {expression};\n\
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
2. / vertices.len() as f32,
));
@@ -102,6 +102,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
reference: 0.1,
}),
item_id_declared: false,
far_depth: false,
}
}
@@ -164,6 +165,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: false,
}
}
@@ -51,6 +51,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
after,
alpha_test: None,
item_id_declared: false,
far_depth: false,
}
}
@@ -37,6 +37,9 @@ mod world_target_tests;
#[cfg(test)]
mod higher_shadowcolor_tests;
#[cfg(test)]
mod single_side_tests;
#[cfg(test)]
mod shadow_faces_tests;
@@ -49,6 +52,9 @@ mod weather_depth_tests;
#[cfg(test)]
mod custom_type_tests;
#[cfg(test)]
mod sky_depth_tests;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
@@ -605,6 +611,8 @@ pub struct PackPost {
rgb: u16,
rgb_unset: bool,
used: u16,
/// Buffers some pass draws into the "alt" side of or flips; the others' B is their A.
doubled: u16,
/// `size.buffer.*`.
sizes: [Option<horizon_shaders::BufferSize>; BUFFERS],
/// Buffers bound as images, and those also copied for a pass that samples them.
@@ -1300,6 +1308,36 @@ fn bindings(
}
None => {
let mut src = source(&s.name, group.shadow(), shadow.is_some(), watershadow);
// A sampler the game gives no texture reads texture unit 0, as do the atlas's
// names: the atlas in gbuffers programs, `colortex0` in deferred, composite and
// final (OptiFine's shaders.txt). Iris 1.11.7 read the atlas through a name of
// ours in gbuffers_terrain, and the scene in `colortex0` through such a name and
// through `gtexture`, `tex` and `texture` in those three, with or without
// `colortex0` declared (probe packs of ours, #9307). Translucent programs and
// the other groups weren't measured and keep the stand-in.
let unit_0 = match group {
Group::Gbuffers => Some(Source::Atlas),
Group::Deferred | Group::Composite | Group::Final => Some(Source::Color(0)),
_ => None,
};
// A name of the game's that has no texture here (a shadow sampler with no
// shadow pass) stays blank; so does a sampler that isn't 2D, and one under a
// `texture.<stage>.<name>` left unbound (#9064's probe read a 16-texel-wide
// texture there in final, undiagnosed).
let unknown = src == Source::Dummy
&& source(&s.name, false, true, false) == Source::Dummy
&& s.ty == "sampler2D"
&& !textures.iter().any(|t| t.name == s.name);
// colortex0 to colortex3 (gcolor, gdepth, gnormal, composite) are no gbuffers
// samplers in shaders.txt's table: Iris 1.11.7 read the atlas through each of
// colortex0-3 and gnormal in gbuffers_terrain (our probe, #9307).
let not_gbuffers =
group == Group::Gbuffers && matches!(src, Source::Color(n) if n < 4);
if let Some(unit_0) = unit_0
&& (src == Source::Atlas || unknown || not_gbuffers)
{
src = unit_0;
}
let blank = match src {
// The shadow pass writes the shadow map.
Source::ShadowDepth(..) => group == Group::Shadow,
@@ -2006,6 +2044,9 @@ impl PackPost {
let mask =
|flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
let mut used = 1u16;
// Buffers some pass draws into the "alt" side of, or flips: only these get a B texture.
let mut doubled = mask(&post.deferred_pre) | mask(&post.composite_pre);
let mut shadow_doubled = 0u16;
let draws = DrawUniforms::new(device);
// The shadow buffers' formats when the shadow pass runs; their programs see them as
// buffers 0 to 7.
@@ -2164,11 +2205,29 @@ impl PackPost {
));
return None;
}
// Composite-style programs draw into the "alt" side of what they write; gbuffers,
// shadow and `final` programs write in place, compute programs through images.
let alt = if geometry.is_none()
&& group != Group::Final
&& matches!(pass.pipeline, Pipeline::Render(_))
{
pass.written
} else {
0
};
if in_shadow {
shadow_doubled |= alt;
} else {
doubled |= alt;
}
for p in std::iter::once(&pass).chain(&pass.compute) {
let (stage, flipped) = pack.released.get();
pack.released.set((stage, flipped | p.flip));
if !in_shadow {
if in_shadow {
shadow_doubled |= p.flip;
} else {
used |= p.reads | p.written | p.flip;
doubled |= p.flip;
}
storage |= image_buffers(&p.images);
image_copied |= p.image_copies;
@@ -2548,18 +2607,24 @@ impl PackPost {
&shadow_config,
f,
colors,
shadow_doubled & colors,
depth1,
linear,
);
notes.push(format!(
"shadow map {0}x{0} over {2} blocks, sunPathRotation {3}, shadowcolor buffers {colors:#b}{1}",
"shadow map {0}x{0} over {2} blocks, sunPathRotation {3}, shadowcolor buffers {colors:#b} (two textures: {4:#b}){1}",
t.size,
if depth1 { ", shadowtex1" } else { "" },
shadow_config.distance,
shadow_config.sun_path_rotation,
shadow_doubled & colors,
));
t
});
log::info!(
"shader pack: colortex buffers {used:#b}, with two textures {:#b}",
doubled & used
);
// Any program can leave pixels untouched. Composite-style passes also write the
// alternate image, so their first write does not replace the main image's clear.
let mut cleared = Vec::new();
@@ -2769,6 +2834,7 @@ impl PackPost {
rgb,
rgb_unset: true,
used,
doubled: doubled & used,
storage,
copied,
sizes,
@@ -4064,8 +4130,16 @@ impl PackPost {
if i != 0 && self.clears[i].is_none() {
attachment |= wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST;
}
let sides = [0, 1]
.map(|_| texture_sized(&label, self.formats[i], attachment, levels, sizes[i]));
let side = || texture_sized(&label, self.formats[i], attachment, levels, sizes[i]);
let a = side();
let sides = [
a.clone(),
if self.doubled & 1 << i != 0 {
side()
} else {
a
},
];
if mipped && let Some((_, layout)) = self.down.get(&self.formats[i]) {
mips[i] = Some(sides.each_ref().map(|t| {
Mips {
@@ -4310,8 +4384,8 @@ impl PackPost {
shadow.interval.to_bits().hash(&mut hasher);
shadow.sun_path_rotation.to_bits().hash(&mut hasher);
shadow.fov.map(|f| f.to_bits()).hash(&mut hasher);
shadow.near.to_bits().hash(&mut hasher);
shadow.far.to_bits().hash(&mut hasher);
shadow.near.map(f32::to_bits).hash(&mut hasher);
shadow.far.map(f32::to_bits).hash(&mut hasher);
for hf in &shadow.hardware_filtering {
hf.hash(&mut hasher);
}
@@ -4501,7 +4575,10 @@ impl PackPost {
store: wgpu::StoreOp::Store,
};
for b in (0..BUFFERS).filter(|&b| self.rgb & 1 << b != 0) {
for alt in [false, true] {
for alt in [false, true]
.into_iter()
.take(1 + usize::from(self.doubled & 1 << b != 0))
{
if let Some(v) = view(self.main, b, alt) {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack three-channel start"),
@@ -4534,7 +4611,8 @@ impl PackPost {
let max_attachments = (limits.max_color_attachments as usize).min(8);
// The buffers the blit doesn't clear, and the other texture of every cleared buffer.
let alt_clears = (0..BUFFERS)
.filter(|&b| self.clears[b].is_some() && view(self.main, b, true).is_some())
.filter(|&b| self.clears[b].is_some() && self.doubled & 1 << b != 0)
.filter(|&b| view(self.main, b, true).is_some())
.map(|b| (b, true));
let mut by_size: Vec<([u32; 2], Vec<(usize, bool)>)> = Vec::new();
for (b, alt) in self
@@ -5490,3 +5568,9 @@ mod const_offset_tests;
#[cfg(test)]
mod integer_colortex0_tests;
#[cfg(test)]
mod atlas_name_post_tests;
#[cfg(test)]
mod unknown_sampler_terrain_tests;
@@ -0,0 +1,74 @@
//! In deferred, composite and final programs the atlas's sampler names (`gtexture`, `tex`,
//! `texture`) and names the game gives no texture read texture unit 0, which OptiFine's
//! shaders.txt gives to `colortex0` there. Our probe packs (#9307) read the scene in `colortex0`
//! through each in Iris 1.11.7, also in programs that don't declare `colortex0`.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv = gl_MultiTexCoord0.xy; gl_Position = ftransform(); }\n";
/// deferred writes a colour into colortex0; composite reads it through `gtexture` into
/// colortex1, composite1 through `tex` into colortex2, composite2 through a name of its own into
/// colortex3; final shows them in that order, left to right, then what its own `texture` reads.
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
for program in ["deferred", "composite", "composite1", "composite2", "final"] {
add(&format!("{program}.vsh"), VERTEX);
}
add(
"deferred.fsh",
"#version 120\nvoid main() {\n/* DRAWBUFFERS:0 */\n gl_FragData[0] = vec4(0.0, 200.0 / 255.0, 40.0 / 255.0, 1.0);\n}\n",
);
add(
"composite.fsh",
"#version 130\nuniform sampler2D gtexture;\nvarying vec2 uv;\nvoid main() {\n/* DRAWBUFFERS:1 */\n gl_FragData[0] = texture(gtexture, uv);\n}\n",
);
add(
"composite1.fsh",
"#version 130\nuniform sampler2D tex;\nvarying vec2 uv;\nvoid main() {\n/* DRAWBUFFERS:2 */\n gl_FragData[0] = texture(tex, uv);\n}\n",
);
add(
"composite2.fsh",
"#version 130\nuniform sampler2D probeOwnName;\nvarying vec2 uv;\nvoid main() {\n/* DRAWBUFFERS:3 */\n gl_FragData[0] = texture(probeOwnName, uv);\n}\n",
);
add(
"final.fsh",
"#version 120\nuniform sampler2D texture;\nuniform sampler2D colortex1;\nuniform sampler2D colortex2;\n\
uniform sampler2D colortex3;\nvarying vec2 uv;\nvoid main() {\n\
\x20 gl_FragColor = uv.x < 0.25 ? texture2D(colortex1, uv) : uv.x < 0.5 ? texture2D(colortex2, uv)\n\
\x20 : uv.x < 0.75 ? texture2D(colortex3, uv) : texture2D(texture, uv);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-atlas-names-in-post",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn atlas_names_read_colortex0_in_deferred_composite_and_final() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [8, 8]);
for (i, p) in pixels.iter().enumerate() {
let name = match i % 8 {
0..2 => "gtexture in composite",
2..4 => "tex in composite1",
4..6 => "a name of the pack's in composite2",
_ => "texture in final",
};
assert_eq!(*p, [0, 200, 40, 255], "{name}, pixel {i}");
}
}
@@ -8,7 +8,7 @@ pub(super) const SIZE: [u32; 2] = [16, 16];
pub(super) struct Gpu {
adapter: wgpu::Adapter,
pub(super) device: wgpu::Device,
queue: wgpu::Queue,
pub(super) queue: wgpu::Queue,
}
impl Gpu {
pub(super) fn new() -> Option<Self> {
@@ -92,7 +92,12 @@ impl Gpu {
);
built
}
fn clear(&self, color: &wgpu::TextureView, depth: &wgpu::TextureView, reverse_depth: f32) {
pub(super) fn clear(
&self,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
reverse_depth: f32,
) {
let mut encoder = self.device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Independent original fixture clear"),
@@ -316,7 +321,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
@@ -677,6 +682,7 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: false,
}),
..Default::default()
};
@@ -783,3 +789,68 @@ fn shadowtex_nearest_settings_read_the_nearest_texel() {
}
}
}
/// The sky program covers the horizon and downward viewing directions too.
/// This original probe reports sky colour in RGB and window depth in alpha.
#[test]
#[ignore = "requires a host GPU"]
fn sky_covers_the_horizon_without_writing_depth_or_overpainting_terrain() {
let gpu = Gpu::new().expect("GPU required for the sky coverage regression");
assert_ne!(gpu.adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
let files = vec![
("gbuffers_skybasic.vsh".into(), "#version 120\nvoid main(){gl_Position=ftransform();}".into()),
("gbuffers_skybasic.fsh".into(), "#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){gl_FragData[0]=vec4(1.0,0.0,0.0,1.0);}".into()),
("final.vsh".into(), VERTEX.into()),
("final.fsh".into(), "#version 120\nuniform sampler2D colortex0;uniform sampler2D depthtex0;varying vec2 uv;\nconst vec4 colortex0ClearColor=vec4(0.0,1.0,0.0,1.0);\nvoid main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,texture2D(depthtex0,uv).r);}".into()),
];
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
sky: Some(crate::gfx::pack_sky::inputs()),
..Default::default()
},
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.skybox = true;
frame.distance = 512.0;
frame.detail_distance = 512.0;
frame.depth_far = 2048.0;
for yaw in [0.0, 45.0, 90.0, 180.0, 270.0] {
for pitch in [-60.0, 0.0, 21.1, 60.0] {
frame.yaw = yaw;
frame.pitch = pitch;
gpu.clear(&color, &depth, 0.0);
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
all_pixels(
&pixels,
[255, 0, 0, 255],
&format!("sky yaw={yaw} pitch={pitch}"),
);
}
}
// A pre-existing foreground depth must survive the sky, as must its colour.
gpu.clear(&color, &depth, 0.5);
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
all_pixels(
&pixels,
[16, 16, 16, 128],
"foreground colour and depth survive sky",
);
}
@@ -45,6 +45,7 @@ fn pack() -> Post {
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
@@ -0,0 +1,93 @@
//! A buffer gets its B texture only when a composite-style program draws into it or flips it:
//! the others' B is a second view of A, and they read the same as before. The programs here
//! are original and cut down.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// `composite` writes colortex1 from colortex2, which no program writes; colortex4 is only
/// flipped by a directive. `shadowcomp` writes shadowcolor1; shadowcolor0 is only read.
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("shadowcomp.vsh", VERTEX);
add(
"shadowcomp.fsh",
"#version 120\n/* RENDERTARGETS: 1 */\nvoid main() { gl_FragData[0]=vec4(0,0.5,0,1); }\n",
);
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\n/* RENDERTARGETS: 1 */\n\
void main() { gl_FragData[0]=vec4(texture2D(colortex2,uv).rgb*0.5,1); }\n",
);
add("final.vsh", VERTEX);
// Pixel column x shows colortex1, colortex4, shadowcolor0 and shadowcolor1 in turn.
add(
"final.fsh",
"#version 330 compatibility\nconst int shadowMapResolution=16;\n\
const vec4 colortex2ClearColor=vec4(1,1,1,1);\n\
const vec4 colortex4ClearColor=vec4(0,0,0.75,1);\n\
const vec4 shadowcolor0ClearColor=vec4(0.25,0,0,1);\n\
uniform sampler2D colortex1;\nuniform sampler2D colortex4;\n\
uniform sampler2D shadowcolor0;\nuniform sampler2D shadowcolor1;\nin vec2 uv;\n\
void main() {\n vec4 c[4] = vec4[4](texture(colortex1,uv), texture(colortex4,uv),\n\
texture(shadowcolor0,uv), texture(shadowcolor1,uv));\n\
gl_FragColor = c[int(gl_FragCoord.x) % 4];\n}\n",
);
add(
"shaders.properties",
"shadow.enabled=true\nflip.composite.colortex4=true\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-single-side",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn only_buffers_drawn_into_or_flipped_get_a_second_texture() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
// Two frames: the second starts from the first one's buffers.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [8, 8]);
let expected = [
[128, 128, 128, 255],
[0, 0, 191, 255],
[64, 0, 0, 255],
[0, 128, 0, 255],
];
for (x, want) in expected.iter().enumerate() {
let got = pixels[x];
assert!(
got.iter().zip(want).all(|(g, w)| g.abs_diff(*w) <= 1),
"frame {frame}, column {x}: {got:?}, expected {want:?}"
);
}
}
let shared = |sides: &[wgpu::TextureView; 2]| sides[0].texture() == sides[1].texture();
let targets = post.targets.as_ref().expect("rendered");
for (b, single) in [(1, false), (2, true), (4, false)] {
let sides = targets.color[b].as_ref().expect("used buffer");
assert_eq!(shared(sides), single, "colortex{b}");
}
let shadow = post.shadow_targets.as_ref().expect("shadow pass");
for (n, single) in [(0, true), (1, false)] {
let sides = shadow.color[n].as_ref().expect("used buffer");
assert_eq!(shared(sides), single, "shadowcolor{n}");
}
}
@@ -0,0 +1,166 @@
//! The sky's programs read their fragments' own depth in `gl_FragCoord.z`, as our probe packs
//! read it in Iris 1.11.7: the sky disc at its distance; and
//! the sky still shows only where the world left the far plane. Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
/// `program` writes its fragment's `gl_FragCoord.z` into colortex1 (32-bit floats) and red into
/// colortex0; final shows colortex1's value as a 24-bit integer on the top half, and the top
/// half of colortex0 on the bottom half.
fn files(program: &str) -> Vec<(String, String)> {
vec![
(
"shaders.properties".into(),
format!("blend.{program}=off\n"),
),
(
format!("{program}.vsh"),
"#version 120\nvoid main() { gl_Position = ftransform(); }\n".into(),
),
(
format!("{program}.fsh"),
"#version 120\n/* DRAWBUFFERS:01 */\nvoid main() {\n\
\x20 gl_FragData[0] = vec4(1.0, 0.0, 0.0, 1.0);\n\
\x20 gl_FragData[1] = vec4(gl_FragCoord.z);\n}\n"
.into(),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 130\n/*\nconst int colortex1Format = RGBA32F;\n*/\n\
uniform sampler2D colortex0;\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
void main() {\n\
\x20 uint n = min(uint(texture(colortex1, uv).r * 16777215.0 + 0.5), 16777215u);\n\
\x20 vec4 depth = vec4(uvec4(n >> 16u, (n >> 8u) & 255u, n & 255u, 255u)) / 255.0;\n\
\x20 gl_FragColor = uv.y > 0.5 ? depth : texture(colortex0, uv + vec2(0.0, 0.5));\n}\n"
.into(),
),
]
}
/// GL window depth of a point `distance` in front of the camera along its view axis, for the
/// fixture's perspective: near 0.05, far `depth_far`.
fn window_depth(distance: f64, far: f64) -> f64 {
let near = 0.05;
let ndc = (far + near) / (far - near) - 2.0 * far * near / ((far - near) * distance);
ndc * 0.5 + 0.5
}
/// The pack's picture of a frame of the fixture, whose world drew nothing but left
/// `world_depth` (GL window depth) everywhere.
fn picture(
gpu: &Gpu,
program: &str,
world_depth: f32,
celestial: &[crate::mesher::Vertex],
) -> Vec<[u8; 4]> {
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
// The world's depth is reverse-Z.
gpu.clear(&color, &depth, 1.0 - world_depth);
let sun = gpu.texture(T::Rgba8Unorm);
let mut post = gpu.load(
files(program),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: Some(sun),
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
sky: Some(super::super::pack_sky::inputs()),
celestial: Some(super::super::pack_sky::celestial_inputs()),
..Default::default()
},
);
assert!(post.sky.is_some(), "{:?}", post.notes);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.skybox = true;
frame.celestial = celestial;
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
}
fn decode(p: [u8; 4]) -> f64 {
f64::from(u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2])) / 16_777_215.0
}
/// The sky disc lies 16 blocks above the eye: the fixture looks level with a 90° field of view,
/// so a pixel `row` rows below the top of the 16-pixel picture sees it along the view axis at
/// 16 / tan(elevation) = 16 / (1 - (row + 0.5) / 8) blocks.
#[test]
fn the_sky_disc_reads_its_own_depth() {
let Some(gpu) = Gpu::new() else { return };
let pixels = picture(&gpu, "gbuffers_skybasic", 1.0, &[]);
for row in 0..4 {
let along = 16.0 / (1.0 - (row as f64 + 0.5) / 8.0);
let expected = window_depth(along, 1000.0);
for x in 0..SIZE[0] as usize {
let got = decode(pixels[row * SIZE[0] as usize + x]);
assert!(
(got - expected).abs() < 2e-5,
"row {row}, column {x}: gl_FragCoord.z {got}, expected {expected}"
);
}
}
}
/// The sun's quad (and the moon's behind the camera), 100 blocks away across the view axis:
/// one depth everywhere.
#[test]
fn the_sun_reads_its_own_depth() {
let Some(gpu) = Gpu::new() else { return };
let quad = |z: f32| {
[
[-400.0, -400.0],
[400.0, -400.0],
[400.0, 400.0],
[-400.0, 400.0],
]
.map(|[x, y]| crate::mesher::Vertex {
pos: [x, y, z],
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
})
};
let quads: Vec<_> = quad(100.0).into_iter().chain(quad(-100.0)).collect();
let pixels = picture(&gpu, "gbuffers_skytextured", 1.0, &quads);
let expected = window_depth(100.0, 1000.0);
for (i, &p) in pixels[..pixels.len() / 2].iter().enumerate() {
let got = decode(p);
assert!(
(got - expected).abs() < 2e-5,
"pixel {i}: gl_FragCoord.z {got}, expected {expected}"
);
}
}
/// Where the world left a depth nearer than the far plane, even one farther away than the sky
/// disc, the world shows (the fixture's grey), not the sky's red.
#[test]
fn the_sky_shows_only_where_the_world_left_the_far_plane() {
let Some(gpu) = Gpu::new() else { return };
let half = SIZE[0] as usize * SIZE[1] as usize / 2;
let far = picture(&gpu, "gbuffers_skybasic", 1.0, &[]);
let near = picture(&gpu, "gbuffers_skybasic", 0.9999, &[]);
// The sky disc covers the upper half of the view (row 7 sees it 256 blocks away, at a
// window depth of 0.9998).
for (i, (f, n)) in far[half..].iter().zip(&near[half..]).enumerate() {
assert_eq!(*f, [255, 0, 0, 255], "far plane, pixel {i}");
assert_eq!(*n, [16, 16, 16, 255], "depth 0.9999, pixel {i}");
}
}
@@ -0,0 +1,130 @@
//! In gbuffers programs a sampler the game gives no texture reads texture unit 0, the atlas
//! (OptiFine's shaders.txt: `gtexture` is unit 0 there), and so do `colortex0` to `colortex3`,
//! which aren't gbuffers samplers in that table. Our probe packs (#9307) read the block atlas
//! through a sampler name of their own, and through each of `colortex0` to `colortex3` and
//! `gnormal`, in gbuffers_terrain in Iris 1.11.7.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::{TextureFormat as T, util::DeviceExt};
const ATLAS: [u8; 4] = [10, 200, 30, 255];
#[test]
fn unknown_samplers_and_colortex0_to_3_in_gbuffers_terrain_read_the_atlas() {
let Some(gpu) = Gpu::new() else { return };
let files = vec![
("final.vsh".to_owned(), VERTEX.to_owned()),
(
"final.fsh".to_owned(),
"#version 120\nuniform sampler2D colortex0;varying vec2 uv;\
void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}"
.to_owned(),
),
(
"gbuffers_terrain.vsh".to_owned(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".to_owned(),
),
(
"gbuffers_terrain.fsh".to_owned(),
"#version 120\nuniform sampler2D probeOwnName;\nuniform sampler2D colortex0;\n\
uniform sampler2D gdepth;\n/* DRAWBUFFERS:0 */\nvoid main(){\n\
\x20 float x = gl_FragCoord.x;\n\
\x20 gl_FragData[0] = x < 6.0 ? texture2D(probeOwnName, vec2(0.5))\n\
\x20 : x < 11.0 ? texture2D(colortex0, vec2(0.5)) : texture2D(gdepth, vec2(0.5));\n}"
.to_owned(),
),
];
let terrain = horizon_shaders::GbuffersInputs {
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);\
hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
..Default::default()
};
let inputs = horizon_shaders::GameInputs {
terrain: Some(terrain),
..Default::default()
};
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let atlas = gpu
.device
.create_texture_with_data(
&gpu.queue,
&wgpu::TextureDescriptor {
label: Some("Original one-colour atlas"),
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: T::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&ATLAS.repeat(4),
)
.create_view(&Default::default());
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
let mut post = gpu.load(
files,
World {
atlas: Some((atlas, [2, 2])),
material_maps: None,
lightmap: color.clone(),
terrain: Some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
inputs,
);
assert!(post.has_terrain(), "{:?}", post.notes);
let corners = [[-1.0f32, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
let vertices: Vec<f32> = [0, 1, 2, 0, 2, 3]
.into_iter()
.flat_map(|c| [corners[c][0], corners[c][1], 0.0])
.collect();
let buffer = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original full-screen quad"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let hud = HudBatch::new(SIZE);
let frame = fixture_frame(&hud);
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::Solid {
rp.set_vertex_buffer(0, buffer.slice(..));
rp.draw(0..6, 0..1);
}
});
// Columns 0-5 read the pack's own name, 6-10 colortex0 (which the program also writes),
// 11-15 gdepth (colortex1).
for (i, p) in pixels.iter().enumerate() {
assert_eq!(*p, ATLAS, "column {}", i % SIZE[0] as usize);
}
}
@@ -42,7 +42,8 @@ pub(super) struct ShadowTargets {
/// The rows a depth copy goes through to `linear` (a depth format copies only to a buffer),
/// and their stride in bytes.
staging: Option<(wgpu::Buffer, u32)>,
/// Both sides of each shadowcolor buffer used.
/// Both sides of each shadowcolor buffer used; one texture for both when no `shadowcomp`
/// program draws into or flips the buffer.
pub color: [Option<[wgpu::TextureView; 2]>; SHADOW_COLORS],
/// The clear colour of each buffer cleared every frame.
pub clears: [Option<[f32; 4]>; SHADOW_COLORS],
@@ -85,12 +86,14 @@ fn target(
impl ShadowTargets {
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`, `linear` (bit n: shadowtex n read through a filtering
/// sampler). The resolution is kept within the device's limit.
/// sampler), `doubled` (bit n: shadowcolor n needs a B texture). The resolution is kept
/// within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
doubled: u16,
depth1: bool,
linear: u8,
) -> Self {
@@ -101,8 +104,8 @@ impl ShadowTargets {
let depth = wgpu::TextureFormat::Depth32Float;
let color = std::array::from_fn(|n| {
(colors & 1 << n != 0).then(|| {
[0, 1].map(|side| {
let label = format!("Shader pack shadowcolor{n} {}", ["A", "B"][side]);
let side = |side: &str| {
let label = format!("Shader pack shadowcolor{n} {side}");
target(
device,
&label,
@@ -111,7 +114,14 @@ impl ShadowTargets {
wgpu::TextureUsages::empty(),
)
.1
})
};
let a = side("A");
let b = if doubled & 1 << n != 0 {
side("B")
} else {
a.clone()
};
[a, b]
})
});
let linear = [0, 1].map(|n| {
@@ -94,10 +94,12 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
("vaColor".into(), "hz_Color".into()),
("vaNormal".into(), "hz_Normal".into()),
],
// At the far plane: the sky shows only where nothing else drew, and it writes no depth.
after: " gl_Position.z = gl_Position.w;\n".into(),
// Tested at the far plane: the sky shows only where nothing else drew, and it writes no
// depth. Its fragments read their own depth, as in Iris.
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: true,
}
}
@@ -121,9 +123,10 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
("vaColor".into(), "hz_Color".into()),
("vaNormal".into(), "hz_Normal".into()),
],
after: " gl_Position.z = gl_Position.w;\n".into(),
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: true,
}
}
@@ -153,6 +156,38 @@ fn disc(y: f32, kind: u32, out: &mut Vec<Vertex>) {
}
}
/// Cover the horizon and downward sky rays as well as the upper vanilla disc.
/// Uncovered directions retain the buffer's clear colour, affecting later sky/fog
/// compositing. Enclosing the camera gives the sky program fragments there too.
/// Depth stays at the far plane through `inputs`, so terrain remains in front.
fn horizon(out: &mut Vec<Vertex>) {
let corners = [
[-512.0, -512.0, -512.0],
[512.0, -512.0, -512.0],
[512.0, 512.0, -512.0],
[-512.0, 512.0, -512.0],
[-512.0, -512.0, 512.0],
[512.0, -512.0, 512.0],
[512.0, 512.0, 512.0],
[-512.0, 512.0, 512.0],
];
for [a, b, c, d] in [
[0, 1, 2, 3],
[4, 7, 6, 5],
[0, 4, 5, 1],
[3, 2, 6, 7],
[0, 3, 7, 4],
[1, 5, 6, 2],
] {
for i in [a, b, c, a, c, d] {
out.push(Vertex {
pos: corners[i],
kind: SKY,
});
}
}
}
pub(super) struct PackSky {
vertices: wgpu::Buffer,
celestial: wgpu::Buffer,
@@ -166,6 +201,7 @@ pub(super) struct PackSky {
impl PackSky {
pub fn new(device: &wgpu::Device) -> Self {
let mut v = Vec::new();
horizon(&mut v);
disc(16.0, SKY, &mut v);
let sky = 0..v.len() as u32;
let apex = Vertex {
@@ -1,5 +1,6 @@
// `invert_generic` and the formula of `pack_projection` in this file are adapted from JOML
// 1.10.9's `Matrix4f` (https://github.com/JOML-CI/JOML, MIT; see THIRD_PARTY_NOTICES.md).
// `invert_generic`, `invert_affine` and the formulas of `pack_projection` and `shadow_ortho` in
// this file are adapted from JOML 1.10.9's `Matrix4f` (https://github.com/JOML-CI/JOML, MIT; see
// THIRD_PARTY_NOTICES.md).
//
// Copyright (c) 2015-2026 Richard Greenlees
//
@@ -390,28 +391,43 @@ fn day_fraction(degrees: f32) -> f32 {
(degrees + 90.0) % 360.0 / 360.0
}
/// The sky angle in degrees of the body that casts the shadows: the sun by day, the moon at
/// night (taken below 360).
fn shadow_light_degrees(frame: &Frame) -> f32 {
if day_fraction(frame.sky.sun_angle) < 0.5 {
frame.sky.sun_angle
/// `shadowAngle`: the day fraction of the body that casts the shadows, the sun by day and the
/// moon (taken below 360) at night.
fn shadow_angle(frame: &Frame) -> f32 {
let sun = day_fraction(frame.sky.sun_angle);
if sun < 0.5 {
sun
} else {
frame.sky.moon_angle % 360.0
day_fraction(frame.sky.moon_angle % 360.0)
}
}
/// The angle in degrees `shadowModelView` turns the light's path by, from `shadowAngle`:
/// `(shadowAngle - 0.25) * 360` in floats, plus 360 when that is negative. Our probes' readings
/// of Iris 1.11.7 (50: 25 ticks of day and night, among them 7295 to 7305 one by one, with
/// `sunPathRotation` 0 and 30) fit this to the last bit; the body's own sky angle, which the
/// fraction is made from, misses 16 of them by 1 to 6 ulps (#8464).
fn shadow_matrix_degrees(frame: &Frame) -> f32 {
let degrees = (shadow_angle(frame) - 0.25) * 360.0;
if degrees < 0.0 {
degrees + 360.0
} else {
degrees
}
}
/// `shadowModelView` and its inverse to the last bit, as Iris 1.11.7 gives them: our probes'
/// readings (20 scenes: times of day and night, `sunPathRotation` 0 and 30, cameras off the
/// block grid) fit these JOML float operations, in this order: `rotateX(90°)`, `rotateZ(-angle)`
/// with the light body's sky angle, `rotateX(sunPathRotation)`, then `translate` by minus the
/// origin `shadow_camera` describes, from the camera's position as a float; and JOML's
/// `invert()`. `shadow_camera`'s view, built in doubles, differs in the last bits (6e-17 for
/// 0), and on a texel boundary of the map that is enough to read the other texel (#8464).
/// readings (times of day and night, `sunPathRotation` 0 and 30, cameras off the block grid)
/// fit these JOML float operations, in this order: `rotateX(90°)`, `rotateZ(-angle)` with
/// `shadow_matrix_degrees`, `rotateX(sunPathRotation)`, then `translate` by minus the origin
/// `shadow_camera` describes, from the camera's position as a float; and JOML's `invert()`.
/// `shadow_camera`'s view, built in doubles, differs in the last bits (6e-17 for 0), and on a
/// texel boundary of the map that is enough to read the other texel (#8464).
pub fn shadow_model_view(frame: &Frame, shadow: &Shadow) -> (Mat4, Mat4) {
use horizon_core::geometry::trig::DEG_TO_RAD;
let mut m = crate::camera::joml::Mat4::IDENTITY;
m.rotate_x(90.0 * DEG_TO_RAD);
m.rotate_z(-(shadow_light_degrees(frame) * DEG_TO_RAD));
m.rotate_z(-(shadow_matrix_degrees(frame) * DEG_TO_RAD));
m.rotate_x(shadow.sun_path_rotation * DEG_TO_RAD);
let step = shadow.interval;
if step > 0.0 {
@@ -506,18 +522,80 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
depth * gl * view
}
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
/// not of `shadowModelView` (Iris's matrices, measured).
pub const SHADOW_EYE: f32 = 100.0;
/// How far toward the light a `shadowMapFov` projection starts, in blocks.
const SHADOW_EYE: f32 = 100.0;
/// The orthographic shadow projection's planes when the pack sets none, as JOML's `zNear` and
/// `zFar`: Iris 1.11.7's, measured with a probe pack (each unset plane on its own).
const SHADOW_NEAR: f32 = -100.05;
const SHADOW_FAR: f32 = 156.0;
/// `shadowProjection` and its inverse to the last bit, as Iris 1.11.7 gives them: our probes'
/// readings (13 settings of `shadowDistance`, `shadowNearPlane` and `shadowFarPlane`, each
/// plane set or not) fit JOML's `setOrtho` over `shadowDistance` each side with `zZeroToOne`,
/// its depth terms then taken to GL's -1..1 (`m22 * 2`, `m32 * 2 - 1`), in floats; and JOML's
/// `invertAffine` of that. The planes are the pack's, or `SHADOW_NEAR`/`SHADOW_FAR`: a pack
/// that sets one gets it as it is, with no 100-block offset (#8464).
fn shadow_ortho(shadow: &Shadow) -> (Mat4, Mat4) {
let d = shadow.distance;
let near = shadow.near.unwrap_or(SHADOW_NEAR);
let far = shadow.far.unwrap_or(SHADOW_FAR);
let side = 2.0 / (d - -d);
let projection = Mat4::from_cols(
Vec4::new(side, 0.0, 0.0, 0.0),
Vec4::new(0.0, side, 0.0, 0.0),
Vec4::new(0.0, 0.0, 1.0 / (near - far) * 2.0, 0.0),
Vec4::new(0.0, 0.0, near / (near - far) * 2.0 - 1.0, 1.0),
);
(projection, invert_affine(projection))
}
/// JOML's `Matrix4f.invertAffine()`, operation by operation.
fn invert_affine(m: Mat4) -> Mat4 {
let g = |c: usize, r: usize| m.col(c)[r];
let (m00, m01, m02) = (g(0, 0), g(0, 1), g(0, 2));
let (m10, m11, m12) = (g(1, 0), g(1, 1), g(1, 2));
let (m20, m21, m22) = (g(2, 0), g(2, 1), g(2, 2));
let (m30, m31, m32) = (g(3, 0), g(3, 1), g(3, 2));
let (m11m00, m10m01, m10m02) = (m00 * m11, m01 * m10, m02 * m10);
let (m12m00, m12m01, m11m02) = (m00 * m12, m01 * m12, m02 * m11);
let det = (m11m00 - m10m01) * m22 + (m10m02 - m12m00) * m21 + (m12m01 - m11m02) * m20;
let s = 1.0 / det;
let (m10m22, m10m21, m11m22) = (m10 * m22, m10 * m21, m11 * m22);
let (m11m20, m12m21, m12m20) = (m11 * m20, m12 * m21, m12 * m20);
let (m20m02, m20m01, m21m02) = (m20 * m02, m20 * m01, m21 * m02);
let (m21m00, m22m01, m22m00) = (m21 * m00, m22 * m01, m22 * m00);
Mat4::from_cols_array(&[
(m11m22 - m12m21) * s,
(m21m02 - m22m01) * s,
(m12m01 - m11m02) * s,
0.0,
(m12m20 - m10m22) * s,
(m22m00 - m20m02) * s,
(m10m02 - m12m00) * s,
0.0,
(m10m21 - m11m20) * s,
(m20m01 - m21m00) * s,
(m11m00 - m10m01) * s,
0.0,
(m10m22 * m31 - m10m21 * m32 + m11m20 * m32 - m11m22 * m30 + m12m21 * m30 - m12m20 * m31)
* s,
(m20m02 * m31 - m20m01 * m32 + m21m00 * m32 - m21m02 * m30 + m22m01 * m30 - m22m00 * m31)
* s,
(m11m02 * m30 - m12m01 * m30 + m12m00 * m31 - m10m02 * m31 + m10m01 * m32 - m11m00 * m32)
* s,
1.0,
])
}
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
/// light's path about Z by the light's angle from the zenith, then X by 90 degrees, so the
/// light lies on the view's +Z. Its origin is `shadowIntervalSize / 2 - camera % interval`
/// (the remainder takes the camera's sign) from the player on each world axis, so the map moves
/// in whole steps. The orthographic projection covers `shadowDistance` each side and depths
/// from `-near` to `far`, counted from `SHADOW_EYE` blocks toward the light; or perspective
/// with `shadowMapFov`.
/// in whole steps. The orthographic projection is `shadow_ortho`'s; or perspective with
/// `shadowMapFov`, from `shadowNearPlane` (0.05) to `shadowFarPlane` (256) counted from
/// `SHADOW_EYE` blocks toward the light.
pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4) {
let tilt = f64::from(shadow.sun_path_rotation).to_radians();
let untilted = DMat4::from_rotation_x(tilt).transform_vector3(light.as_dvec3());
@@ -537,14 +615,13 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
DVec3::ZERO
};
let view = (rotation * DMat4::from_translation(-origin)).as_mat4();
let (near, far) = (shadow.near, shadow.far);
let eye = Mat4::from_translation(Vec3::new(0.0, 0.0, -SHADOW_EYE));
let projection = match shadow.fov {
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far) * eye,
None => {
let d = shadow.distance;
Mat4::orthographic_rh_gl(-d, d, -d, d, -near, far) * eye
Some(fov) => {
let (near, far) = (shadow.near.unwrap_or(0.05), shadow.far.unwrap_or(256.0));
let eye = Mat4::from_translation(Vec3::new(0.0, 0.0, -SHADOW_EYE));
Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far) * eye
}
None => shadow_ortho(shadow).0,
};
(view, projection)
}
@@ -648,7 +725,13 @@ impl FrameUniforms {
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
};
let light = shadow_light(frame, &self.shadow);
let (_, shadow_projection) = shadow_camera(light, frame.camera, &self.shadow);
let (shadow_projection, shadow_projection_inverse) = match self.shadow.fov {
Some(_) => {
let (_, projection) = shadow_camera(light, frame.camera, &self.shadow);
(projection, projection.inverse())
}
None => shadow_ortho(&self.shadow),
};
let (shadow_view, shadow_view_inverse) = shadow_model_view(frame, &self.shadow);
let day_time = inputs.day_time.rem_euclid(24000) as i32;
let world_day = inputs.day_time.div_euclid(24000);
@@ -695,11 +778,7 @@ impl FrameUniforms {
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
"sunAngle" => Value::f(&[sun_angle]),
"shadowAngle" => Value::f(&[if day {
sun_angle
} else {
day_fraction(frame.sky.moon_angle % 360.0)
}]),
"shadowAngle" => Value::f(&[shadow_angle(frame)]),
"worldTime" => Value::i(&[day_time]),
"worldDay" => Value::i(&[world_day as i32]),
"moonPhase" => Value::i(&[world_day.rem_euclid(8) as i32]),
@@ -707,7 +786,7 @@ impl FrameUniforms {
"shadowModelView" => m4(shadow_view),
"shadowModelViewInverse" => m4(shadow_view_inverse),
"shadowProjection" => m4(shadow_projection),
"shadowProjectionInverse" => m4(shadow_projection.inverse()),
"shadowProjectionInverse" => m4(shadow_projection_inverse),
"atlasSize" => Value::i(&[self.atlas[0] as i32, self.atlas[1] as i32]),
"frameTime" => Value::f(&[frame_time]),
"frameTimeCounter" => Value::f(&[seconds]),
@@ -1128,6 +1207,80 @@ mod initial_history_tests {
float(u, name).map(f32::to_bits)
}
/// `shadowProjection` and its inverse to the last bit, as a probe pack of ours read them
/// back in Iris 1.11.7, for `shadowDistance`, `shadowNearPlane` and `shadowFarPlane` (none
/// meaning the pack doesn't set it): a plane the pack sets is taken as it is, with no
/// 100-block offset; one it doesn't set is -100.05 (near) or 156 (far).
#[test]
fn the_shadow_projection_is_iris_s_for_the_pack_s_planes() {
let hud = HudBatch::new([1280, 720]);
let f = frame(&hud);
let (o, z, one) = (0, 0x80000000, 0x3f800000);
type Case = (f32, Option<f32>, Option<f32>, [u32; 4], [u32; 4]);
// (distance, near, far, [side, m22, m32, -], [inverse side, m22, m32, -])
let cases: [Case; 5] = [
(
100.0,
None,
None,
[0x3c23d70a, 0xbbfff335, 0xbe5fc198, 0],
[0x42c80001, 0xc3000666, 0xc1dfccc8, 0],
),
(
100.0,
Some(0.5),
Some(128.0),
[0x3c23d70a, 0xbc808081, 0xbf810101, 0],
[0x42c80000, 0xc27f0000, 0xc2808000, 0],
),
(
100.0,
None,
Some(128.0),
[0x3c23d70a, 0xbc0fb013, 0xbdfb0128, 0],
[0x42c80000, 0xc2e40ccc, 0xc15f9994, 0],
),
(
100.0,
Some(0.05),
None,
[0x3c23d70a, 0xbc521e5f, 0xbf801503, 0],
[0x42c80000, 0xc29bf333, 0xc29c0ccd, 0],
),
(
127.3,
Some(1.7),
Some(333.3),
[0x3c00b42f, 0xbbc5a2bf, 0xbf814ffb, 0],
[0x42fe9999, 0xc325cccc, 0xc3277fff, 0],
),
];
for (distance, near, far, [side, m22, m32, _], [i_side, i22, i32, _]) in cases {
let mut u = FrameUniforms {
shadow: Shadow {
distance,
near,
far,
..Shadow::default()
},
..FrameUniforms::default()
};
u.update(&f, &PackInputs::default(), [1280, 720]);
assert_eq!(
bits(&u, "shadowProjection"),
[side, o, o, o, o, side, o, o, o, o, m22, o, o, o, m32, one],
"{near:?} {far:?}"
);
assert_eq!(
bits(&u, "shadowProjectionInverse"),
[
i_side, z, z, o, z, i_side, z, o, z, z, i22, o, z, z, i32, one
],
"{near:?} {far:?}"
);
}
}
/// Iris 1.11.7's camera and shadow matrices to the last bit, read back from its uniforms by
/// a probe pack of ours: on a texel boundary of the shadow map the last bit decides which
/// texel a fragment reads (#8464). Noon at 6000 ticks, looking west (yaw 90) with vanilla's
@@ -1248,6 +1401,86 @@ mod initial_history_tests {
assert_eq!(float(&u, "shadowAngle")[0].to_bits(), 0x3e1afe2e);
}
/// Between whole ticks of the timeline, Iris 1.11.7 turns `shadowModelView` by an angle made
/// from `shadowAngle`, not by the sky's own angle: read back by a probe pack of ours at 7297,
/// 7300 and 16000 ticks, and at 7303 with `sunPathRotation = 30`, the camera at
/// (13.5, 69.62, -13.5). The sky angles are vanilla's timeline at those ticks.
#[test]
fn the_shadow_matrices_turn_by_iris_s_angle_between_ticks() {
let hud = HudBatch::new([1280, 720]);
let mut f = frame(&hud);
f.camera = DVec3::new(13.5, 68.0 + f64::from(1.62f32), -13.5);
let (o, z, one, minus) = (0, 0x80000000, 0x3f800000, 0xbf800000);
let cases: [(f32, u32, u32, [u32; 16], [u32; 16]); 4] = [
(
0.0,
0x415d5e10,
0x4341d5e1,
[
0x3f789291, o, 0xbe74dfb0, o, 0x3e74dfb0, o, 0x3f789291, o, o, minus, z, o,
0x3f223deb, 0x40200000, 0x3ef7034a, one,
],
[
0x3f789291, 0x3e74dfb0, o, o, o, o, minus, o, 0xbe74dfb0, 0x3f789291, z, o,
0xbf000000, 0xbf1eb87f, 0x40200000, one,
],
),
(
0.0,
0x415de96f,
0x4341de97,
[
0x3f788940, o, 0xbe7576d1, o, 0x3e7576d1, o, 0x3f788940, o, o, minus, z, o,
0x3f2250af, 0x40200000, 0x3ef6d1f4, one,
],
[
0x3f788940, 0x3e7576d1, o, o, o, o, minus, o, 0xbe7576d1, 0x3f788940, z, o,
0xbeffffff, 0xbf1eb87f, 0x40200000, one,
],
),
(
0.0,
0x43107d5b,
0x43a23eae,
[
0x3f506307, o, 0x3f14b28c, o, 0xbf14b28c, z, 0x3f506307, o, o, minus, z, o,
0x3d400268, 0x40200000, 0x3f4b8c94, one,
],
[
0x3f506307, 0xbf14b28c, o, o, o, z, minus, o, 0x3f14b28c, 0x3f506307, z, o,
0xbf000000, 0xbf1eb880, 0x40200000, one,
],
),
(
30.0,
0x415e74d7,
0x4341e74d,
[
0x3f787fe8, o, 0xbe760e06, o, 0x3e5516f6, 0xbf000000, 0x3f5734fd, o,
0xbdf60e06, 0xbf5db3d7, 0xbef87fe8, o, 0x3f6a2bc6, 0x3fed72ac, 0x3fcea5f8, one,
],
[
0x3f787fe8, 0x3e5516f6, 0xbdf60e06, o, o, 0xbf000000, 0xbf5db3d7, o,
0xbe760e06, 0x3f5734fd, 0xbef87fe8, o, 0xbf000001, 0xbf1eb881, 0x40200000, one,
],
),
];
for (tilt, sun, moon, view, inverse) in cases {
f.sky.sun_angle = f32::from_bits(sun);
f.sky.moon_angle = f32::from_bits(moon);
let mut u = FrameUniforms {
shadow: Shadow {
sun_path_rotation: tilt,
..Shadow::default()
},
..FrameUniforms::default()
};
u.update(&f, &PackInputs::default(), [1280, 720]);
assert_eq!(bits(&u, "shadowModelView"), view, "{sun:x}");
assert_eq!(bits(&u, "shadowModelViewInverse"), inverse, "{sun:x}");
}
}
#[test]
fn first_history_is_current_then_tracks_the_actual_previous_frame() {
let hud = HudBatch::new([800, 450]);
@@ -45,6 +45,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
reference: 0.1,
}),
item_id_declared: false,
far_depth: false,
}
}
+28 -31
View File
@@ -709,24 +709,19 @@ pub fn lookup(
];
for (kind, url) in urls {
let Some(url) = url else { continue };
// registerTexture computes the hash first. A cape or elytra URL the
// hash cannot take is that texture's problem alone: vanilla's lookup
// goes on to the next, and only the skin texture decides the default.
let Some(hash) = texture_hash(url) else {
if kind == Kind::Skin {
return None;
}
continue;
};
// registerTexture computes the hash first: a bad URL, even a cape's,
// fails the whole lookup.
let hash = texture_hash(url)?;
if kind == Kind::Skin {
skin = Some(skin_location(&hash));
}
match download(kind, url) {
// A cape or elytra that cannot be fetched is simply not drawn; only
// the skin itself falls back to the default one (`SkinManager.get`).
Download::Failed if kind == Kind::Skin => return None,
Download::Failed | Download::Ready => {}
// `SkinManager.registerTextures` joins the skin, cape and elytra
// futures with `allOf`: any one failing fails the profile's
// `PlayerSkin`, and `createLookup` then shows the default skin.
Download::Failed => return None,
Download::Pending => state = Download::Pending,
Download::Ready => {}
}
}
if state != Download::Ready || require_secure && !textures.signed {
@@ -1562,35 +1557,37 @@ mod tests {
);
}
/// A cape or elytra that cannot be fetched takes only itself out of the
/// picture: the player's skin still shows. Only the skin texture's own
/// failure is the default skin.
/// `SkinManager.registerTextures` waits on the skin, cape and elytra
/// together (`CompletableFuture.allOf`): a cape or elytra that cannot be
/// fetched, or whose URL has no hash, fails the profile, and the player
/// shows the default skin, as the skin's own failure does.
#[test]
fn a_failed_cape_does_not_hide_the_skin() {
fn a_failed_cape_or_elytra_shows_the_default_skin() {
let textures = super::Textures {
skin: Some("https://textures.minecraft.net/texture/skin1".into()),
cape: Some("https://textures.minecraft.net/texture/cape1".into()),
elytra: None,
elytra: Some("https://textures.minecraft.net/texture/elytra1".into()),
signed: true,
slim: false,
};
let skin = super::lookup(&textures, true, |kind, _| match kind {
super::Kind::Skin => super::Download::Ready,
_ => super::Download::Failed,
});
assert_eq!(skin, Some(super::skin_location("skin1")));
// A cape URL the hash cannot take is the same: the skin shows.
let bad_url = super::Textures {
let all_ready = super::lookup(&textures, true, |_, _| super::Download::Ready);
assert_eq!(all_ready, Some(super::skin_location("skin1")));
for failing in [super::Kind::Skin, super::Kind::Cape, super::Kind::Elytra] {
let skin = super::lookup(&textures, true, |kind, _| {
if kind == failing {
super::Download::Failed
} else {
super::Download::Ready
}
});
assert_eq!(skin, None, "{failing:?} failed");
}
let bad_cape_url = super::Textures {
cape: Some("no-scheme".into()),
..textures.clone()
};
assert_eq!(
super::lookup(&bad_url, true, |_, _| super::Download::Ready),
Some(super::skin_location("skin1"))
);
// The skin itself failing is still the default skin.
assert_eq!(
super::lookup(&textures, true, |_, _| super::Download::Failed),
super::lookup(&bad_cape_url, true, |_, _| super::Download::Ready),
None
);
}
+117
View File
@@ -1559,6 +1559,8 @@ pub struct Cushion {
pub color: u8,
/// BlockAttachedEntity.ticksSinceLastCheck.
pub check_interval: u32,
/// `Entity.getCustomName` (`CustomName`), copied onto the dropped cushion.
pub custom_name: Option<horizon_world::nbt::Tag>,
}
impl Cushion {
/// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25.
@@ -1577,6 +1579,16 @@ impl Cushion {
pub fn item(&self) -> String {
format!("minecraft:{}_cushion", self.color_name())
}
/// `Cushion.getCushionItemStackWithData`: the coloured item with
/// `DataComponents.CUSTOM_NAME` from `getCustomName`, as the item frame
/// beside it does.
pub fn item_stack_with_data(&self) -> ItemStack {
let mut stack = ItemStack::new(&self.item(), 1);
if let Some(name) = &self.custom_name {
stack.data_mut().custom_name = Some(name.clone());
}
stack
}
/// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment
/// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`.
pub fn seat(&self) -> Vec3 {
@@ -1783,6 +1795,7 @@ impl ArmorStand {
};
self.tick_settle(medium, step);
}
self.apply_honey_slide(scene.world, facts);
}
}
impl ArmorStand {
@@ -2113,6 +2126,49 @@ impl ArmorStand {
self.velocity = velocity;
Some(StandStep { falling, friction })
}
/// `HoneyBlock.entityInside`: side-slide movement after the stand's travel.
/// `ArmorStand.hasPhysics` / `Entity.isAffectedByBlocks` exclude markers
/// and NoGravity stands. Slide effects are separate from this movement.
pub fn apply_honey_slide(&mut self, world: &World, facts: &BlockFacts) {
let old_delta_y = |dy: f64| dy / f64::from(0.98_f32) + 0.08;
if self.marker || self.no_gravity || self.on_ground || old_delta_y(self.velocity.y) >= -0.08
{
return;
}
// Entity.checkInsideBlocks: test the deflated body against block cells,
// not their inset collision shape (the stand slides beside that shape).
let bounds = self.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let overlap = 0.4375 + if self.small { 0.125 } else { 0.25 };
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
if &*facts.get(world.block(x, y, z)).name != "minecraft:honey_block"
|| self.position.y > f64::from(y) + 0.9375 - 1.0e-7
{
continue;
}
let dx = (f64::from(x) + 0.5 - self.position.x).abs();
let dz = (f64::from(z) + 0.5 - self.position.z).abs();
if dx + 1.0e-7 <= overlap && dz + 1.0e-7 <= overlap {
continue;
}
// HoneyBlock.doSlideMovement, including its float-widened
// getOldDeltaY/getNewDeltaY conversion around air travel.
let old = old_delta_y(self.velocity.y);
if old < -0.13 {
let factor = -0.05 / old;
self.velocity.x *= factor;
self.velocity.z *= factor;
}
self.velocity.y = (-0.05 - 0.08) * f64::from(0.98_f32);
self.enchantment_fall_distance = 0.0;
// The new oldDeltaY is -0.05, so another honey cell cannot slide again.
return;
}
}
}
}
/// The rest of the stand's travel: the water slowdown, the lava slowdown
/// and sink, or the air drag and gravity. The blocks' push (a bubble
/// column) comes after this, as `LivingEntity.aiStep` applies the block
@@ -3467,6 +3523,67 @@ mod entity_collision_tests {
mod armor_stand_motion_tests {
use super::*;
#[test]
fn honey_slide_runs_after_stand_travel_and_preserves_no_physics_controls() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
let mut world = World::new(assets.registry.default_state("minecraft:air").unwrap(), 3);
world.chunks.insert(
(0, 0),
std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)),
);
for (z, name) in [(4, "minecraft:honey_block"), (10, "minecraft:stone")] {
for y in 100..=120 {
world
.set(6, y, z, assets.registry.default_state(name).unwrap())
.unwrap();
}
}
// Independently captured Java values after five actual ticks.
for (z, no_gravity, marker, y, vy, grounded) in [
(
4.5,
false,
false,
117.51156798899842,
-0.12740000247955321,
false,
),
(10.5, false, false, 118.0, -0.0784000015258789, true),
(4.5, true, false, 118.0, 0.0, false),
(4.5, false, true, 118.0, 0.0, false),
] {
let mut stand = ArmorStand::new(1, Vec3::new(5.78, 118.0, z), 0.0);
// Match the freshly summoned entity rather than the placement constructor.
stand.on_ground = false;
stand.no_gravity = no_gravity;
stand.marker = marker;
let mut scene = SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
for _ in 0..5 {
stand.tick(&mut scene, &facts, &motion.climbing, false, None);
}
assert_eq!(
stand.position,
Vec3::new(5.78, y, z),
"NoGravity={no_gravity}, Marker={marker}"
);
assert_eq!(stand.velocity, Vec3::new(0.0, vy, 0.0));
assert_eq!(stand.on_ground, grounded);
}
}
#[test]
fn a_name_tag_names_a_live_stand_and_unnamed_tags_pass() {
let mut stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
+12 -9
View File
@@ -707,12 +707,10 @@ pub fn mob_to_nbt(
);
map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2));
if id == "minecraft:hoglin" {
// Hoglin.addAdditionalSaveData writes it only when set.
if m.extra.cannot_be_hunted {
map.insert("CannotBeHunted".into(), Tag::Byte(1));
} else {
map.remove(&"CannotBeHunted".into());
}
map.insert(
"CannotBeHunted".into(),
Tag::Byte(m.extra.cannot_be_hunted as i8),
);
}
if id == "minecraft:piglin" {
// InventoryCarrier.writeInventoryToTag.
@@ -3113,6 +3111,9 @@ pub fn cushion_to_nbt(c: &Cushion, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let mut tag = base.tag(raw);
put(&mut tag, "block_pos", Tag::IntArray(c.pos.to_vec()));
put(&mut tag, "color", string(c.color_name()));
if let Some(name) = &c.custom_name {
put(&mut tag, "CustomName", name.clone());
}
tag
}
/// The position, block position, yaw and colour of a saved cushion.
@@ -3142,6 +3143,7 @@ pub fn cushion_from_nbt(tag: &Tag, id: u32) -> Option<Cushion> {
yaw,
color,
check_interval: 0,
custom_name: tag.get("CustomName").cloned(),
})
}
@@ -3158,6 +3160,7 @@ mod cushion_tests {
yaw: 270.0,
color: 14,
check_interval: 55,
custom_name: None,
};
let raw = Tag::compound([("CustomName", string("Seat"))]);
let tag = cushion_to_nbt(&cushion, [1, 2, 3, 4], Some(&raw));
@@ -5163,8 +5166,8 @@ mod sheep_nbt_tests {
}
/// `Hoglin.readAdditionalSaveData` and `addAdditionalSaveData`:
/// `CannotBeHunted` (a bastion's hoglins) stays with the hoglin, and such a
/// hoglin is no piglin's prey (`Hoglin.canBeHunted`).
/// `CannotBeHunted` (a bastion's hoglins) stays with the hoglin and is always
/// written, and such a hoglin is no piglin's prey (`Hoglin.canBeHunted`).
#[test]
fn a_hoglin_keeps_cannot_be_hunted_and_is_no_prey() {
use crate::mobs::Species;
@@ -5194,7 +5197,7 @@ mod sheep_nbt_tests {
assert!(!prey.extra.cannot_be_hunted);
assert!(prey.seen(None).huntable);
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None);
assert_eq!(saved.get("CannotBeHunted"), None);
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(0)));
}
}
+42 -1
View File
@@ -244,6 +244,7 @@ mod native_reload_listeners;
mod native_scene;
mod native_server;
mod native_shaders;
mod native_structures;
mod native_ticks;
mod native_visuals;
mod native_world_columns;
@@ -444,6 +445,7 @@ pub use native_item_energy::ItemEnergyBuffer;
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_reload_listeners::NativeReloadListener;
pub use native_server::NativeExperienceOrb;
pub use native_structures::{NativeStructureBlock, NativeStructureTemplate};
pub use native_world_columns::NativeColumnBlocks;
pub use native_world_draw::NativeWorldDraw;
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
@@ -6837,7 +6839,12 @@ impl Game<'_> {
}
return Ok(true);
}
if block_hit.is_some() && (self.use_bone_meal(&stack)? || self.ignite_tnt(&stack)?) {
// TntBlock.useItemOn is the block's own: a sneak with something in hand skips it, and
// the flint and steel or fire charge lights a fire on the TNT instead.
if block_hit.is_some()
&& (self.use_bone_meal(&stack)?
|| (!(sneaking && hands_full) && self.ignite_tnt(&stack)?))
{
return Ok(true);
}
// ShearsItem.useOn: shears crop a growing plant's head to its top age.
@@ -6908,6 +6915,16 @@ impl Game<'_> {
self.use_spyglass();
return Ok(true);
}
// FoodOnAStickItem.use with no boost (not riding its mount, or the
// mount refused): PASS, but counted as a use of the stick.
if !on_block
&& matches!(
&*stack.item,
"minecraft:carrot_on_a_stick" | "minecraft:warped_fungus_on_a_stick"
)
{
self.award_stat("used", &stack.item, 1);
}
// BundleItem.use: startUsingItem, then onUseTick empties it.
if !on_block && info.bundle {
self.start_using_item();
@@ -13844,6 +13861,15 @@ impl Game<'_> {
&mut self.world.random,
),
);
// FlintAndSteelItem / FireChargeItem.useOn: BLOCK_CHANGE at the lit block,
// and ItemStack.useOn's used statistic.
self.post_block_game_event(
"minecraft:block_change",
hit.pos,
self.sculk_actor(),
None,
);
self.award_stat("used", &stack.item, 1);
if item == "fire_charge" {
self.consume_held();
} else {
@@ -13866,6 +13892,20 @@ impl Game<'_> {
&mut self.world.random,
);
self.audio.play(sound, Some(at(target)), 1.0, pitch);
// BLOCK_PLACE: flint and steel names the clicked block, a fire charge the
// fire. Then ItemStack.useOn's used statistic.
let placed_at = if item == "fire_charge" {
target
} else {
hit.pos
};
self.post_block_game_event(
"minecraft:block_place",
placed_at,
self.sculk_actor(),
None,
);
self.award_stat("used", &stack.item, 1);
if item == "fire_charge" {
self.consume_held();
} else {
@@ -14288,6 +14328,7 @@ impl Game<'_> {
.map_or(eye, |pl| [pl.position.x, pl.position.y, pl.position.z]);
self.audio
.play("minecraft:entity.arrow.shoot", Some(feet), 1.0, pitch);
self.award_stat("used", "minecraft:bow", 1);
if !self.creative
&& !infinity
&& let Some(i) = arrow_slot
@@ -911,6 +911,7 @@ impl Game<'_> {
if landed {
self.post_landing_event(self.armor_stands[i].position, true);
}
self.armor_stands[i].apply_honey_slide(&self.world, self.res.facts());
let a = &self.armor_stands[i];
if !a.marker && !a.no_gravity {
self.entity_powder_snow_particles(
@@ -1480,6 +1481,10 @@ impl Game<'_> {
}) {
return true;
}
// Counted on the server only, before the swap.
if self.remote.is_none() {
self.award_stat("used", &stack.item, 1);
}
let swap = swap_with_equipment_slot(stack, current, self.creative);
self.armor[index] = Some(swap.worn.clone());
// Player.setItemSlot: onEquipItem plays the equip sound.
+10
View File
@@ -1240,9 +1240,19 @@ impl Game<'_> {
],
);
}
// BoatItem.use: ENTITY_PLACE at the hit (the boat's position), by the player.
let at = self
.boats
.last()
.map(|b| [b.position.x, b.position.y, b.position.z]);
if let Some(at) = at {
let ctx = self.player_context(None);
self.post_game_event("minecraft:entity_place", at, ctx);
}
if !self.creative {
self.consume_held();
}
self.award_stat("used", &stack.item, 1);
self.swing_arm();
true
}
+2
View File
@@ -200,6 +200,8 @@ impl Game<'_> {
} else if let Some(screen) = self.book_screen(stack) {
self.open_book(screen);
}
// WritableBookItem.use / WrittenBookItem.use count every open.
self.award_stat("used", &stack.item, 1);
self.swing_arm();
true
}
@@ -250,6 +250,68 @@ impl Game<'_> {
out.push('\n');
}
}
"mob_anger" => {
// Metadata-state equality through the production writer, not packet delivery.
let uuid = u128::from_str_radix(
&op["uuid"]
.as_str()
.ok_or("anger state: missing UUID")?
.replace('-', ""),
16,
)?;
let index = op["index"].as_u64().ok_or("anger state: missing index")? as u8;
let state = self.harness_owner(|g| {
let Some(super::commands::Target::Mob(id)) = g.target_by_uuid(uuid) else {
return Err("anger state: UUID is not a loaded mob".into());
};
let actual = g
.persist
.uuids
.get(&id)
.copied()
.ok_or("anger state: no saved UUID")?;
if horizon_client::lan::playerdata::ints_uuid(actual) != uuid {
return Err("anger state: UUID mismatch".into());
}
let m = g
.mobs
.iter()
.find(|m| m.id == id && m.alive() && !m.removed)
.ok_or("anger state: missing live actor")?;
if Some(m.kind.id.as_str()) != op["kind"].as_str() {
return Err("anger state: wrong actor type".into());
}
let hub = &g.lan.as_ref().ok_or("anger state: no connected hub")?.hub;
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
super::lan::mob_meta(
m,
hub,
None,
g.world.game_time,
&mut meta,
&mut defaults,
);
let entry = meta
.iter()
.find(|e| e.0 == index)
.or_else(|| defaults.iter().find(|e| e.0 == index))
.ok_or("anger state: metadata entry/default absent")?;
if entry.1 != horizon_protocol::entity_data::serializer::LONG {
return Err("anger state: metadata serializer is not LONG".into());
}
let horizon_protocol::entity_data::Value::Long(value) = &entry.2 else {
return Err("anger state: metadata value is not long".into());
};
Ok(json!({"uuid":actual,"kind":m.kind.id,"index":index,
"serializer":entry.1,"value":value,"game_time":g.world.game_time}))
})?;
out.push_str(
&json!({"cmd":format!("#mob_anger {}",op["label"].as_str().unwrap_or("")),
"messages":[],"results":[],"state":state})
.to_string(),
);
out.push('\n');
}
"server_distances" => {
let state = self.harness_owner(|g| {
let hub = &g.lan.as_ref().ok_or("no connected hub")?.hub;
@@ -365,6 +365,14 @@ impl Game<'_> {
slot.extra.armor_attributes = old.extra.armor_attributes;
slot.age = old.age;
slot.previous = slot.position;
// Wolf.readAdditionalSaveData initializes persistent anger at
// the live level clock, just as load_mob does on admission.
if slot.species() == horizon_client::mobs::Species::Wolf {
slot.extra.anger = horizon_client::mobs::anger_left(
slot.extra.anger_end,
self.world.game_time,
);
}
}
}
Target::Item(id) => {
@@ -1186,6 +1194,107 @@ impl Game<'_> {
}
}
#[cfg(test)]
mod wolf_live_anger_tests {
use super::Target;
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_world::nbt::Tag;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn live_wolf_anger_writes_use_the_level_clock_and_keep_bee_state() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.world.game_time = 12_345;
// Bee's independently modeled end is the first control, before the Wolf regression.
for (kind, health, number) in [("bee", 10.0_f32, 1), ("wolf", 8.0, 2)] {
game.mobs.clear();
let tag = Tag::Compound(
[
(
"id".into(),
Tag::String(format!("minecraft:{kind}").as_str().into()),
),
("UUID".into(), Tag::IntArray(vec![316, 1, 0, number])),
(
"Pos".into(),
Tag::List(vec![
Tag::Double(10.5_f64.to_bits()),
Tag::Double(200.0_f64.to_bits()),
Tag::Double(10.5_f64.to_bits()),
]),
),
("Health".into(), Tag::Float(health.to_bits())),
("NoAI".into(), Tag::Byte(1)),
("NoGravity".into(), Tag::Byte(1)),
("Silent".into(), Tag::Byte(1)),
("PersistenceRequired".into(), Tag::Byte(1)),
("anger_end_time".into(), Tag::Long(-1)),
]
.into_iter()
.collect(),
);
let id = game.load_mob(tag, None).unwrap();
let i = game.mobs.iter().position(|m| m.id == id).unwrap();
game.mobs[i].invulnerable = 17;
game.mobs[i].last_hurt = 2.0;
game.mobs[i].age = 6;
for end in [12_395, 12_395, 12_420, -1] {
let mut tag = game.mob_tree_nbt(i);
let Tag::Compound(fields) = &mut tag else {
panic!("mob compound")
};
fields.insert("anger_end_time".into(), Tag::Long(end));
game.apply_entity_nbt(Target::Mob(id), tag).unwrap();
let saved = game.mob_tree_nbt(i);
assert_eq!(
saved.get("anger_end_time"),
Some(&Tag::Long(end)),
"{kind} end={end}"
);
assert_eq!(game.world.game_time, 12_345);
assert_eq!(game.persist.uuids.get(&id), Some(&[316, 1, 0, number]));
assert_eq!(game.mobs[i].health, health);
assert!(game.mobs[i].no_ai);
assert_eq!(
(
game.mobs[i].invulnerable,
game.mobs[i].last_hurt,
game.mobs[i].age
),
(17, 2.0, 6)
);
}
}
}
}
#[cfg(test)]
mod loot_table_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
@@ -944,9 +944,16 @@ impl Game<'_> {
let port = match ctx.get("port") {
Some(Arg::Int(p)) => *p,
// HttpUtil.getAvailablePort.
_ => std::net::TcpListener::bind(("0.0.0.0", 0))
.and_then(|l| l.local_addr())
.map_or(25564, |a| i32::from(a.port())),
_ => std::net::TcpListener::bind((
if std::env::var_os("HORIZON_TEST_LOOPBACK").is_some_and(|v| v == "1") {
"127.0.0.1"
} else {
"0.0.0.0"
},
0,
))
.and_then(|l| l.local_addr())
.map_or(25564, |a| i32::from(a.port())),
};
let published = if self.serving() {
self.published_port()
@@ -400,6 +400,7 @@ impl Game<'_> {
// is an empty stack (no match) when the shots broke it.
if let Some(stack) = self.held().cloned() {
self.trigger_item("minecraft:shot_crossbow", &stack);
self.award_stat("used", &stack.item, 1);
}
}
}
+31 -6
View File
@@ -278,6 +278,7 @@ impl Game<'_> {
yaw: snap_yaw(self.yaw),
color,
check_interval: 0,
custom_name: None,
});
self.world
.modified
@@ -396,12 +397,7 @@ impl Game<'_> {
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
return;
}
let stack = ItemStack {
item: std::sync::Arc::from(c.item()),
count: 1,
damage: 0,
extra: None,
};
let stack = c.item_stack_with_data();
self.spawn_item(stack, [p.x, p.y, p.z], [0.0; 3], 10);
}
@@ -607,3 +603,32 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::Tag;
/// `Cushion.getCushionItemStackWithData`: a named cushion drops named.
#[test]
fn a_named_cushion_drops_with_its_name() {
let c = Cushion {
id: 1,
position: Vec3::ZERO,
pos: [0, 64, 0],
yaw: 0.0,
color: 1,
check_interval: 0,
custom_name: Some(Tag::String("Seat".into())),
};
let stack = c.item_stack_with_data();
assert_eq!(&*stack.item, "minecraft:orange_cushion");
assert!(stack.data().is_some_and(|d| d.custom_name.is_some()));
// An unnamed one gains no component.
let c = Cushion {
custom_name: None,
..c
};
assert!(c.item_stack_with_data().data().is_none());
}
}
+3 -19
View File
@@ -67,7 +67,7 @@ impl Game<'_> {
return Ok(false);
};
if state.properties.is_empty() {
self.debug_stick_message(text(
self.send_overlay_message(text(
"item.minecraft.debug_stick.empty",
&[state.name.as_str()],
));
@@ -121,7 +121,7 @@ impl Game<'_> {
);
});
self.process_level_events()?;
self.debug_stick_message(text(
self.send_overlay_message(text(
"item.minecraft.debug_stick.update",
&[property.as_str(), value.as_str()],
));
@@ -135,7 +135,7 @@ impl Game<'_> {
let property = keys[relative_index(keys.len(), current, backward)].clone();
let value = state.properties.get(&property).cloned().unwrap_or_default();
self.set_debug_stick_property(&state.name, &property);
self.debug_stick_message(text(
self.send_overlay_message(text(
"item.minecraft.debug_stick.select",
&[property.as_str(), value.as_str()],
));
@@ -151,22 +151,6 @@ impl Game<'_> {
stack.data_mut().set_debug_stick_property(block, property);
stack.tidy();
}
/// `ServerPlayer.sendOverlayMessage`: the steady player sees it on this
/// game's HUD; a server client receives it as a packet.
fn debug_stick_message(&mut self, message: Text) {
if self.lan_acting.is_some() {
self.guest_packets
.push(horizon_protocol::packets::play::Clientbound::SystemChat(
horizon_protocol::packets::play::SystemChat {
content: horizon_client::lan::data::component_nbt(&message.component()),
overlay: true,
},
));
} else if self.options.dedicated.is_none() {
self.titles.set_overlay(message, false);
}
}
}
#[cfg(test)]
@@ -553,6 +553,8 @@ impl Game<'_> {
self.keys.insert(KeyCode::ShiftLeft);
}
self.using = None;
// The oracle's player wears nothing: no armor left on from an earlier case.
self.armor = Default::default();
self.inventory[self.selected] = stack.clone();
self.offhand = parse_stack(op["offhand"].as_str().unwrap_or("-"))?;
// The used statistic of each item held, once.
+1
View File
@@ -1222,6 +1222,7 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.ender_eye.launch", Some(at), 1.0, pitch);
self.consume_held();
self.award_stat("used", "minecraft:ender_eye", 1);
self.swing_arm();
}
+29 -11
View File
@@ -355,12 +355,10 @@ impl Game<'_> {
}
// Server path of `use`: `dropAllLeashConnections(null)`, and when that
// drops anything `playSound(null, player, LEAD_BREAK, NEUTRAL, 1, 1)`.
// `SoundEvents.LEAD_BREAK` is `minecraft:item.lead.break`. A position
// is what the server thread forwards, so the window hears the snap.
if self.drop_all_leash_connections()
&& let Some(p) = self.player.as_ref()
{
self.lead_sound("break", [p.position.x, p.position.y, p.position.z]);
// The entity overload follows the player and excludes nobody, including
// the guest whose action is running while its hub is swapped out.
if self.drop_all_leash_connections() {
self.play_player_bound_sound("minecraft:item.lead.break", 6, 1.0, 1.0, false);
}
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let mut rocket = self.new_rocket(at, stack, true);
@@ -370,6 +368,7 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.award_stat("used", "minecraft:firework_rocket", 1);
self.swing_arm();
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_elytra_boost(player, &name);
@@ -772,8 +771,9 @@ mod tests {
/// `FireworkRocketItem.use` on the server, while fall-flying: a lead on a
/// mob within the 32-block box drops as an item and one
/// `minecraft:item.lead.break` plays at the player's feet. A lead outside
/// that box, or held for another player, stays. Standing still does neither.
/// `minecraft:item.lead.break` follows the player on NEUTRAL, heard by the
/// acting player too. A lead outside that box, or held for another player,
/// stays. Standing still does neither.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_gliding_firework_drops_leads_and_plays_lead_break() {
@@ -823,6 +823,9 @@ mod tests {
.get("minecraft:cow")
.expect("cow")
.clone();
// A server action keeps the guest identity while its hub is swapped out.
game.lan_acting = Some(11);
game.lan_acting_entity = Some(37);
let me = game.acting_uuid();
let someone_else = [me[0].wrapping_add(1), me[1], me[2], me[3]];
let mut leashed = |id, at: Vec3, owner: [i32; 4]| {
@@ -860,11 +863,26 @@ mod tests {
assert_eq!(leads.len(), 1);
let heard = game.audio.capture.take().unwrap_or_default();
let snaps: Vec<_> = heard
.into_iter()
.iter()
.filter(|s| s.event == "minecraft:item.lead.break")
.map(|s| (s.position, s.volume, s.pitch))
.collect();
assert_eq!(snaps, vec![(feet, 1.0, 1.0)]);
assert_eq!(snaps.len(), 1);
let snap = snaps[0];
assert_eq!((snap.position, snap.volume, snap.pitch), (feet, 1.0, 1.0));
assert_eq!(
snap.entity,
Some(37),
"the snap must follow the acting guest"
);
assert_eq!(snap.source, 6, "SoundSource.NEUTRAL");
assert_eq!(snap.except_entity, None, "vanilla excludes no player");
let (packet, range) =
horizon_client::lan::containers::captured_sound_packet(snap, None, 42);
let horizon_protocol::packets::play::Clientbound::SoundEntity(packet) = packet else {
panic!("a firework lead snap must use the entity-bound packet");
};
assert_eq!((packet.id, packet.source, range), (37, 6, 16.0));
assert_eq!(packet.sound.1.unwrap().0, "minecraft:item.lead.break");
}
}
horizon_client::impl_joined!(Rocket);
+7 -9
View File
@@ -194,10 +194,6 @@ fn bite_impulse(r: &mut impl RandomSource) -> f64 {
// Mth.nextFloat(random, min, max): `random.nextFloat() * (max - min) + min`.
f64::from(-0.4f32 * (r.next_float() * (1.0f32 - 0.6f32) + 0.6f32))
}
/// RandomSource.triangle(mode, deviation).
fn triangle(r: &mut impl RandomSource, mode: f64, deviation: f64) -> f64 {
mode + deviation * (f64::from(r.next_float()) - f64::from(r.next_float()))
}
/// `FishingHook.onHitBlock`: `getDeltaMovement().normalize().scale(hit.distanceTo(this))`,
/// where `HitResult.distanceTo` is the *squared* distance to the hook: the bobber
/// springs back a little off a wall instead of stopping dead.
@@ -269,11 +265,11 @@ impl Game<'_> {
);
let mut v = Vec3::new(f64::from(-y_sin), f64::from(dir_y), f64::from(-y_cos));
let len = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt();
let r = &mut self.world.random;
let r = &mut LegacyRandom::new(horizon_client::sound_engine::unique_seed());
v = Vec3::new(
v.x * (0.6 / len + triangle(r, 0.5, 0.0103365)),
v.y * (0.6 / len + triangle(r, 0.5, 0.0103365)),
v.z * (0.6 / len + triangle(r, 0.5, 0.0103365)),
v.x * (0.6 / len + r.triangle(0.5, 0.0103365)),
v.y * (0.6 / len + r.triangle(0.5, 0.0103365)),
v.z * (0.6 / len + r.triangle(0.5, 0.0103365)),
);
self.next_entity += 1;
self.hook = Some(Hook {
@@ -297,6 +293,8 @@ impl Game<'_> {
lure,
lerp: HookLerp::default(),
});
// FishingRodItem.use counts a cast, not a reel-in.
self.award_stat("used", "minecraft:fishing_rod", 1);
}
self.swing_arm();
true
@@ -550,7 +548,7 @@ impl Game<'_> {
if h.time_until_hooked > 0 {
// The fish swimming up: a bubble now and then, and a fishing
// particle to each side of its path.
h.fish_angle += triangle(&mut self.world.random, 0.0, 9.188) as f32;
h.fish_angle += self.world.random.triangle(0.0, 9.188) as f32;
let angle = h.fish_angle * (std::f64::consts::PI / 180.0) as f32;
let (sin, cos) = (trig::sin(f64::from(angle)), trig::cos(f64::from(angle)));
let distance = h.time_until_hooked as f32 * 0.1;
+6 -1
View File
@@ -231,8 +231,13 @@ impl Game<'_> {
/// `SpyglassItem.use`: the sound, then start scoping (a mod's `Start`
/// may refuse that, not the sound).
pub(super) fn use_spyglass(&mut self) {
let at = self.eye_position();
// Player.playSound: at the feet (getY), not the eyes.
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio.play("minecraft:item.spyglass.use", at, 1.0, 1.0);
self.award_stat("used", "minecraft:spyglass", 1);
self.start_using_item();
}
/// `SpyglassItem.releaseUsing` / `finishUsingItem` (after 1200 ticks).
+4 -3
View File
@@ -489,7 +489,7 @@ fn mob_variant_meta(
/// from the defaults the client constructs), with vanilla's entry indices:
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
/// `defaults` gets the defaults of the entries that can go back to theirs.
fn mob_meta(
pub(super) fn mob_meta(
m: &Mob,
hub: &Hub,
player_entity: Option<i32>,
@@ -2083,7 +2083,7 @@ impl Game<'_> {
let conn = guest.conn;
let at = host.hub.guests.len();
host.hub.guests.push(*guest);
self.guest_close_all(&mut host, conn);
self.guest_disconnected(&mut host, conn);
*guest = host.hub.guests.remove(at);
}
if let Err(e) = self.enter_level(Some(&mut host), back) {
@@ -3687,6 +3687,7 @@ mod tests {
for (template, id) in [
("minecraft:zombie", "ruby:ghoul"),
("minecraft:villager", "ruby:settler"),
("minecraft:wandering_trader", "ruby:merchant"),
] {
assert_eq!(
meta_of(id),
@@ -3702,7 +3703,7 @@ mod tests {
let sync = std::sync::Arc::new(data::SyncData::empty());
let config = NetConfig {
announce: false,
..NetConfig::lan(0, name.into(), 2)
..NetConfig::loopback(0, name.into(), 2)
};
let lan = Lan::start_with(sync.clone(), config).unwrap();
let encoder = ChunkEncoder::new(
+99 -3
View File
@@ -7,7 +7,7 @@
//! block log, the level events and the entity tracker.
use super::{
Dim, Game, GuestUse, Hit, InteractTarget, Mining, Screen, lan::LanHost,
respawn_anchor::RespawnConfig,
lan_crafting::MenuRemoval, respawn_anchor::RespawnConfig,
};
use horizon_client::{
command::vanilla,
@@ -1435,6 +1435,21 @@ impl Game<'_> {
} else {
return false;
};
// `ContainerEntity.unpackChestVehicleLootTable`: a chest boat still
// holding a loot table rolls it on the first open.
if let Some(i) = self
.boats
.iter()
.position(|b| b.id as i32 == pos.2 && b.chest)
{
// AbstractChestBoat.createMenu: a spectator opens no boat whose
// loot table is still to roll, and the table stays.
let spectator = host.hub.guest(conn).is_some_and(|g| g.spectator());
if spectator && self.boat_loot_pending(self.boats[i].id) {
return false;
}
self.unpack_boat_loot(i);
}
// `createMenu` (ChestMenu.threeRows or a HopperMenu), and
// `MenuProvider.getDisplayName`, the vehicle's name key.
let Some((menu, _)) = horizon_client::containers::vehicle_menu(&entity_type) else {
@@ -1463,6 +1478,64 @@ impl Game<'_> {
true
}
/// `ContainerEntity.unpackChestVehicleLootTable` for a chest boat: the table
/// its raw NBT still holds rolls into the slots on the first open (an empty
/// boat, as a command leaves one), and `player_generates_container_loot`
/// fires for the player who opened it.
fn boat_loot_pending(&self, id: u32) -> bool {
matches!(
self.persist.raw.get(&id),
Some(horizon_world::nbt::Tag::Compound(map)) if map.contains_key(&"LootTable".into())
)
}
fn unpack_boat_loot(&mut self, i: usize) {
use horizon_world::nbt::Tag;
let id = self.boats[i].id;
let (table, seed) = {
let Some(Tag::Compound(map)) = self.persist.raw.get_mut(&id) else {
return;
};
let Some(table) = crate::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
else {
return;
};
let seed = match map.get(&"LootTableSeed".into()) {
Some(Tag::Long(seed)) => Some(*seed),
_ => None,
};
// The table is spent even when nothing can be rolled into it (the
// loot data is not loaded): vanilla clears it before filling.
map.remove(&"LootTable".into());
map.remove(&"LootTableSeed".into());
(table, seed)
};
// `LootTable.fill` sets the slots it rolls and leaves the rest; a boat
// that already holds something keeps it, so no table is applied over it.
if self.boats[i].items.iter().any(Option::is_some) {
return;
}
let loot = crate::structure_blocks::loot_data(&self.options.resources, &self.pack_files());
let b = &self.boats[i];
let at = Vec3::new(b.position.x, b.position.y, b.position.z);
let biome = self.biome_name_at(at).unwrap_or_default();
let pos = (
b.position.x.floor() as i32,
b.position.y.floor() as i32,
b.position.z.floor() as i32,
);
crate::structure_blocks::roll_container(
&mut self.boats[i].items,
loot.as_deref(),
table.clone(),
seed,
pos,
&biome,
);
self.trigger_loot_table(&table);
}
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
/// it no longer counts among the openers of the lid, and the lid's close
/// sound is played for everyone around (`ChestBlockEntity.onClose`, a
@@ -1487,9 +1560,25 @@ impl Game<'_> {
/// whose server is closing. The guest must be in `host.hub.guests` and the
/// level they are in must be the one in place.
pub(super) fn guest_close_all(&mut self, host: &mut LanHost, conn: u32) {
self.guest_remove_menus(host, conn, MenuRemoval::Live);
}
/// `AbstractContainerMenu.removed` after the player has disconnected.
/// Only HostEvent::Left selects this policy; ordinary closes and shutdown
/// keep their existing return-to-inventory behavior.
pub(super) fn guest_disconnected(&mut self, host: &mut LanHost, conn: u32) {
self.guest_remove_menus(host, conn, MenuRemoval::Disconnected);
}
/// `AbstractContainerMenu.removed`: close the guest's active menu using
/// its live or disconnected disposition for cursor and crafting inputs.
fn guest_remove_menus(&mut self, host: &mut LanHost, conn: u32, removal: MenuRemoval) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
if removal == MenuRemoval::Disconnected {
self.guest_drop_carried(host, gi);
}
if let Some((window, slots)) = host.hub.guests[gi]
.menu
.as_ref()
@@ -1504,10 +1593,17 @@ impl Game<'_> {
host.hub.container_closed(conn, window);
}
self.lan_merchant_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.lan_crafting_removed(host, conn, None, removal);
if removal == MenuRemoval::Disconnected {
// The inventory grid is separate from an open table. Empty both
// before lan_crafting_left saves the guest; never drop result slots.
self.lan_crafting_removed(host, conn, Some(0), removal);
}
self.lan_station_closed(host, conn, None);
self.lan_mount_closed(host, conn, None);
self.guest_carried_back(host, gi);
if removal == MenuRemoval::Live {
self.guest_carried_back(host, gi);
}
// `PlayerContainerEvent.Close`, after the menu's `removed`.
self.player_menu_closing(host, conn);
}
+24 -1
View File
@@ -14,6 +14,14 @@ use horizon_client::{
};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// `AbstractContainerMenu.dropOrPlaceInInventory`: actual network disconnects
/// drop inputs; a live close still returns them to the inventory.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum MenuRemoval {
Live,
Disconnected,
}
/// The guest's menus the game runs: kept between clicks, so a drag (quick
/// craft) spans them as in vanilla.
#[derive(Default)]
@@ -675,6 +683,18 @@ impl Game<'_> {
host: &mut LanHost,
conn: u32,
window: Option<i32>,
) -> bool {
self.lan_crafting_removed(host, conn, window, MenuRemoval::Live)
}
/// `AbstractContainerMenu.clearContainer`: only input slots are removed;
/// `dropOrPlaceInInventory` chooses drops for a disconnected player.
pub(super) fn lan_crafting_removed(
&mut self,
host: &mut LanHost,
conn: u32,
window: Option<i32>,
removal: MenuRemoval,
) -> bool {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return false;
@@ -695,7 +715,10 @@ impl Game<'_> {
}
};
if !grid.is_empty() {
self.lan_place_back(host, gi, grid);
match removal {
MenuRemoval::Live => self.lan_place_back(host, gi, grid),
MenuRemoval::Disconnected => self.drop_guest_items(host, gi, grid),
}
}
true
}
@@ -917,7 +917,7 @@ mod tests {
fn hitting_shulker_bullet_sends_15_crit_particles_to_players() {
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "ShulkerBulletTest".into(), 2)
..NetConfig::loopback(0, "ShulkerBulletTest".into(), 2)
};
let mut sync = data::SyncData::empty();
sync.builtin.registries.insert(
+31 -16
View File
@@ -95,6 +95,9 @@ pub(super) struct Sweep {
pub(super) hit_box: Aabb,
/// The entity id of the player the sweep started from, which it spares.
pub(super) skip: Option<i32>,
/// The weapon's `minecraft:ignite` (Fire Aspect) on each swept player, in
/// seconds (`doPostAttackEffects` after a sweep hit lands).
ignite: f32,
}
impl Strike {
@@ -155,22 +158,7 @@ impl Game<'_> {
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
};
let ignite = self.held().map_or(0.0, |held| {
let ctx = EffectContext {
this: Some("minecraft:player"),
attacker: Some("minecraft:player"),
direct_attacker: Some("minecraft:player"),
is_direct: true,
items: Some(world_items!(self)),
..Default::default()
};
let mut random = self.world.random.clone();
world_stations!(self).enchantments.ignite_seconds(
held.enchantments(),
&ctx,
&mut random,
)
});
let ignite = self.weapon_ignite_seconds();
Some(Strike {
attacker: self.lan_acting,
amount,
@@ -188,6 +176,26 @@ impl Game<'_> {
})
}
/// The held weapon's `minecraft:ignite` (Fire Aspect) seconds on a player
/// victim (`EnchantmentHelper.doPostAttackEffects`).
fn weapon_ignite_seconds(&mut self) -> f32 {
let Some(held) = self.held().cloned() else {
return 0.0;
};
let ctx = EffectContext {
this: Some("minecraft:player"),
attacker: Some("minecraft:player"),
direct_attacker: Some("minecraft:player"),
is_direct: true,
items: Some(world_items!(self)),
..Default::default()
};
let mut random = self.world.random.clone();
world_stations!(self)
.enchantments
.ignite_seconds(held.enchantments(), &ctx, &mut random)
}
/// The guests by connection, with their entity ids.
pub(super) fn lan_guest_entities(&self) -> Vec<(u32, u32)> {
self.lan.as_ref().map_or_else(Vec::new, |host| {
@@ -453,6 +461,7 @@ impl Game<'_> {
team: self.player_team().cloned(),
hit_box: Aabb::new(Vec3::ZERO, Vec3::ZERO),
skip: None,
ignite: self.weapon_ignite_seconds(),
}
}
@@ -509,6 +518,12 @@ impl Game<'_> {
}
if g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack") {
g.knock_back_player(0.4, xd, zd, PVP_SOURCE);
// EnchantmentHelper.doPostAttackEffects, after the knockback.
if sweep.ignite > 0.0
&& let Some(p) = g.player.as_mut()
{
let _ = p.ignite_for_ticks((sweep.ignite * 20.0) as i32);
}
}
};
for (victim, id, team) in victims {
+10
View File
@@ -258,6 +258,9 @@ impl Game<'_> {
// The causing entity's aim, for what turns a blow by where it points
// (`SulfurCube.knockback`).
self.mobs[index].attacker_aim = aim;
// Absorbed tenths left by a hit that came another way (a player's projectile) are
// not this player's.
self.mobs[index].dealt_absorbed = 0;
let hurt = match player_uuid {
Some(uuid) => {
self.mobs[index].damage_with_player_uuid(amount, source, uuid, Some(&mut breach))
@@ -265,6 +268,13 @@ impl Game<'_> {
None => self.mobs[index].damage_with(amount, source, Some(&mut breach)),
};
self.mobs[index].attacker_aim = None;
// LivingEntity.actuallyHurt: Stats.DAMAGE_DEALT_ABSORBED for the attacking player,
// awarded here, during that player's own attack (a server's guest has its own
// statistics only inside `as_guest`).
let absorbed = std::mem::take(&mut self.mobs[index].dealt_absorbed);
if absorbed > 0 {
self.award_custom_stat("damage_dealt_absorbed", absorbed);
}
if hurt && self.mobs[index].health != old_health {
self.mob_damage_event(self.mobs[index].id);
}
+2
View File
@@ -1140,6 +1140,8 @@ impl Game<'_> {
self.consume_held();
self.give_or_drop_stack(filled);
}
// EmptyMapItem.use: consumed, then counted.
self.award_stat("used", "minecraft:map", 1);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_map_filled(player, &name, id);
}
+40
View File
@@ -447,12 +447,16 @@ pub(super) fn living_mod_types()
r#""width":0.6,"height":1.95,"eye_height":1.74,"category":"monster","fire_immune":false"#;
let villager =
r#""width":0.6,"height":1.95,"eye_height":1.62,"category":"misc","fire_immune":false"#;
let trader =
r#""width":0.6,"height":1.95,"eye_height":1.62,"category":"creature","fire_immune":false"#;
let table = format!(
r#"{{"minecraft:zombie":{{{zombie},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.23000000417232513}}}},
"minecraft:villager":{{{villager},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.5}}}},
"minecraft:wandering_trader":{{{trader},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.7}}}},
"minecraft:snowball":{{"width":0.25,"height":0.25,"eye_height":0.0,"category":"misc","fire_immune":false}},
"ruby:ghoul":{{{zombie},"attributes":{{"minecraft:max_health":40.0,"minecraft:movement_speed":0.23000000417232513}},"template":"minecraft:zombie"}},
"ruby:settler":{{{villager},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.5}},"template":"minecraft:villager"}},
"ruby:merchant":{{{trader},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.7}},"template":"minecraft:wandering_trader"}},
"ruby:bolt":{{"width":0.25,"height":0.25,"eye_height":0.0,"category":"misc","fire_immune":false,"template":"minecraft:snowball"}}}}"#
);
// A folder per call: tests run in parallel in one process, and each removes its own.
@@ -541,6 +545,42 @@ mod tests {
);
}
/// A content mod's type from the wandering trader, the neutral template
/// whose behaviour is all goals: a trader under its own id, with a
/// trader's goals, that saves what a trader saves and loads back.
#[test]
fn a_mod_trader_type_is_a_wandering_trader_under_its_own_id() {
use horizon_client::mobs::{Mob, Species};
use horizon_world::nbt::Tag;
let types = living_mod_types();
let merchant = &types["ruby:merchant"];
assert_eq!(merchant.species, Species::WanderingTrader);
assert_eq!(merchant.class_id(), "minecraft:wandering_trader");
let at = horizon_core::geometry::Vec3::new(0.5, 64.0, 0.5);
let trader = Mob::new(1, types["minecraft:wandering_trader"].clone(), at, 0.0);
let merchant = Mob::new(2, merchant.clone(), at, 0.0);
assert!(merchant.ai.goals.has("Float") && trader.ai.goals.has("Float"));
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
let keys = |tag: &Tag| -> Vec<String> {
let Tag::Compound(map) = tag else {
panic!("a mob saves a compound");
};
map.keys().map(|k| k.to_string_lossy()).collect()
};
let tag = saved(&merchant);
assert_eq!(
keys(&tag),
keys(&saved(&trader)),
"it saves what a trader saves"
);
let loaded =
horizon_client::entity_nbt::mob_from_nbt(&tag, &types, 3, None).expect("it loads back");
assert_eq!(
(loaded.kind.id.as_str(), loaded.species()),
("ruby:merchant", Species::WanderingTrader)
);
}
/// Only a build whose `native-mods.json` lists a mod counts as built with mods.
#[test]
fn only_a_listed_mod_makes_a_build_a_mod_build() {
+116 -9
View File
@@ -153,10 +153,17 @@ impl Game<'_> {
.into_iter()
.chain(self.own_name_tag())
.map(|(m, name)| (m, name, true));
// Who is riding whom: asked once per tag below, and rescanning every mob
// for each tag made this quadratic in the number of loaded mobs.
let ridden: std::collections::HashSet<u32> =
self.mobs.iter().filter_map(|o| o.extra.mount).collect();
// Who is riding whom: asked once per tag below, and rescanning every entity
// for each tag made this quadratic in the number of loaded entities.
let local_entity_id = self.remote.as_ref().map(|r| r.entity_id as u32);
let ridden = collect_vehicle_ids(
&self.mobs,
self.text_displays.values().filter_map(|d| d.vehicle),
self.armor_stands.iter().filter_map(|a| a.vehicle),
self.interactions.values().filter_map(|i| i.vehicle),
self.riding,
local_entity_id,
);
for (m, name, player) in named.chain(players) {
if !m.alive() {
continue;
@@ -207,7 +214,9 @@ impl Game<'_> {
.then(|| m.extra.description.component())
.flatten()
.map(|tag| horizon_client::entity_nbt::name_text(&tag).resolve_legacy());
let is_own = Some(m.id) == own;
let name_shown = sb::name_shown(team, viewer, view)
&& (!is_own || !view.is_vehicle)
&& (player || m.name_visible || Some(m.id) == target)
&& (player || m.custom_name.is_some());
let mut visible = name_shown;
@@ -622,11 +631,45 @@ impl Game<'_> {
}
}
/// `EntityAttachment.NAME_TAG`: `(0, bbHeight, 0)` of the box of the entity's
/// current pose, which the submitter lifts 0.5 more, between the last two tick
/// positions. The player box is `Pose::dimensions(1.0)`.
/// shortcut: the scale attribute is not in the tag height, add `model_scale`
/// when tags on scaled players matter.
/// Collect entity IDs of all vehicles: entities that have passengers
/// (mobs, players, text displays, armor stands, or interactions riding them).
///
/// On multiplayer servers (e.g. `mcpvp.club`), server plugins hang custom nametags
/// (ranks, prefixes, stats) over players using `TextDisplay` or `ArmorStand` passengers.
/// In vanilla `LivingEntityRenderer.shouldShowName`, a vehicle does not show its default
/// name tag (`!entity.isVehicle()`).
///
/// If the local player's remote entity ID is in the set, ID 0 (`player_model`) is also
/// added so that Horizon's own name tag extra (`ownNameTag`) recognizes that the player
/// is ridden and suppresses the duplicate tag (#9477).
fn collect_vehicle_ids(
mobs: &[Mob],
text_display_vehicles: impl IntoIterator<Item = u32>,
armor_stand_vehicles: impl IntoIterator<Item = u32>,
interaction_vehicles: impl IntoIterator<Item = u32>,
local_riding: Option<u32>,
local_entity_id: Option<u32>,
) -> std::collections::HashSet<u32> {
let mut ridden: std::collections::HashSet<u32> = mobs
.iter()
.filter_map(|o| o.extra.mount)
.chain(mobs.iter().filter_map(|m| m.extra.rider.map(|_| m.id)))
.chain(text_display_vehicles)
.chain(armor_stand_vehicles)
.chain(interaction_vehicles)
.collect();
if let Some(riding) = local_riding {
ridden.insert(riding);
}
if let Some(id) = local_entity_id
&& ridden.contains(&id)
{
// The local player's model has id 0 (player_model).
ridden.insert(0);
}
ridden
}
/// The entity ids the player-list pass names, which the custom-name pass must
/// therefore skip.
///
@@ -644,6 +687,11 @@ impl Game<'_> {
fn named_by_player_list(players: &[(&Mob, String)]) -> std::collections::HashSet<u32> {
players.iter().map(|(player, _)| player.id).collect()
}
/// `EntityAttachment.NAME_TAG`: `(0, bbHeight, 0)` of the box of the entity's
/// current pose, which the submitter lifts 0.5 more, between the last two tick
/// positions. The player box is `Pose::dimensions(1.0)`.
/// shortcut: the scale attribute is not in the tag height, add `model_scale`
/// when tags on scaled players matter.
pub(super) fn name_tag_at(m: &Mob, alpha: f64) -> glam::DVec3 {
glam::DVec3::new(
m.previous.x + (m.position.x - m.previous.x) * alpha,
@@ -785,4 +833,63 @@ mod tests {
m.extra.crouching = false;
assert!((at_y(&m) - 2.3).abs() < 1e-6, "1.8 + 0.5");
}
/// #9477: When server plugins attach a TextDisplay or ArmorStand to a player
/// (e.g. rank prefixes on multiplayer lobbies), vanilla's `LivingEntityRenderer.shouldShowName`
/// hides the default nametag because `entity.isVehicle()` is true.
///
/// For the local player with `ownNameTag: true`, `player_model` has ID 0 while
/// the server sends passengers for `local_entity_id`. `collect_vehicle_ids`
/// maps `local_entity_id -> 0` so that `ownNameTag` is not drawn twice over the
/// server's rider nametag.
#[test]
fn vehicle_with_text_display_or_armor_stand_passenger_is_marked_ridden() {
let mut remote_player = player_mob();
remote_player.id = 42;
let mobs = vec![remote_player];
// 1. TextDisplay riding remote player 42
let ridden = collect_vehicle_ids(
&mobs,
vec![42],
std::iter::empty(),
std::iter::empty(),
None,
Some(100),
);
assert!(
ridden.contains(&42),
"remote player 42 is ridden by text display"
);
assert!(!ridden.contains(&100), "local player 100 has no passenger");
assert!(!ridden.contains(&0), "local model 0 has no passenger");
// 2. ArmorStand riding local player 100 -> model 0 must be in ridden
let ridden_local = collect_vehicle_ids(
&mobs,
std::iter::empty(),
vec![100],
std::iter::empty(),
None,
Some(100),
);
assert!(
ridden_local.contains(&100),
"local player 100 is ridden by armor stand"
);
assert!(
ridden_local.contains(&0),
"local player model (id 0) must be mapped to vehicle so ownNameTag is suppressed (#9477)"
);
// 3. Name tag is suppressed for vehicles when team is None
let view_vehicle = sb::NameView {
is_vehicle: true,
..Default::default()
};
assert!(
!sb::name_shown(None, None, view_vehicle),
"vanilla shouldShowName hides tag for vehicles with no team"
);
}
}
@@ -0,0 +1,262 @@
//! Structure templates read for server mods (vanilla's
//! `StructureTemplateManager.get`, and what `StructureTemplate.placeInWorld`
//! would place) without placing them: a builder reads a template once, then
//! places its blocks one at a time (docs/native-mods.md, "Structure
//! templates").
use super::Game;
use horizon_client::structure_block::parse_identifier;
use horizon_core::position::BlockPos;
use horizon_core::random::{LegacyRandom, RandomSource, position_seed};
use horizon_world::nbt::Compound;
use horizon_world::registry::BlockStateId;
use horizon_world::worldgen::structure::{Mirror, Rotation, StateTransforms, StructureTemplate};
/// What `native_structure_template` read: the template's size after the
/// rotation (`StructureTemplate.getSize`), and the blocks it places, in the
/// order `StructureTemplate.placeInWorld` places them.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct NativeStructureTemplate {
pub size: [i32; 3],
pub blocks: Vec<NativeStructureBlock>,
}
/// One block a template places: its position in the level, its state after
/// the mirror and the rotation (as `native_set_block` takes it), and the
/// block entity data the template saved with it (a chest's items, a sign's
/// text), without its position.
#[derive(Clone, Debug, PartialEq)]
pub struct NativeStructureBlock {
pub pos: [i32; 3],
pub state: BlockStateId,
pub nbt: Option<Compound>,
}
// Native mods use this; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// For a server mod: structure template `id` (`mymod:hut`, found as
/// `StructureTemplateManager.get` finds it: the world's saved structures,
/// then the game's and the mods' own) as `/place template id` would place
/// it at `at` with `rotation` and `mirror`, without placing anything. The
/// blocks come in vanilla's placement order (full blocks, the others, then
/// those with a block entity, each by y, x, z), from the palette
/// `/place template` picks at `at`; no processor runs, and the template's
/// entities are left out. None for an id that doesn't parse or has a `.`
/// or `..` part, a template the game doesn't have, and on a client.
/// API @API:structure_template@.
pub(super) fn native_structure_template(
&mut self,
id: &str,
at: [i32; 3],
rotation: Rotation,
mirror: Mirror,
) -> Option<NativeStructureTemplate> {
if !self.native_authoritative() {
return None;
}
let id = template_id(id)?;
let entry = self.template_entry(&id)?;
let transforms = self.state_transforms()?;
let template =
StructureTemplate::from_nbt(&entry.tag, self.pack.assets.registry, transforms);
Some(placements(&template, transforms, at, rotation, mirror))
}
}
/// `Identifier.tryParse`, less the paths the world's `generated` folder
/// refuses (`FileUtil.isPathNormalized`): one with a `.` or `..` part.
fn template_id(id: &str) -> Option<String> {
let id = parse_identifier(id)?;
let (_, path) = id.split_once(':')?;
path.split('/')
.all(|part| part != "." && part != "..")
.then_some(id)
}
/// The blocks `StructureTemplate.placeInWorld` places for `template` at `at`
/// with the pivot at its origin and no processors, as `/place template` and
/// the structure block place it: the palette `getRandomPalette` draws from a
/// random seeded with `at`, each position transformed and moved to `at`, each
/// state mirrored, then rotated. A template with no palette or a side below 1
/// places nothing.
fn placements(
template: &StructureTemplate,
transforms: &StateTransforms,
at: [i32; 3],
rotation: Rotation,
mirror: Mirror,
) -> NativeStructureTemplate {
let size = template.size(rotation);
let palettes = template.palettes();
if palettes.is_empty() || template.size.iter().any(|side| *side < 1) {
return NativeStructureTemplate {
size,
blocks: Vec::new(),
};
}
let palette = LegacyRandom::new(position_seed(BlockPos::new(at[0], at[1], at[2])))
.next_int_bound(palettes.len() as i32)
.unwrap_or(0) as usize;
let blocks = palettes[palette]
.blocks()
.iter()
.map(|block| {
let relative = StructureTemplate::transform(block.pos, mirror, rotation, [0; 3]);
NativeStructureBlock {
pos: std::array::from_fn(|i| relative[i] + at[i]),
state: transforms.rotate(transforms.mirror(block.state, mirror), rotation),
nbt: block.nbt.clone(),
}
})
.collect();
NativeStructureTemplate { size, blocks }
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::version::DATA_VERSION;
use horizon_world::nbt::Tag;
use horizon_world::registry::BlockRegistry;
// Air, stone, then a block facing north, east, south and west.
const NAMES: [&str; 6] = [
"minecraft:air",
"minecraft:stone",
"test:north",
"test:east",
"test:south",
"test:west",
];
fn registry() -> BlockRegistry {
let state = |name: &str| Tag::compound([("id", Tag::String(name.into()))]);
BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(NAMES.iter().map(|n| state(n)).collect()),
),
(
"defaults",
Tag::compound(NAMES.iter().zip(0..).map(|(n, i)| (*n, Tag::Int(i)))),
),
]))
.unwrap()
}
/// Vanilla's turns of a facing: clockwise 90, 180 and counterclockwise 90
/// (north, east, south, west), and its mirrors: left-right swaps north
/// and south, front-back east and west.
fn transforms() -> StateTransforms {
StateTransforms::from_json(
r#"{"rotate":[[0,0,0],[1,1,1],[3,4,5],[4,5,2],[5,2,3],[2,3,4]],
"mirror":[[0,0],[1,1],[4,2],[3,5],[2,4],[5,3]],
"full_block":[false,true,false,false,false,false],
"loot_containers":[],"block_entities":{}}"#,
NAMES.len(),
)
.unwrap()
}
fn named(name: &str) -> Tag {
Tag::compound([("Name", Tag::String(name.into()))])
}
fn block(pos: [i32; 3], state: i32, nbt: Option<Compound>) -> Tag {
let pos = Tag::List(pos.iter().map(|v| Tag::Int(*v)).collect());
let mut fields = vec![("pos", pos), ("state", Tag::Int(state))];
if let Some(nbt) = nbt {
fields.push(("nbt", Tag::Compound(nbt)));
}
Tag::compound(fields)
}
fn template(size: [i32; 3], palettes: Vec<Vec<Tag>>, blocks: Vec<Tag>) -> StructureTemplate {
let size = Tag::List(size.iter().map(|v| Tag::Int(*v)).collect());
let palettes = Tag::List(palettes.into_iter().map(Tag::List).collect());
let tag = Tag::compound([
("size", size),
("palettes", palettes),
("blocks", Tag::List(blocks)),
]);
StructureTemplate::from_nbt(&tag, &registry(), &transforms())
}
#[test]
fn a_template_reads_as_place_template_turns_mirrors_and_orders_it() {
let mut chest = Compound::new();
chest.insert("id".into(), Tag::String("minecraft:chest".into()));
let template = template(
[2, 1, 3],
vec![vec![
named("minecraft:stone"),
named("test:north"),
named("test:east"),
]],
vec![
block([0, 0, 1], 2, Some(chest.clone())),
block([1, 0, 2], 1, None),
block([0, 0, 0], 0, None),
],
);
let read = placements(
&template,
&transforms(),
[10, 64, 20],
Rotation::Clockwise90,
Mirror::FrontBack,
);
// A quarter turn swaps the sides along x and z.
assert_eq!(read.size, [3, 1, 2]);
// StructureTemplate.transform: front-back negates x, then a clockwise
// quarter turn about the origin takes (x, y, z) to (-z, y, x). Each
// state is mirrored, then turned: north stays north, then faces east;
// east faces west, then north. The full block comes first, the block
// entity last.
let at = |pos: [i32; 3], name: &str, nbt: Option<Compound>| NativeStructureBlock {
pos,
state: BlockStateId(NAMES.iter().position(|n| *n == name).unwrap() as u32),
nbt,
};
assert_eq!(
read.blocks,
[
at([10, 64, 20], "minecraft:stone", None),
at([8, 64, 19], "test:east", None),
at([9, 64, 20], "test:north", Some(chest)),
]
);
}
#[test]
fn a_template_with_palettes_gives_the_one_place_template_picks_there() {
let template = template(
[1, 1, 1],
vec![vec![named("minecraft:stone")], vec![named("test:west")]],
vec![block([0, 0, 0], 0, None)],
);
// Mth.getSeed(0, 0, 0) is 0, and `new Random(0).nextInt(2)` is 1
// (its first `nextInt()` is -1155484576): the second palette.
let read = placements(
&template,
&transforms(),
[0, 0, 0],
Rotation::None,
Mirror::None,
);
assert_eq!(read.blocks.len(), 1);
assert_eq!(read.blocks[0].state, BlockStateId(5));
}
#[test]
fn template_ids_parse_as_identifiers_without_dot_parts() {
assert_eq!(template_id("hut").as_deref(), Some("minecraft:hut"));
assert_eq!(
template_id("mymod:houses/hut").as_deref(),
Some("mymod:houses/hut")
);
assert_eq!(template_id("mymod:../saves/hut"), None);
assert_eq!(template_id("mymod:houses/./hut"), None);
}
}
+1 -1
View File
@@ -6179,7 +6179,7 @@ impl Game<'_> {
// reaches its own write when this window is a server client.
self.rope_rot_old = (self.yaw, self.pitch);
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
let riding = self.screen != Screen::Dead && self.remote_ride(keys, sprint_window)?;
// The server sends no passenger's position: they sit where their
// vehicles are now.
self.position_riders();
@@ -123,6 +123,7 @@ impl Game<'_> {
yaw: super::cushions::snap_yaw(yaw),
color: 0,
check_interval: 0,
custom_name: None,
});
} else if kind == "minecraft:lightning_bolt" {
self.spawn_remote_bolt(id, position);
@@ -12,6 +12,7 @@
//! and tells the server of a riding jump (`START_RIDING_JUMP`). A minecart
//! moves on the server with the rider's input, and the rider sits in it.
use super::{Game, Keys, ray_box};
use crate::Result;
use horizon_client::mobs::{Mob, equine, happy_ghast};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::packets::play::{self, Serverbound as S};
@@ -243,10 +244,18 @@ impl Game<'_> {
/// The local player's tick in its vehicle, in place of its own movement:
/// false when it rides nothing.
pub(super) fn remote_ride(&mut self, keys: Keys) -> bool {
pub(super) fn remote_ride(&mut self, keys: Keys, sprint_window: i32) -> Result<bool> {
let Some(id) = self.riding else {
return false;
return Ok(false);
};
// LocalPlayer.aiStep runs its sprint decisions in a vehicle too: `canSprint` and
// this client's authority stand in for food (`isSprintingPossible`).
let (can_sprint, local) = self
.driven_mount()
.map_or((false, false), |m| (equine::can_sprint(m.species()), true));
if let Some(p) = self.player.as_mut() {
p.sprint_input_tick_riding(keys, sprint_window, can_sprint, local)?;
}
if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
// LocalPlayer.aiStep: the jump bar charges while jump is held.
let jump = self.charge_mount_jump(i, keys);
@@ -256,7 +265,7 @@ impl Game<'_> {
// Another animal moves on the server with the rider's input
// (PlayerInput); the rider sits where `positionRider` puts it.
self.position_rider();
return true;
return Ok(true);
}
let seat = if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
// The seat does not move; the rider sits on the cushion.
@@ -297,7 +306,7 @@ impl Game<'_> {
// riding it. Nothing with the id is in the level any more (it
// died, or the world it rode in was left), so the seat goes.
self.riding = None;
return false;
return Ok(false);
}
cart
};
@@ -317,7 +326,7 @@ impl Game<'_> {
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
true
Ok(true)
}
/// `LocalPlayer.tick` for a passenger: the boat it controls goes to the
@@ -451,6 +460,10 @@ impl Game<'_> {
}
// playerJumpPendingScale waits for the ground.
let pending = m.rider.map_or(0.0, |r| r.jump);
// The rider's sprint state, not the key: Camel.getRiddenSpeed reads
// `controller.isSprinting()`, which keeps the double-tapped sprint
// after the key is let go (`shouldStopRunSprinting` decides the end).
let sprinting = self.player.as_ref().is_some_and(|p| p.sprinting);
m.rider = Some(equine::Rider {
strafe,
forward,
@@ -458,7 +471,7 @@ impl Game<'_> {
pitch: self.pitch,
jump: if jump > 0.0 { jump } else { pending },
steer: false,
sprint: keys.sprint,
sprint: sprinting,
jumping: keys.jump,
client: false,
});
+1
View File
@@ -74,6 +74,7 @@ impl Game<'_> {
}
/// `new SocialInteractionsScreen(lastScreen)`: it opens on All, with an empty search.
pub(super) fn open_social(&mut self, from_pause: bool) {
self.hide_social_tutorial();
self.social.page = Page::All;
self.social.search.set_value("");
self.social.scroll = 0.0;
+20 -2
View File
@@ -399,6 +399,8 @@ impl<'a> Split<'a> {
}
};
self.host.renderer = Some(renderer);
// The new atlas has no downloaded skins: queue them again, as after device loss.
self.host.skin_slots.forget();
self.host.split_window = !self.host.options.offscreen;
self.host.options.offscreen = true;
self.host.options.paced_by_split = true;
@@ -474,6 +476,7 @@ impl<'a> Split<'a> {
};
drop(old);
self.host.renderer = Some(renderer);
self.host.skin_slots.forget();
self.host.options.offscreen = false;
self.host.split_window = false;
self.host.options.paced_by_split = false;
@@ -545,11 +548,16 @@ impl<'a> Split<'a> {
/// When the windows had presented (before the GPU wait), for the frame-time readout.
fn present(&mut self) -> Instant {
let views = self.views();
let vsync = self.host.settings.vsync;
let paced = windows::paced_surface(self.window1_shown, self.surfaces.len());
// A present mode switch reconfigures a surface, and an Outdated or Lost texture
// may: a reconfigure waits for the GPU to come idle and reports an error when a
// frame is submitted while it waits. Land this turn's frames on the submit
// threads first (the compositor then copies views already submitted, too).
self.wait_queues();
let renderers: Vec<_> = std::iter::once(&self.host.renderer)
.chain(self.guests.iter().map(|g| &g.game.renderer))
.collect();
let vsync = self.host.settings.vsync;
let paced = windows::paced_surface(self.window1_shown, self.surfaces.len());
for (k, surface) in self.surfaces.iter_mut().enumerate() {
let frames: Vec<_> = renderers
.iter()
@@ -570,6 +578,16 @@ impl<'a> Split<'a> {
}
presented
}
/// Every player's frame submitted by its submit thread, no device wait: before
/// anything reconfigures a surface, whose wait for the GPU breaks on a submission
/// made while it waits.
fn wait_queues(&mut self) {
let renderers = std::iter::once(&mut self.host.renderer)
.chain(self.guests.iter_mut().map(|g| &mut g.game.renderer));
for renderer in renderers.flatten() {
renderer.wait_submissions();
}
}
}
impl ApplicationHandler for Split<'_> {
@@ -122,7 +122,14 @@ impl Split<'_> {
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
let game = self.start_guest(seat, &options, self.host.settings.clone());
let mut settings = self.host.settings.clone();
if pad.is_some() {
// The Controller setting is P1's own: a player who pressed a pad to join plays
// with it even when P1 turned controllers off, or its pad would be dead on the
// profile screen it joins into.
settings.controller.enabled = true;
}
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
let mut game = game?;
if let Some(pad) = pad {
@@ -570,6 +570,9 @@ impl<'a> Split<'a> {
.map_err(Into::into)
.and_then(|w| {
let w = Arc::new(w);
// The new surface configures itself on the players' device, and that
// wait breaks on a frame submitted meanwhile: drain every queue first.
self.wait_queues();
let renderer = self.host.renderer.as_ref().ok_or("no renderer")?;
Ok::<_, Box<dyn std::error::Error>>((w.clone(), renderer.compositor(w)?))
});
+11
View File
@@ -49,6 +49,17 @@ impl Game<'_> {
if !container || self.chest_menu_blocked(key, &name) {
return;
}
// BaseContainerBlockEntity.canOpen: a spectator opens no container that still
// has a loot table to unpack (it clears while it rolls, on first open).
if self
.world
.chunks
.get(&(key.0 >> 4, key.2 >> 4))
.and_then(|c| c.saved.as_ref())
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
{
return;
}
let Some(entity) = BlockEntity::for_block(&name) else {
return;
};
+15
View File
@@ -11,6 +11,21 @@ use horizon_client::{
};
impl Game<'_> {
/// `ServerPlayer.sendOverlayMessage`: the steady player sees it on this
/// game's HUD; a server client receives it as a packet.
pub(super) fn send_overlay_message(&mut self, message: Text) {
if self.lan_acting.is_some() {
self.guest_packets
.push(horizon_protocol::packets::play::Clientbound::SystemChat(
horizon_protocol::packets::play::SystemChat {
content: horizon_client::lan::data::component_nbt(&message.component()),
overlay: true,
},
));
} else if self.options.dedicated.is_none() {
self.titles.set_overlay(message, false);
}
}
/// `Hud.extractOverlayMessage`: the action bar.
pub(super) fn draw_overlay_message(&self, batch: &mut HudBatch, bar_height: i32) {
let (w, h) = (batch.width as i32, batch.height as i32);
+15 -1
View File
@@ -166,7 +166,9 @@ impl Game<'_> {
pub(super) fn tnt_prime_allowed(&mut self, pos: BlockPos) -> bool {
let mut cancel = false;
self.native_tnt_primed(self.lan_acting_uuid, [pos.x, pos.y, pos.z], &mut cancel);
!cancel
// `TntBlock.prime` leaves the block alone with `tnt_explodes` off, for
// redstone, fire and an unstable block breaking alike.
!cancel && self.ui.rules.bool("tnt_explodes")
}
/// Primes TNT a level event announced, or, when the mods refuse, puts
/// back the block the fire or signal had already taken out.
@@ -283,6 +285,10 @@ impl Game<'_> {
let (owner, guest, power) = (t.owner, t.guest, t.power);
let in_water = self.bounds_in_water(bounds);
self.tnts.remove(i);
// PrimedTnt.tick: with `tnt_explodes` off it goes away without exploding.
if !self.ui.rules.bool("tnt_explodes") {
return Ok(());
}
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
let interaction = self.destroy_type("tnt_explosion_drop_decay");
self.combat.explosion_in_water = in_water;
@@ -321,6 +327,13 @@ impl Game<'_> {
return Ok(false);
}
let pos = BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
if !self.ui.rules.bool("tnt_explodes") {
// TntBlock.useItemOn: the rule is off, the acting player's overlay
// says so, and PASS lets the item's own useOn light a fire there.
let message = Text::translate("block.minecraft.tnt.disabled", &[]);
self.send_overlay_message(message);
return Ok(false);
}
if !self.tnt_prime_allowed(pos) {
return Ok(true);
}
@@ -337,6 +350,7 @@ impl Game<'_> {
} else {
self.consume_held();
}
self.award_stat("used", item, 1);
Ok(true)
}
/// `TntRenderer`: the block centred on the entity, swelling over the last
@@ -371,6 +371,10 @@ impl Game<'_> {
if stack.damage >= self.trident_max_damage(&stack).saturating_sub(1) {
return;
}
// Counted (on the server) before the throw or the spin.
if self.remote.is_none() {
self.award_stat("used", "minecraft:trident", 1);
}
if riptide > 0 {
// `hurtWithoutBreaking` and the sound belong to the server; a server's
// client only launches.
+71 -2
View File
@@ -32,6 +32,7 @@ pub(super) enum Hint {
PunchTree,
OpenInventory,
CraftPlanks,
SocialInteractions,
}
/// The observations needed on the first tick of each step.
@@ -255,6 +256,19 @@ impl super::Game<'_> {
{
return;
}
// Minecraft.tick: also show this in a single-player world once it is
// published to LAN, but not merely because its client uses memory networking.
if !self.settings.file.bool("joinedFirstServer")
&& (!self.has_singleplayer_server() || self.is_published())
{
let toast = self.tutorial_toast(Hint::SocialInteractions, Some(0.0));
self.tutorial_toasts.push(toast);
self.settings.file.set(
"joinedFirstServer",
horizon_client::options_file::V::Bool(true),
);
self.settings.save(&Self::options_path());
}
if self.screen == super::Screen::Playing
&& ([
KeyCode::KeyW,
@@ -341,6 +355,14 @@ pub(super) struct HintToast {
}
impl super::Game<'_> {
pub(super) fn hide_social_tutorial(&mut self) {
for toast in &mut self.tutorial_toasts {
if toast.hint == Hint::SocialInteractions {
toast.wanted = false;
}
}
}
pub(super) fn tutorial_toast_slots(&self) -> u32 {
self.tutorial_toasts
.iter()
@@ -392,6 +414,13 @@ impl super::Game<'_> {
"Press %1$s",
vec![],
),
Hint::SocialInteractions => (
"tutorial.socialInteractions.title",
"Social Interactions",
"tutorial.socialInteractions.description",
"Press %1$s to open social interactions",
vec![],
),
Hint::CraftPlanks => (
"tutorial.craft_planks.title",
"Craft wooden planks",
@@ -412,6 +441,7 @@ impl super::Game<'_> {
Hint::Move => vec![key("key.jump")],
Hint::PunchTree => vec![key("key.attack")],
Hint::OpenInventory => vec![key("key.inventory")],
Hint::SocialInteractions => vec![key("key.socialInteractions")],
_ => vec![],
};
let description = substitute(self.translate(description_key, description), &desc_args);
@@ -442,6 +472,9 @@ impl super::Game<'_> {
) {
let wanted = self.tutorial.hint();
for t in &mut self.tutorial_toasts {
if t.hint == Hint::SocialInteractions {
continue;
}
t.wanted = wanted.is_some_and(|(hint, _)| hint == t.hint);
if t.wanted {
t.progress = wanted.and_then(|(_, progress)| progress);
@@ -480,7 +513,9 @@ impl super::Game<'_> {
}
continue;
}
let (sound, finished) = t.slide.update_now(|_| t.wanted);
let (sound, finished) = t.slide.update_now(|fully| {
t.wanted && (t.hint != Hint::SocialInteractions || social_hint_progress(fully).0)
});
if let Some(show) = sound {
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
self.audio
@@ -490,7 +525,11 @@ impl super::Game<'_> {
self.tutorial_toasts.remove(i);
continue;
}
if let Some(progress) = t.progress {
if t.hint == Hint::SocialInteractions {
let (_, progress) = social_hint_progress(t.slide.fully_for);
t.progress = Some(progress);
t.smooth = progress;
} else if let Some(progress) = t.progress {
let delta = ((t.slide.fully_for - t.last_smooth) as f32 / 100.0).clamp(0.0, 1.0);
t.smooth += (progress - t.smooth) * delta;
t.last_smooth = t.slide.fully_for;
@@ -515,6 +554,7 @@ impl super::Game<'_> {
Hint::FindTree | Hint::PunchTree => "tree",
Hint::OpenInventory => "recipe_book",
Hint::CraftPlanks => "wooden_planks",
Hint::SocialInteractions => "social_interactions",
};
batch.sprite_scaled(
atlas,
@@ -562,10 +602,39 @@ impl super::Game<'_> {
}
}
/// TutorialToast.update uses the time fully visible (not the slide-in time),
/// and hides only after 8000 ms. Its timed bar does not use progress smoothing.
fn social_hint_progress(fully_visible_ms: i64) -> (bool, f32) {
(
fully_visible_ms <= 8000,
(fully_visible_ms as f32 / 8000.0).min(1.0),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn social_hint_fills_for_eight_seconds_before_hiding() {
assert_eq!(social_hint_progress(0), (true, 0.0));
assert_eq!(social_hint_progress(4000), (true, 0.5));
assert_eq!(social_hint_progress(8000), (true, 1.0));
assert_eq!(social_hint_progress(8001), (false, 1.0));
}
#[test]
fn social_hint_counts_from_full_visibility_and_finishes_sliding_out() {
let mut slide = horizon_client::toasts::Slide::default();
for (now, visible) in [(0, true), (600, true), (8600, true), (8601, false)] {
let (_, finished) = slide.update(now, |fully| social_hint_progress(fully).0);
assert!(!finished);
assert_eq!(social_hint_progress(slide.fully_for).0, visible);
}
let (_, finished) = slide.update(9202, |fully| social_hint_progress(fully).0);
assert!(finished);
}
#[test]
fn craft_planks_hint_toast_appears_after_delay_and_clears_on_planks() {
let mut tutorial = Tutorial::new(Step::CraftPlanks);
+1 -1
View File
@@ -578,7 +578,7 @@ mod hub_tests {
fn hub() -> Hub {
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "ArrivalsTest".into(), 4)
..NetConfig::loopback(0, "ArrivalsTest".into(), 4)
};
let mut sync = data::SyncData::empty();
// The entity types the tracker names: the player, as id 0.
+2 -2
View File
@@ -1686,7 +1686,7 @@ mod tests {
announce: false,
listen: false,
accepts_transfers: false,
..crate::lan::NetConfig::lan(0, "Transfers".into(), 8)
..crate::lan::NetConfig::loopback(0, "Transfers".into(), 8)
};
let lan =
crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config)
@@ -1771,7 +1771,7 @@ mod tests {
let _ = never.recv();
Vec::new()
}))),
..crate::lan::NetConfig::lan(0, "Owner - World".into(), 8)
..crate::lan::NetConfig::loopback(0, "Owner - World".into(), 8)
};
let lan =
crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config)
+5 -1
View File
@@ -136,7 +136,11 @@ impl Scanner {
pub fn start() -> Self {
let games: Games = Arc::default();
let stop = Arc::new(AtomicBool::new(false));
if let Ok(socket) = bind_listener() {
// Constructing multiplayer screens in unit tests must not open the LAN.
if !cfg!(test)
&& !super::test_loopback()
&& let Ok(socket) = bind_listener()
{
let (games, stop) = (games.clone(), stop.clone());
let _ = std::thread::Builder::new()
.name("horizon-lan-scan".into())
+3 -3
View File
@@ -1238,7 +1238,7 @@ impl Hub {
let joined = C::SystemChat(play::SystemChat {
content: yellow_names(
"multiplayer.player.joined",
vec![display_name(self.guests[i].uuid, &name)],
vec![self.player_display_name(self.guests[i].uuid, &name)],
),
overlay: false,
})
@@ -2028,7 +2028,7 @@ impl Hub {
let left = C::SystemChat(play::SystemChat {
content: yellow_names(
"multiplayer.player.left",
vec![display_name(g.uuid, &g.name)],
vec![self.player_display_name(g.uuid, &g.name)],
),
overlay: false,
})
@@ -4578,7 +4578,7 @@ mod tests {
fn hub_with_sync(sync: SyncData) -> (Hub, std::sync::mpsc::Receiver<crate::lan::Out>) {
let config = crate::lan::NetConfig {
announce: false,
..crate::lan::NetConfig::lan(0, "EffectSyncTest".into(), 2)
..crate::lan::NetConfig::loopback(0, "EffectSyncTest".into(), 2)
};
let sync = std::sync::Arc::new(sync);
let lan = Lan::start_with(sync.clone(), config).unwrap();
+50 -7
View File
@@ -218,6 +218,15 @@ pub struct NetConfig {
pub levels: crate::chunk::LevelShapes,
}
impl NetConfig {
/// A real TCP server confined to this machine, without multicast announcements.
/// Use this for socket tests; `lan` retains the public LAN behaviour.
pub fn loopback(port: u16, motd: String, max_players: usize) -> Self {
Self {
bind: Some(std::net::Ipv4Addr::LOCALHOST.into()),
announce: false,
..Self::lan(port, motd, max_players)
}
}
/// MinecraftServer.enforceSecureProfile.
pub fn enforces_secure_chat(&self) -> bool {
self.secure_chat.as_ref().is_some_and(|s| s.enforced())
@@ -635,7 +644,13 @@ impl Lan {
/// The socket [`Lan::listen`] accepts on, bound ahead so that a failure
/// can be reported before the hub is at hand.
pub fn bind(address: Option<std::net::IpAddr>, port: u16) -> io::Result<TcpListener> {
let bind = address.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
let bind = address.unwrap_or_else(|| {
if test_loopback() {
std::net::Ipv4Addr::LOCALHOST.into()
} else {
std::net::Ipv4Addr::UNSPECIFIED.into()
}
});
let listener = TcpListener::bind((bind, port))?;
listener.set_nonblocking(true)?;
Ok(listener)
@@ -654,7 +669,7 @@ impl Lan {
let thread = std::thread::Builder::new()
.name("horizon-lan-accept".into())
.spawn(move || accept_loop(listener, accept_shared, &accept_stop))?;
self._pinger = if announce {
self._pinger = if announce && !test_loopback() {
discovery::Pinger::start(self.shared.motd(), port).ok()
} else {
None
@@ -931,13 +946,41 @@ fn handle(stream: TcpStream, addr: SocketAddr, counted: Counted) {
/// HttpUtil.getAvailablePort: a free local port.
pub fn available_port() -> u16 {
TcpListener::bind(("0.0.0.0", 0))
let bind = if cfg!(test) || test_loopback() {
"127.0.0.1"
} else {
"0.0.0.0"
};
TcpListener::bind((bind, 0))
.and_then(|l| l.local_addr())
.map_or(25565, |a| a.port())
}
/// Same-machine harnesses opt in; real LAN runs keep the normal network interfaces.
pub(super) fn test_loopback() -> bool {
std::env::var_os("HORIZON_TEST_LOOPBACK").is_some_and(|v| v == "1")
}
#[cfg(test)]
mod tests {
#[test]
fn a_local_server_accepts_only_the_requested_loopback_address() {
let lan = super::Lan::start_with(
std::sync::Arc::new(super::data::SyncData::empty()),
super::NetConfig::loopback(0, "Local test".into(), 1),
)
.unwrap();
let local = std::net::TcpStream::connect(("127.0.0.1", lan.port)).unwrap();
let other = "127.0.0.2".parse::<std::net::IpAddr>().unwrap();
assert!(
std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::new(other, lan.port),
std::time::Duration::from_millis(500),
)
.is_err()
);
drop(local);
}
use super::conn::MEMORY_ADDRESS;
use super::*;
use std::{io::Read, time::Instant};
@@ -949,7 +992,7 @@ mod tests {
fn a_flood_of_silent_sockets_is_turned_away() {
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "Flood".into(), 1)
..NetConfig::loopback(0, "Flood".into(), 1)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let address = (std::net::Ipv4Addr::LOCALHOST, lan.port);
@@ -994,7 +1037,7 @@ mod tests {
let config = NetConfig {
announce: false,
listen: false,
..NetConfig::lan(0, "Status".into(), 8)
..NetConfig::loopback(0, "Status".into(), 8)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
assert!(lan.shared.try_admit(MEMORY_ADDRESS, 1, "Player").is_none());
@@ -1012,7 +1055,7 @@ mod tests {
let config = NetConfig {
announce: false,
listen: false,
..NetConfig::lan(0, "Share".into(), 20)
..NetConfig::loopback(0, "Share".into(), 20)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
@@ -1046,7 +1089,7 @@ mod tests {
lang: Arc::new(Default::default()),
log_ips: true,
}),
..NetConfig::lan(0, "Room".into(), 1)
..NetConfig::loopback(0, "Room".into(), 1)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let shared = &lan.shared;
+21 -2
View File
@@ -1410,6 +1410,9 @@ pub struct Mob {
pub last_hurt: f32,
/// `LivingEntity.getAbsorptionAmount`: golden hearts that soak damage before health.
pub absorption: f32,
/// Tenths of absorbed damage a player's hit dealt, which the attack awards as
/// `Stats.DAMAGE_DEALT_ABSORBED` to the player whose turn it is (see [`Mob::soak_hit`]).
pub dealt_absorbed: i32,
pub last_damage: Option<(Damage, i64)>,
/// `DamageSource.getDirectEntity` of `last_damage` when [`Damage`] cannot name it (a
/// thrown potion, an area effect cloud): its entity type.
@@ -3053,6 +3056,7 @@ impl Mob {
air: 300,
last_hurt: 0.0,
absorption: 0.0,
dealt_absorbed: 0,
last_damage: None,
last_direct: None,
combat: combat::Tracker::default(),
@@ -3408,7 +3412,7 @@ impl Mob {
if absorbed {
self.hurt_wolf_armor(dealt);
} else {
let dealt = self.soak_absorption(dealt);
let dealt = self.soak_hit(dealt, &source);
self.health -= dealt;
}
self.last_hurt = amount;
@@ -3420,7 +3424,7 @@ impl Mob {
if absorbed {
self.hurt_wolf_armor(amount);
} else {
let through = self.soak_absorption(amount);
let through = self.soak_hit(amount, &source);
self.health -= through;
}
self.hurt_time = 10;
@@ -3784,6 +3788,21 @@ impl Mob {
|| present_ambient != self.extra.ambient_effects.contains(&effect)
|| was_visible != visible)
}
/// [`Mob::soak_absorption`] for a hit from `source`: `LivingEntity.actuallyHurt`
/// credits a `ServerPlayer` attacker with the absorbed damage in tenths.
fn soak_hit(&mut self, damage: f32, source: &Damage) -> f32 {
let through = self.soak_absorption(damage);
let absorbed = damage - through;
if absorbed > 0.0
&& absorbed < 3.402_823_5e37
&& matches!(source.attacker, Some(Who::Player))
{
self.dealt_absorbed = self
.dealt_absorbed
.saturating_add((absorbed * 10.0).round() as i32);
}
through
}
/// `LivingEntity.actuallyHurt`: the absorption amount takes the damage first;
/// what is left reaches the health.
pub fn soak_absorption(&mut self, damage: f32) -> f32 {
+5
View File
@@ -80,6 +80,11 @@ pub fn is_equine(s: Species) -> bool {
| Species::TraderLlama
)
}
/// `Entity.canSprint`: false everywhere but the camel (`Camel.canSprint`). A
/// rider's `LocalPlayer.isSprintingPossible` asks its vehicle.
pub fn can_sprint(s: Species) -> bool {
matches!(s, Species::Camel | Species::CamelHusk)
}
/// `AbstractHorse.getMaxTemper`: 100, 30 for llamas.
pub fn max_temper(s: Species) -> i32 {
if super::llama::is_llama(s) {
+19
View File
@@ -788,6 +788,25 @@ mod crack_sound_tests {
k.max_health = 100.0;
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
/// `LivingEntity.actuallyHurt`: absorbed damage from a player is credited in
/// tenths; other attackers and a hit with nothing absorbed credit nothing.
#[test]
fn player_hit_credits_dealt_absorbed() {
let mut m = golem();
m.absorption = 4.0;
assert!(m.damage(1.5, Damage::player([0.0, 0.0, 1.0])));
assert_eq!(m.dealt_absorbed, 15);
m.invulnerable = 0;
assert!(m.damage(10.0, Damage::player([0.0, 0.0, 1.0])));
assert_eq!(m.dealt_absorbed, 15 + 25);
m.invulnerable = 0;
m.damage(2.0, Damage::player([0.0, 0.0, 1.0]));
assert_eq!(m.dealt_absorbed, 40);
let mut other = golem();
other.absorption = 4.0;
other.damage(1.5, Damage::of(crate::mobs::DamageKind::Generic));
assert_eq!(other.dealt_absorbed, 0);
}
/// One hit of `amount` past the golem's hurt frames: how often it played
/// `entity.iron_golem.damage`.
fn cracks(m: &mut Mob, amount: f32) -> usize {
+1 -1
View File
@@ -155,7 +155,7 @@ fn baby_turtle_on_land(s: &super::Seen) -> bool {
/// `universal_anger` rule on and no anger target left, any player. Creative and
/// spectator players are dropped first (`isValidPlayerTarget`), by the goal's
/// `can_target_player`.
fn angry_at_player(m: &Mob, w: &MobWorld) -> bool {
pub(super) fn angry_at_player(m: &Mob, w: &MobWorld) -> bool {
m.extra.angry_at == Some(Who::Player)
|| (w.universal_anger && m.extra.anger > 0 && m.extra.angry_at.is_none())
}
+40 -1
View File
@@ -80,7 +80,7 @@ pub fn register(m: &mut Mob) {
2,
NearestTarget::player(true)
.interval(10)
.when(|m, _| m.extra.anger > 0),
.when(super::tame::angry_at_player),
);
}
S::Drowned => {
@@ -479,6 +479,12 @@ fn zombified_piglin_step(m: &mut Mob, cx: &mut Cx) {
} else if m.ai.target.is_none() {
m.extra.flag2 = false;
}
// The anger's target is whoever the piglin holds as its target, so a
// piglin hit by another mob is angry at that mob and not at the player
// (`NearestAttackableTargetGoal` tests `isAngryAt`).
if m.ai.target.is_some() {
m.extra.angry_at = m.ai.target;
}
if m.extra.anger > 0 {
m.extra.anger -= 1;
if !m.is_baby() {
@@ -500,6 +506,7 @@ fn zombified_piglin_step(m: &mut Mob, cx: &mut Cx) {
}
if m.extra.anger == 0 {
m.ai.target = None;
m.extra.angry_at = None;
m.extra.flag2 = false;
}
} else {
@@ -1572,4 +1579,36 @@ mod wither_arrow_tests {
});
let _ = ran;
}
/// #9808: a zombified piglin angry at another mob (a skeleton's arrow
/// hit it) is not angry at the player (`NeutralMob.isAngryAt`).
#[test]
fn a_piglin_angry_at_a_mob_does_not_target_the_player() {
with_test_world(&[], |w, types| {
let mut w = w.clone();
w.player = Some(test_player(Vec3::new(3.5, 64.0, 0.5)));
let kind = Arc::new(types.types["minecraft:zombified_piglin"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.anger = 300;
m.extra.angry_at = Some(Who::Mob(99));
let mut lr = horizon_core::random::LegacyRandom::new(0);
for _ in 0..40 {
let mut events = Vec::new();
m.tick(&w, &mut lr, &mut events);
assert_ne!(m.ai.target, Some(Who::Player));
}
// Fresh piglin, angry at the player: it does take the player.
let kind = Arc::new(types.types["minecraft:zombified_piglin"].clone());
let mut m = Mob::new(2, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.anger = 300;
m.extra.angry_at = Some(Who::Player);
let mut hit = false;
for _ in 0..60 {
let mut events = Vec::new();
m.tick(&w, &mut lr, &mut events);
hit |= m.ai.target == Some(Who::Player);
}
assert!(hit, "angry at the player targets the player");
});
}
}
+2 -1
View File
@@ -26,7 +26,7 @@ pub use crate::game::{
NativeCriterion, NativeDataMapType, NativeExperienceOrb, NativeFalling, NativeHeightmap,
NativeHeldItem, NativeHit, NativeItemEntity, NativeMapDecoration, NativeMapInfo,
NativeMobRender, NativeModEvent, NativePlayer, NativePlayerRender, NativeReloadListener,
NativeShake, NativeWorldDraw,
NativeShake, NativeStructureBlock, NativeStructureTemplate, NativeWorldDraw,
};
pub use crate::gfx::native_render::{
BlockSpriteArrays, RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent,
@@ -65,6 +65,7 @@ pub use horizon_world::{
loot::{LootStack, NativeLootContext},
nbt::{Compound, Tag},
registry::BlockStateId,
worldgen::structure::{Mirror, Rotation},
};
pub use serde_json;
use serde_json::{Map, Value};
+101 -3
View File
@@ -932,8 +932,29 @@ impl PlayerMotion {
&mut self,
keys: crate::sprint::Keys,
sprint_window: i32,
) -> Result<()> {
let conditions = self.sprint_conditions();
self.run_sprint_tick(keys, conditions, sprint_window)
}
/// The same phase for a passenger: `LocalPlayer.isSprintingPossible` asks the
/// vehicle's `canSprint` and this client's authority there, not food.
pub fn sprint_input_tick_riding(
&mut self,
keys: crate::sprint::Keys,
sprint_window: i32,
vehicle_can_sprint: bool,
vehicle_local: bool,
) -> Result<()> {
let conditions = crate::sprint::Conditions {
passenger: true,
vehicle_can_sprint,
vehicle_local,
..self.sprint_conditions()
};
self.run_sprint_tick(keys, conditions, sprint_window)
}
fn sprint_conditions(&self) -> crate::sprint::Conditions {
crate::sprint::Conditions {
sprinting: self.sprinting,
mobility_restricted: self.mobility_restricted,
enough_food: self.health.food.has_enough_food(),
@@ -951,7 +972,14 @@ impl PlayerMotion {
under_water: self.is_under_water(),
swimming: self.swimming,
..Default::default()
};
}
}
fn run_sprint_tick(
&mut self,
keys: crate::sprint::Keys,
conditions: crate::sprint::Conditions,
sprint_window: i32,
) -> Result<()> {
// The sprint setter edits attributes and flags, independent of local
// keyboard history. Retain the input prefix even if that setter fails.
let mut state = self.sprint_input;
@@ -2856,7 +2884,7 @@ impl LivingAirWorld for MotionWorld<'_, '_, '_> {
Ok(self.player.status.levitation)
}
fn is_client_side(&mut self) -> Result<bool> {
Ok(false)
Ok(self.player.client_side)
}
fn has_chunk_at(&mut self, p: BlockPos) -> Result<bool> {
Ok(self.scene.chunks.contains_key(&(p.x >> 4, p.z >> 4)))
@@ -2868,7 +2896,7 @@ impl LivingAirWorld for MotionWorld<'_, '_, '_> {
self.player.position.y
}
fn minimum_y(&mut self) -> Result<i32> {
Ok(0)
Ok(self.scene.shape.min_y)
}
fn discard_friction(&mut self) -> Result<bool> {
Ok(false)
@@ -3045,6 +3073,37 @@ mod tests {
));
}
#[test]
fn a_glide_that_just_ended_moves_slowly_until_the_pose_changes() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("movement-properties.nbt").exists() {
eprintln!("skipped glide exit regression: prepared resources unavailable");
return;
}
let assets = Assets::load(&root).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut p = PlayerMotion::new(&resources, Vec3::ZERO).unwrap();
p.set_pose(crate::pose::Pose::FallFlying).unwrap();
// Gliding: LivingEntity.isVisuallySwimming is false.
p.fall_flying = true;
assert!(!p.moving_slowly());
// The glide ended, the pose not yet: visually crawling.
p.fall_flying = false;
assert!(p.moving_slowly());
// Not in water (Entity.isVisuallyCrawling).
p.fluids.was_touching_water = true;
assert!(!p.moving_slowly());
p.fluids.was_touching_water = false;
p.set_pose(crate::pose::Pose::Standing).unwrap();
assert!(!p.moving_slowly());
}
#[test]
fn sword_sweep_requires_charge_ground_and_slow_horizontal_movement() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
@@ -5804,4 +5863,43 @@ mod tests {
"the flight speed follows the sprint state, not the sprint key"
);
}
/// #9817: `LivingEntity.travelInAir`: a client (`level.isClientSide()`) whose
/// chunk below is not loaded skips gravity and falls at 0.1 a tick
/// (above `getMinY`), before the 0.98 vertical drag.
#[test]
fn a_client_over_an_unloaded_chunk_falls_at_a_tenth() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
for (client_side, want) in [
(true, -0.1 * f64::from(0.98_f32)),
(false, -0.0784000015258789),
] {
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
// Far outside the test scene's chunks.
let at = Vec3::new(10_000.5, 20.0, 10_000.5);
assert!(!scene.chunks.contains_key(&(10_000 >> 4, 10_000 >> 4)));
let mut player = PlayerMotion::new(&resources, at).unwrap();
player.client_side = client_side;
MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
health: &resources.health,
}
.controlled_simulation_tick(Default::default())
.unwrap();
assert_eq!(player.velocity.y, want, "client_side={client_side}");
}
}
}
+8 -1
View File
@@ -118,8 +118,15 @@ impl PlayerMotion {
}
Ok(())
}
/// `LocalPlayer.isMovingSlowly`: crouching, or `Entity.isVisuallyCrawling`
/// (visually swimming out of water). `LivingEntity.isVisuallySwimming`
/// counts the swimming pose and the fall-flying pose a glide that just
/// ended still holds, so the tick after an elytra lands moves at sneaking
/// speed (#8295).
pub fn moving_slowly(&self) -> bool {
self.local_crouching || (self.pose == Pose::Swimming && !self.fluids.was_touching_water)
let visually_swimming =
self.pose == Pose::Swimming || (!self.fall_flying && self.pose == Pose::FallFlying);
self.local_crouching || (visually_swimming && !self.fluids.was_touching_water)
}
}
impl MotionWorld<'_, '_, '_> {
+496 -49
View File
@@ -10,7 +10,10 @@
pub mod client;
use crate::hud::{HudAtlas, HudBatch, rgba};
use client::{PendingInviteDto, RealmAddress, RealmServer, RealmsClient, RealmsError};
use crate::text::Text;
use client::{
PendingInviteDto, RealmAddress, RealmServer, RealmsClient, RealmsError, Subscription,
};
use horizon_client_render::auth::{self, AuthSession};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -21,6 +24,8 @@ const HEADER_HEIGHT: f32 = 44.0;
const CONTENT_WIDTH: f32 = 308.0;
const ROW_WIDTH: f32 = 300.0;
const ROW_HEIGHT: f32 = 36.0;
/// `RealmsConfigureWorldScreen`'s `MenuTabBar` height (`gui/tab_header_background`).
const TAB_HEIGHT: f32 = 24.0;
/// `hud::rgba` packing (ABGR): 160 grey, vanilla's red and `RealmsPopups.COLOR_INFO`.
const GREY: u32 = 0xFFA0_A0A0;
const RED: u32 = 0xFF55_55FF;
@@ -142,6 +147,88 @@ fn extend_link(server: &RealmServer, profile: u128) -> String {
}
}
/// `RealmsConfigureWorldScreen`'s tabs, in `MenuTabBar` order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfigureTab {
Worlds,
Players,
Subscription,
Settings,
}
impl ConfigureTab {
/// The tabs in `MenuTabBar` order, which is also their index.
const ALL: [Self; 4] = [
Self::Worlds,
Self::Players,
Self::Subscription,
Self::Settings,
];
/// `RealmsWorldsTab.TITLE` and the other tabs' own titles.
fn title(self) -> &'static str {
match self {
Self::Worlds => "mco.configure.worlds.title",
Self::Players => "mco.configure.world.players.title",
Self::Subscription => "mco.configure.world.subscription.tab",
Self::Settings => "mco.configure.world.settings.title",
}
}
/// `RealmsConfigureWorldScreen.init`'s `setTabActiveState(3, !serverData.expired)`: an
/// expired Realm's settings tab is off (with the expired tooltip).
fn enabled(self, expired: bool) -> bool {
self != Self::Settings || !expired
}
}
/// The tab bar's geometry over `MenuTabBar.builder(this, width)`: the bar is at most 400
/// wide, centred, and each of the four tabs is an eighth of its inner width rounded up to an
/// even number (`Java`'s integer division before the rounding).
fn tab_geometry(w: f32) -> (f32, f32) {
let width = w.min(400.0) - 28.0;
let x = (((w - width) / 2.0).floor() / 2.0).ceil() * 2.0;
let tab = ((width / 4.0).floor() / 2.0).ceil() * 2.0;
(x, tab)
}
/// `RealmsSubscriptionTab.daysLeftPresentation`: what the days-left line says, in the
/// caller's language; empty when nothing is left to say.
fn days_left_text(days_left: i32, expired: bool) -> String {
let t = crate::lang::get;
if days_left < 0 && expired {
return t("mco.configure.world.subscription.expired");
}
if days_left <= 1 {
return t("mco.configure.world.subscription.less_than_a_day");
}
let (months, days) = (days_left / 30, days_left % 30);
match (months > 0, days > 0) {
(true, true) => crate::lang::tr(
"mco.configure.world.subscription.remaining.months.days",
&[&months, &days],
),
(true, false) => crate::lang::tr(
"mco.configure.world.subscription.remaining.months",
&[&months],
),
(false, true) => {
crate::lang::tr("mco.configure.world.subscription.remaining.days", &[&days])
}
(false, false) => String::new(),
}
}
/// `RealmsSubscriptionTab.localPresentation`: the start date in the player's own local
/// time. The process has no locale to format with, so the shape is fixed (`%Y-%m-%d`)
/// where vanilla asks for the locale's medium date.
fn start_date_text(epoch_millis: i64) -> String {
let Some(time) = chrono::DateTime::from_timestamp_millis(epoch_millis) else {
return String::new();
};
time.with_timezone(&chrono::Local)
.format("%Y-%m-%d")
.to_string()
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LayoutState {
Loading,
@@ -165,6 +252,8 @@ pub enum Popup {
Leave(RealmServer),
/// `GetServerDetailsTask.resourcePackDownloadConfirmationScreen`.
ResourcePack { pack: RealmsPack },
/// `RealmsSubscriptionTab`'s delete question (only offered once the Realm has expired).
Delete(RealmServer),
}
#[derive(Clone, Debug)]
@@ -181,7 +270,14 @@ pub enum SubScreen {
},
Configure {
server: RealmServer,
tab: ConfigureTab,
/// `RealmsClient.subscriptionFor` on a worker, then its answer (the subscription tab).
subscription: Option<Result<Subscription, RealmsError>>,
subscription_job: Option<Job<Result<Subscription, RealmsError>>>,
/// `RealmsClient.deleteRealm` on a worker, from the subscription tab's question.
deleting: Option<Job<Result<(), RealmsError>>>,
error: Option<String>,
/// `RealmsClient.open`/`close` on a worker.
job: Option<Job<Result<(), RealmsError>>>,
},
AddRealm,
@@ -337,7 +433,15 @@ impl Screen {
*invites = Some(result);
}
}
SubScreen::Configure { server, error, job } => {
SubScreen::Configure {
server,
subscription,
subscription_job,
deleting,
error,
job,
..
} => {
if let Some(result) = job.as_ref().and_then(take) {
*job = None;
match result {
@@ -349,6 +453,22 @@ impl Screen {
Err(e) => *error = Some(e.text()),
}
}
if let Some(result) = subscription_job.as_ref().and_then(take) {
*subscription_job = None;
*subscription = Some(result);
}
if let Some(result) = deleting.as_ref().and_then(take) {
*deleting = None;
match result {
// The Realm is gone: the list reloads without it.
Ok(()) => {
self.subscreen = SubScreen::Main;
self.selected = None;
refresh = true;
}
Err(e) => *error = Some(e.text()),
}
}
}
SubScreen::Connecting { .. } => return self.poll_join(),
_ => {}
@@ -455,9 +575,17 @@ impl Screen {
/// Play: `RealmsMainScreen.play` → `GetServerDetailsTask` on a worker. The screen
/// shows "Connecting to the Realm..." and stays responsive.
fn play_selected(&mut self) -> Outcome {
let Some(server) = self.selected_server().filter(|s| s.is_open()).cloned() else {
match self.selected_server() {
Some(server) => self.start_join(server.clone()),
None => Outcome::None,
}
}
/// `RealmsMainScreen.play` for `server`, the list's or the configuration screen's.
fn start_join(&mut self, server: RealmServer) -> Outcome {
if !server.is_open() {
return Outcome::None;
};
}
let Some(client) = client() else {
return Outcome::None;
};
@@ -473,6 +601,26 @@ impl Screen {
Outcome::None
}
/// `RealmsConfigureWorldScreen.init`'s `fetchSubscription`: the subscription tab's
/// data, on a worker.
fn start_configure_subscription(&mut self) {
let SubScreen::Configure {
server,
subscription_job,
..
} = &mut self.subscreen
else {
return;
};
if subscription_job.is_some() {
return;
}
if let Some(client) = client() {
let id = server.id;
*subscription_job = Some(spawn(move || client.subscription_for(id)));
}
}
pub fn click(&mut self, hit: Option<u8>, x: f32, y: f32, (w, h): (f32, f32)) -> Outcome {
match hit {
Some(1) => self.play_selected(),
@@ -481,9 +629,14 @@ impl Screen {
if let Some(server) = self.selected_server().cloned() {
self.subscreen = SubScreen::Configure {
server,
tab: ConfigureTab::Worlds,
subscription: None,
subscription_job: None,
deleting: None,
error: None,
job: None,
};
self.start_configure_subscription();
}
Outcome::None
}
@@ -538,7 +691,9 @@ impl Screen {
Outcome::None
}
Some(30) => {
if let SubScreen::Configure { server, error, job } = &mut self.subscreen
if let SubScreen::Configure {
server, error, job, ..
} = &mut self.subscreen
&& job.is_none()
&& let Some(client) = client()
{
@@ -554,6 +709,40 @@ impl Screen {
}
Outcome::None
}
// The configuration screen's tabs (60..=63) and its footer: Play (64).
Some(sel @ 60..=63) => {
if let SubScreen::Configure { server, tab, .. } = &mut self.subscreen
&& let Some(wanted) = ConfigureTab::ALL.get(usize::from(sel - 60))
&& wanted.enabled(server.expired)
{
*tab = *wanted;
}
Outcome::None
}
Some(64) => match &self.subscreen {
SubScreen::Configure { server, .. } => self.start_join(server.clone()),
_ => Outcome::None,
},
// The subscription tab's own buttons: Delete (expired only, after its question)
// and Extend.
Some(65) => {
if let SubScreen::Configure {
server, deleting, ..
} = &self.subscreen
&& server.expired
&& deleting.is_none()
{
let server = server.clone();
self.subscreen = SubScreen::Popup(Popup::Delete(server));
}
Outcome::None
}
Some(66) => match (&self.subscreen, session()) {
(SubScreen::Configure { server, .. }, Some(s)) => {
Outcome::OpenUri(extend_link(server, s.uuid))
}
_ => Outcome::None,
},
Some(40) => Outcome::OpenUri(BUY_REALMS.to_owned()),
Some(50) => self.popup_continue(),
Some(btn @ 100..=200) => {
@@ -576,6 +765,21 @@ impl Screen {
}
Outcome::None
}
// The tab bar draws its own tabs (`MenuTabBar`), so a click there carries no
// button id: the tab bar's geometry decides.
None if matches!(self.subscreen, SubScreen::Configure { .. }) => {
let (bar_x, tab_w) = tab_geometry(w);
if y < TAB_HEIGHT && x >= bar_x {
let index = ((x - bar_x) / tab_w).floor() as usize;
if let SubScreen::Configure { server, tab, .. } = &mut self.subscreen
&& let Some(wanted) = ConfigureTab::ALL.get(index)
&& wanted.enabled(server.expired)
{
*tab = *wanted;
}
}
Outcome::None
}
_ => self.click_list(x, y, (w, h)),
}
}
@@ -615,6 +819,27 @@ impl Screen {
}
Outcome::Connect(address)
}
// RealmsSubscriptionTab.deleteRealm: the question was asked over the
// configuration screen, so it answers there; the list returns once the
// Realm is gone.
Popup::Delete(server) => {
let Some(client) = client() else {
return Outcome::None;
};
let id = server.id;
self.subscreen = SubScreen::Configure {
server,
tab: ConfigureTab::Subscription,
subscription: None,
subscription_job: None,
deleting: Some(spawn(move || client.delete_realm(id))),
error: None,
job: None,
};
// A failed delete stays here and the tab reads again.
self.start_configure_subscription();
Outcome::None
}
}
}
@@ -710,22 +935,53 @@ impl Screen {
}
}
}
SubScreen::Configure { server, job, .. } => {
let label = if server.is_open() {
SubScreen::Configure {
server,
tab,
subscription,
deleting,
job,
..
} => {
// The footer: Play, the open/close toggle (vanilla keeps it on the settings
// tab; it stays here until that tab lands) and Back.
buttons.push((
cx - 155.0,
h - 26.0,
100.0,
t("mco.selectServer.play"),
server.is_open(),
64,
None,
));
let toggle = if server.is_open() {
t("mco.configure.world.buttons.close")
} else {
t("mco.configure.world.buttons.open")
};
buttons.push((
cx - 100.0,
mid + 10.0,
200.0,
label,
job.is_none(),
30,
None,
));
buttons.push((cx - 100.0, h - 36.0, 200.0, t("gui.back"), true, 32, None));
buttons.push((cx - 50.0, h - 26.0, 100.0, toggle, job.is_none(), 30, None));
buttons.push((cx + 55.0, h - 26.0, 100.0, t("gui.back"), true, 32, None));
// RealmsSubscriptionTab's own two buttons.
if *tab == ConfigureTab::Subscription && matches!(subscription, Some(Ok(_))) {
buttons.push((
cx - 100.0,
92.0,
200.0,
t("mco.configure.world.subscription.extend"),
true,
66,
None,
));
buttons.push((
cx - 100.0,
118.0,
200.0,
t("mco.configure.world.delete.button"),
server.expired && deleting.is_none(),
65,
None,
));
}
}
SubScreen::AddRealm => {
buttons.push((
@@ -895,19 +1151,6 @@ impl Screen {
buttons
}
/// `RealmsServer` expiry tooltip text (`RealmsMainScreen` status line).
fn expiry_text(server: &RealmServer) -> String {
if server.expired {
crate::lang::get("mco.selectServer.expired")
} else if server.days_left <= 0 {
crate::lang::get("mco.selectServer.expires.soon")
} else if server.days_left == 1 {
crate::lang::get("mco.selectServer.expires.day")
} else {
crate::lang::tr("mco.selectServer.expires.days", &[&server.days_left])
}
}
#[allow(clippy::too_many_arguments)]
pub fn draw(
&self,
@@ -915,7 +1158,7 @@ impl Screen {
atlas: &HudAtlas,
caret: &mut crate::ui::Caret,
focused_at: Instant,
_cursor: (f32, f32),
cursor: (f32, f32),
w: f32,
h: f32,
) {
@@ -1005,19 +1248,151 @@ impl Screen {
}
}
}
SubScreen::Configure { server, error, job } => {
let title = format!("{} {}", t("mco.configure.world.title"), server.name);
centered(batch, atlas, &title, cx, 20.0, u32::MAX);
let info_y = mid - 60.0;
centered(batch, atlas, &server.motd, cx, info_y, GREY);
centered(
SubScreen::Configure {
server,
tab,
subscription,
deleting,
error,
job,
..
} => {
// `RealmsConfigureWorldScreen`: the tab bar over `gui/tab_header_background`,
// the header separators either side of it, and the footer's separator.
crate::ui::tiled(
batch,
atlas,
&Self::expiry_text(server),
cx,
info_y + 14.0,
GREY,
"gui/tab_header_background",
0.0,
0.0,
w,
TAB_HEIGHT,
16.0,
u32::MAX,
);
let (bar_x, tab_w) = tab_geometry(w);
let now = crate::hud_overlays::now_ms();
for (i, which) in ConfigureTab::ALL.iter().enumerate() {
let x = bar_x + i as f32 * tab_w;
let selected = which == tab;
let hover = crate::ui::hovered(cursor, x, 0.0, tab_w, TAB_HEIGHT);
let sprite = match (selected, hover) {
(true, true) => "gui/sprites/widget/tab_selected_highlighted",
(true, false) => "gui/sprites/widget/tab_selected",
(false, true) => "gui/sprites/widget/tab_highlighted",
(false, false) => "gui/sprites/widget/tab",
};
crate::ui::nine_slice(
batch,
atlas,
sprite,
x,
0.0,
tab_w,
TAB_HEIGHT,
u32::MAX,
);
if selected {
crate::ui::menu_background(
batch,
atlas,
x + 2.0,
2.0,
tab_w - 4.0,
22.0,
false,
);
batch.fill(atlas, x + 2.0, 22.0, tab_w - 4.0, 1.0, u32::MAX);
}
crate::ui::menu_tab_label(
batch,
atlas,
&Text::translate(which.title(), &[]),
(x, 0.0, tab_w, TAB_HEIGHT),
selected,
which.enabled(server.expired),
now,
);
}
for (left, width) in [(0.0, bar_x), (bar_x + 4.0 * tab_w, w - bar_x - 4.0 * tab_w)]
{
crate::ui::tiled_sized(
batch,
atlas,
"gui/header_separator",
left,
TAB_HEIGHT - 2.0,
width,
2.0,
[32.0, 2.0],
u32::MAX,
);
}
crate::ui::tiled_sized(
batch,
atlas,
"gui/footer_separator",
0.0,
h - 35.0,
w,
2.0,
[32.0, 2.0],
u32::MAX,
);
let left = cx - 100.0;
match tab {
ConfigureTab::Subscription => match subscription {
Some(Ok(sub)) => {
let mut label = |y: f32, text: &str, color: u32| {
batch.text(atlas, text, left, y, color, true);
};
label(26.0, &t("mco.configure.world.subscription.start"), u32::MAX);
label(41.0, &start_date_text(sub.start_date), GREY);
let days_label = if sub.kind == "RECURRING" {
t("mco.configure.world.subscription.recurring.daysleft")
} else {
t("mco.configure.world.subscription.timeleft")
};
label(56.0, &days_label, u32::MAX);
label(71.0, &days_left_text(sub.days_left, server.expired), GREY);
for (i, line) in wrap_lines(
&t("mco.configure.world.subscription.recurring.info"),
atlas,
200.0,
)
.iter()
.enumerate()
{
label(146.0 + i as f32 * 10.0, line, GREY);
}
}
Some(Err(e)) => {
for (i, line) in
wrap_lines(&e.text(), atlas, w - 40.0).iter().enumerate()
{
centered(batch, atlas, line, cx, 60.0 + i as f32 * 10.0, RED);
}
}
None => centered(
batch,
atlas,
&t("mco.configure.world.loading"),
cx,
60.0,
u32::MAX,
),
},
// The other tabs land in follow-ups; vanilla shows this while its own
// fetches run.
_ => centered(
batch,
atlas,
&t("mco.configure.world.loading"),
cx,
60.0,
u32::MAX,
),
}
let status = if job.is_some() {
Some((
t(if server.is_open() {
@@ -1027,19 +1402,14 @@ impl Screen {
}),
u32::MAX,
))
} else if deleting.is_some() {
Some((t("mco.configure.world.buttons.delete"), u32::MAX))
} else {
error.clone().map(|e| (e, RED))
};
if let Some((text, color)) = status {
for (i, line) in wrap_lines(&text, atlas, w - 40.0).iter().enumerate() {
centered(
batch,
atlas,
line,
cx,
info_y + 32.0 + i as f32 * 10.0,
color,
);
centered(batch, atlas, line, cx, h - 60.0 + i as f32 * 10.0, color);
}
}
}
@@ -1062,6 +1432,7 @@ impl Screen {
let message = match popup {
Popup::Leave(_) => t("mco.configure.world.leave.question.line1"),
Popup::ResourcePack { .. } => t("mco.configure.world.resourcepack.question"),
Popup::Delete(_) => t("mco.configure.world.delete.question.line1"),
};
let lines = wrap_lines(&message, atlas, CONTENT_WIDTH);
let top = mid - 10.0 - 10.0 * lines.len() as f32;
@@ -1455,4 +1826,80 @@ mod tests {
assert!(!screen.escape());
assert!(matches!(screen.subscreen, SubScreen::Main));
}
/// `RealmsConfigureWorldScreen`'s `MenuTabBar`: Worlds, Players, Subscription,
/// Settings, and an expired Realm's settings tab is off.
#[test]
fn configure_tabs_follow_the_vanilla_order_and_expiry() {
assert_eq!(
ConfigureTab::ALL,
[
ConfigureTab::Worlds,
ConfigureTab::Players,
ConfigureTab::Subscription,
ConfigureTab::Settings,
]
);
for tab in ConfigureTab::ALL {
assert!(tab.enabled(false), "{}", tab.title());
}
assert!(!ConfigureTab::Settings.enabled(true));
for tab in [
ConfigureTab::Worlds,
ConfigureTab::Players,
ConfigureTab::Subscription,
] {
assert!(tab.enabled(true), "{}", tab.title());
}
}
/// `MenuTabBar.builder(this, width)`: at most 400 wide, centred, four even tabs.
#[test]
fn the_tab_bar_is_centred_and_four_tabs_wide() {
// 854x480 at GUI scale 2 is 427x240: 372 inner width, 28 from the left, 94 a tab.
assert_eq!(tab_geometry(427.0), (28.0, 94.0));
// A window narrower than 400 keeps its own width less the bar's margins:
// 272 inner width over four tabs, 14 from the left.
assert_eq!(tab_geometry(300.0), (14.0, 68.0));
}
/// `RealmsSubscriptionTab.daysLeftPresentation`, compared with the same translations
/// so the check reads in any language.
#[test]
fn days_left_says_what_vanilla_says() {
let t = crate::lang::get;
assert_eq!(
days_left_text(-1, true),
t("mco.configure.world.subscription.expired")
);
let less = t("mco.configure.world.subscription.less_than_a_day");
assert_eq!(days_left_text(0, false), less);
assert_eq!(days_left_text(1, false), less);
assert_eq!(
days_left_text(45, false),
crate::lang::tr(
"mco.configure.world.subscription.remaining.months.days",
&[&1, &15]
)
);
assert_eq!(
days_left_text(60, false),
crate::lang::tr("mco.configure.world.subscription.remaining.months", &[&2])
);
assert_eq!(
days_left_text(5, false),
crate::lang::tr("mco.configure.world.subscription.remaining.days", &[&5])
);
// An expired Realm still shows its remaining time while it has some.
assert_eq!(
days_left_text(60, true),
crate::lang::tr("mco.configure.world.subscription.remaining.months", &[&2])
);
}
/// An unreadable start date says nothing rather than a wrong date.
#[test]
fn an_unreadable_start_date_is_empty() {
assert_eq!(start_date_text(i64::MAX), "");
}
}
@@ -120,6 +120,20 @@ struct PendingInvitesResponse {
invites: Vec<PendingInviteDto>,
}
/// `Subscription`: the realm's own, as `RealmsClient.subscriptionFor` reads it.
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Subscription {
/// `Subscription.startDate`: epoch milliseconds.
#[serde(default, deserialize_with = "nullable")]
pub start_date: i64,
#[serde(default, deserialize_with = "nullable")]
pub days_left: i32,
/// `Subscription.SubscriptionType`: `NORMAL` or `RECURRING`.
#[serde(rename = "type", default, deserialize_with = "nullable")]
pub kind: String,
}
/// Why a Realms call failed (`RealmsServiceException` and its `RealmsError`). None of them
/// holds a request header: the text a screen shows can't contain the token.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -400,6 +414,19 @@ impl RealmsClient {
Self::execute(self.request("PUT", &url, READ_TIMEOUT), Some("")).map(drop)
}
/// `RealmsClient.subscriptionFor`: the configuration screen's subscription tab.
pub fn subscription_for(&self, realm_id: i64) -> Result<Subscription, RealmsError> {
let url = self.url(&format!("worlds/{realm_id}/subscription"));
let json = Self::execute(self.request("GET", &url, READ_TIMEOUT), None)?;
serde_json::from_str(&json).map_err(|_| RealmsError::Parse)
}
/// `RealmsClient.deleteRealm` (`Request.delete`).
pub fn delete_realm(&self, realm_id: i64) -> Result<(), RealmsError> {
let url = self.url(&format!("worlds/{realm_id}"));
Self::execute(self.request("DELETE", &url, READ_TIMEOUT), None).map(drop)
}
/// `RealmsClient.redeemInviteCode`: the invite-code service, with the token in
/// `x-mojangauth` as `frontendV2Request` sets it.
pub fn redeem_invite_code(&self, code: &str) -> Result<(), RealmsError> {
+4 -4
View File
@@ -208,7 +208,7 @@ impl Server {
}
fn server(resources: &Path) -> Server {
server_with(resources, NetConfig::lan(0, "Test - World".into(), 8))
server_with(resources, NetConfig::loopback(0, "Test - World".into(), 8))
}
fn server_with(resources: &Path, net: NetConfig) -> Server {
@@ -295,7 +295,7 @@ fn a_lan_guest_sees_the_signed_in_owners_skin() {
let net = NetConfig {
announce: false,
owner_profile: Some(profile.clone()),
..NetConfig::lan(0, "Test - World".into(), 8)
..NetConfig::loopback(0, "Test - World".into(), 8)
};
let mut s = server_with(&resources, net);
s.hub.singleplayer_owner = Some(offline_uuid("Owner"));
@@ -484,7 +484,7 @@ fn neoforge_shapes_items_reach_two_guests() {
sync.id("minecraft:item", "minecraft:raw_copper").unwrap(),
sync.id("minecraft:item", "minecraft:redstone").unwrap(),
);
let mut s = server_sync(sync, NetConfig::lan(0, "Test - World".into(), 8));
let mut s = server_sync(sync, NetConfig::loopback(0, "Test - World".into(), 8));
let port = s.hub.lan.port;
// Guest one joins first and stays while guest two joins and reads its
// own join packets, then guest one reads its own.
@@ -741,7 +741,7 @@ fn a_signed_in_hosts_lan_authenticates_guests_but_not_its_own_player() {
let net = NetConfig {
online: Some(Arc::new(OnlineMode::generate(false).unwrap())),
announce: false,
..NetConfig::lan(0, "Test - World".into(), 8)
..NetConfig::loopback(0, "Test - World".into(), 8)
};
let lan = Lan::start_with(sync, net).unwrap();
let guest = Connection::connect("127.0.0.1", lan.port, Duration::from_secs(5)).unwrap();
+83 -2
View File
@@ -689,9 +689,20 @@ pub fn parse_stack(snbt: &str) -> Result<Option<ItemStack>, String> {
let (tag, _) = horizon_world::nbt::parse_snbt(snbt)?;
Ok(crate::item_nbt::item_from_nbt(&tag))
}
/// The `components` compound for a stack, or None when every component is default.
/// The saved `DataComponentPatch.CODEC` compound, or None when default.
/// A filled map's empty decorations equal its prototype (`Items.mapProperties`).
pub fn encode_components(stack: &ItemStack) -> Option<Tag> {
components_tag(stack.damage, stack.data())
let mut components = components_tag(stack.damage, stack.data())?;
if &*stack.item == "minecraft:filled_map"
&& matches!(components.get("minecraft:map_decorations"), Some(Tag::Compound(entries)) if entries.is_empty())
&& let Tag::Compound(entries) = &mut components
{
entries.remove(&JavaString::from("minecraft:map_decorations"));
if entries.is_empty() {
return None;
}
}
Some(components)
}
/// `encode_components` from the damage value and extra components.
pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option<Tag> {
@@ -1108,6 +1119,76 @@ pub fn command_components(text: &str) -> Result<Tag, String> {
mod tests {
use super::*;
#[test]
fn empty_map_decoration_patch_uses_filled_map_prototype() {
let parse = |text| horizon_world::nbt::parse_snbt(text).unwrap().0;
let empty = parse(r#"{"minecraft:map_decorations":{}}"#);
assert!(decode_components(None).1.is_none());
assert!(encode_components(&ItemStack::new("minecraft:filled_map", 1)).is_none());
let mut fresh = ItemStack::new("minecraft:filled_map", 1);
fresh
.data_mut()
.other
.push(("minecraft:map_decorations".into(), parse("{}")));
// The value codec has no item prototype; only the saved patch elides it.
assert_eq!(components_tag(0, fresh.data()), Some(empty.clone()));
assert!(encode_components(&fresh).is_none());
let (damage, extra) = decode_components(Some(&empty));
let loaded = ItemStack {
damage,
extra,
..ItemStack::new("minecraft:filled_map", 1)
};
let saved = crate::item_nbt::item_to_nbt(&loaded);
assert_eq!(saved, parse(r#"{id:"minecraft:filled_map",count:1}"#));
let back = crate::item_nbt::item_from_nbt(&saved).unwrap();
assert!(encode_components(&back).is_none());
let keep = parse(r#"{"minecraft:map_decorations":{},"minecraft:custom_data":{keep:7}}"#);
let (damage, extra) = decode_components(Some(&keep));
let stack = ItemStack {
damage,
extra,
..ItemStack::new("minecraft:filled_map", 1)
};
assert_eq!(
encode_components(&stack),
Some(parse(r#"{"minecraft:custom_data":{keep:7}}"#))
);
}
#[test]
fn nondefault_map_decoration_patches_round_trip() {
let parse = |text| horizon_world::nbt::parse_snbt(text).unwrap().0;
let nonempty = parse(
r#"{"minecraft:map_decorations":{"a+b":{type:"minecraft:red_x",x:-1.25d,z:2.75d,rotation:-45.5f}},"minecraft:custom_data":{keep:7}}"#,
);
let removed =
parse(r#"{"!minecraft:map_decorations":{},"minecraft:custom_data":{keep:7}}"#);
let empty = parse(r#"{"minecraft:map_decorations":{}}"#);
for (item, components) in [
("minecraft:filled_map", &nonempty),
("minecraft:stone", &nonempty),
("minecraft:filled_map", &removed),
("minecraft:stone", &empty),
("minecraft:map", &empty),
] {
let (damage, extra) = decode_components(Some(components));
let stack = ItemStack {
damage,
extra,
..ItemStack::new(item, 1)
};
assert_eq!(
encode_components(&stack),
Some(components.clone()),
"{item}"
);
let back =
crate::item_nbt::item_from_nbt(&crate::item_nbt::item_to_nbt(&stack)).unwrap();
assert_eq!(encode_components(&back), Some(components.clone()), "{item}");
}
}
#[test]
fn components_round_trip() {
let mut stack = ItemStack::new("minecraft:diamond_sword", 1);
+3 -44
View File
@@ -8,15 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -209,12 +200,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
version = "0.2.4"
@@ -231,17 +216,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -491,7 +465,7 @@ dependencies = [
"toml",
"tower",
"ureq",
"zip 2.6.1",
"zip",
]
[[package]]
@@ -506,7 +480,7 @@ dependencies = [
"sha2",
"toml",
"wasmparser",
"zip 9.1.3",
"zip",
]
[[package]]
@@ -520,7 +494,7 @@ dependencies = [
"serde_json",
"sha2",
"toml",
"zip 9.1.3",
"zip",
]
[[package]]
@@ -1778,21 +1752,6 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "zip"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zip"
version = "9.1.3"
+2 -2
View File
@@ -27,11 +27,11 @@ getrandom = "0.3"
rusqlite = { version = "0.37", features = ["bundled"] }
# Draining a refused upload (already one of axum's).
http-body-util = "0.1"
# The upload's zip checked before any reader opens it (src/archive.rs): horizon-mods' own.
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
# A native mod's `.hnmod` for the tests (already one of horizon-mods').
zip = { version = "2", default-features = false, features = ["deflate"] }
[profile.release]
strip = true
+197
View File
@@ -0,0 +1,197 @@
//! The upload's zip, checked once before any kind's own reader opens it: every
//! reader (registry, launcher, game) then sees the same plain files.
//!
//! Refused: a local header that disagrees with the central directory (one
//! zip that reads as two), two entries for one path, overlapping entries,
//! symlinks and other special files, encrypted entries, and more entries or
//! declared bytes than any kind's reader takes.
use std::{collections::HashSet, path::Path};
/// Entries: a content mod's 65 536 files and their folders.
const MAX_ENTRIES: usize = 100_000;
/// Declared uncompressed bytes: above every kind's own total (a content mod's
/// 512 MiB and a native bundle's 64 MiB).
const MAX_UNCOMPRESSED: u64 = 1 << 30;
/// File type bits of a Unix mode: a regular file, a folder, or none named.
const S_IFMT: u32 = 0o170000;
const S_IFREG: u32 = 0o100000;
const S_IFDIR: u32 = 0o040000;
pub fn check(path: &Path) -> Result<(), String> {
check_with(path, MAX_ENTRIES, MAX_UNCOMPRESSED)
}
fn check_with(path: &Path, max_entries: usize, max_bytes: u64) -> Result<(), String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("not a zip: {e}"))?;
if zip.len() > max_entries {
return Err(format!("over {max_entries} entries in the zip"));
}
// Duplicate central names, local headers that differ from the central
// directory (name, sizes, method, flags), entries sharing bytes.
zip.validate_header_consistency()
.map_err(|e| e.to_string())?;
let mut seen = HashSet::new();
let mut total = 0u64;
for i in 0..zip.len() {
let entry = zip.by_index_raw(i).map_err(|e| e.to_string())?;
let name = entry.name().map_err(|e| e.to_string())?.into_owned();
if entry
.unix_mode()
.is_some_and(|m| ![0, S_IFREG, S_IFDIR].contains(&(m & S_IFMT)))
{
return Err(format!("{name:?} is a symlink or special file"));
}
if entry.encrypted() {
return Err(format!("{name:?} is encrypted"));
}
// One path on a case-insensitive disk, a file and a folder alike.
if !seen.insert(name.trim_end_matches('/').to_lowercase()) {
return Err(format!("{name:?} is in the zip twice"));
}
total = total.saturating_add(entry.size());
if total > max_bytes {
return Err(format!("the zip expands beyond {} MiB", max_bytes >> 20));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Cursor, Write};
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
fn stored() -> SimpleFileOptions {
SimpleFileOptions::default().compression_method(CompressionMethod::Stored)
}
fn zip_of(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in files {
zip.start_file(*name, stored()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// Replaces the `nth` (0-based, `None` = every) occurrence of `from` by
/// `to` (same length): a name in a local header comes before its central
/// directory record.
fn patch(bytes: &mut [u8], from: &[u8], to: &[u8], nth: Option<usize>) {
assert_eq!(from.len(), to.len());
let at: Vec<usize> = (0..=bytes.len() - from.len())
.filter(|&i| &bytes[i..i + from.len()] == from)
.collect();
assert!(!at.is_empty());
for (k, &i) in at.iter().enumerate() {
if nth.is_none_or(|n| n == k) {
bytes[i..i + to.len()].copy_from_slice(to);
}
}
}
fn checked(bytes: &[u8]) -> Result<(), String> {
checked_with(bytes, MAX_ENTRIES, MAX_UNCOMPRESSED)
}
fn checked_with(bytes: &[u8], entries: usize, total: u64) -> Result<(), String> {
let dir = std::env::temp_dir().join(format!(
"registry-archive-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("upload.zip");
std::fs::write(&path, bytes).unwrap();
let result = check_with(&path, entries, total);
let _ = std::fs::remove_dir_all(&dir);
result
}
#[test]
fn plain_files_and_folders_pass() {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.add_directory("assets/", stored()).unwrap();
zip.start_file("assets/a.png", stored()).unwrap();
zip.write_all(b"png").unwrap();
zip.start_file("horizon-mod.toml", SimpleFileOptions::default())
.unwrap();
zip.write_all(b"id = \"a\"").unwrap();
assert_eq!(checked(&zip.finish().unwrap().into_inner()), Ok(()));
}
#[test]
fn a_symlink_is_refused() {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("a.txt", stored()).unwrap();
zip.write_all(b"x").unwrap();
zip.add_symlink("assets/link", "/etc/passwd", stored())
.unwrap();
let err = checked(&zip.finish().unwrap().into_inner()).unwrap_err();
assert!(err.contains("\"assets/link\" is a symlink"), "{err}");
}
#[test]
fn a_fifo_is_refused() {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("pipe", stored().external_attributes(0o010644 << 16))
.unwrap();
let err = checked(&zip.finish().unwrap().into_inner()).unwrap_err();
assert!(err.contains("special file"), "{err}");
}
/// The central directory says `good.txt`, the local header `evil.txt`: a
/// streaming reader would see another file than the checks did.
#[test]
fn a_local_header_that_differs_is_refused() {
let mut bytes = zip_of(&[("good.txt", b"x")]);
patch(&mut bytes, b"good.txt", b"evil.txt", Some(0));
assert!(checked(&bytes).unwrap_err().contains("mismatch"));
}
#[test]
fn two_entries_for_one_path_are_refused() {
let mut bytes = zip_of(&[("a.txt", b"one"), ("b.txt", b"two")]);
patch(&mut bytes, b"b.txt", b"a.txt", None);
assert!(checked(&bytes).unwrap_err().contains("duplicate"));
// Two cases of one name are one file on macOS and Windows.
let bytes = zip_of(&[("Data/x.json", b"{}"), ("data/X.json", b"{}")]);
let err = checked(&bytes).unwrap_err();
assert!(err.contains("\"data/X.json\" is in the zip twice"), "{err}");
}
#[test]
fn an_encrypted_entry_is_refused() {
let mut bytes = zip_of(&[("a.txt", b"x")]);
// Bit 0 of the general purpose flags, in the local header (offset 6)
// and the central record (offset 8): the headers still agree.
bytes[6] |= 1;
let central = (0..bytes.len() - 4)
.find(|&i| &bytes[i..i + 4] == b"PK\x01\x02")
.unwrap();
bytes[central + 8] |= 1;
assert!(checked(&bytes).unwrap_err().contains("encrypted"));
}
#[test]
fn entries_and_declared_size_are_capped() {
let bytes = zip_of(&[("a", b""), ("b", b""), ("c", b"")]);
assert_eq!(checked_with(&bytes, 3, 1 << 20), Ok(()));
assert!(
checked_with(&bytes, 2, 1 << 20)
.unwrap_err()
.contains("over 2 entries")
);
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("big", SimpleFileOptions::default()).unwrap();
zip.write_all(&vec![0; 3 << 20]).unwrap();
let bytes = zip.finish().unwrap().into_inner();
assert!(bytes.len() < 1 << 20);
assert_eq!(checked_with(&bytes, 10, 4 << 20), Ok(()));
let err = checked_with(&bytes, 10, 2 << 20).unwrap_err();
assert!(err.contains("expands beyond 2 MiB"), "{err}");
}
}
+8
View File
@@ -5,6 +5,7 @@
//! request with the entry. Reviewers decide; merging publishes. Signed-in
//! players also rate published projects (`ratings`, its only data: sessions
//! are signed cookies, and GitHub holds everything else).
mod archive;
pub mod github;
pub mod ratings;
@@ -861,6 +862,7 @@ pub fn publish(
return Err(bad("a project needs a name and a licence"));
}
// The file's own checks first: a wrong file never reaches GitHub.
archive::check(file).map_err(bad)?;
let facts = index::inspect(&kind, file).map_err(bad)?;
if let Some(id) = facts.id.as_ref().filter(|id| **id != form.id) {
return Err(bad(format!("the file's id is {id:?}, not {:?}", form.id)));
@@ -909,6 +911,9 @@ pub fn publish(
license: form.license.trim().to_owned(),
source: form.source.trim().to_owned(),
icon: form.icon.clone(),
categories: Vec::new(),
tags: Vec::new(),
screenshots: Vec::new(),
key: form.key.clone().filter(|k| !k.trim().is_empty()),
versions: vec![version.clone()],
},
@@ -1183,6 +1188,9 @@ pub fn list(config: &Config, login: &str, user_id: u64, form: &Form) -> Result<S
license: form.license.trim().to_owned(),
source: String::new(),
icon: form.icon.clone(),
categories: Vec::new(),
tags: Vec::new(),
screenshots: Vec::new(),
key: form.key.clone().filter(|k| !k.trim().is_empty()),
versions: Vec::new(),
};
@@ -685,6 +685,29 @@ async fn what_the_form_refuses() {
/// The origin gate in front of a submission, before the sign-in and the
/// form: how the answer to a request from `origin`.
/// The mod's file with a symlink beside its files: refused before any kind's
/// reader opens it (src/archive.rs).
#[tokio::test(flavor = "multi_thread")]
async fn a_zip_with_a_symlink_is_refused() {
let (mut client, _mock) = setup().await;
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
let mut zip = zip::ZipWriter::new_append(std::io::Cursor::new(file)).unwrap();
zip.add_symlink(
"assets/link",
"/etc/passwd",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
let linked = zip.finish().unwrap().into_inner();
let csrf = client.sign_in().await;
let (status, json) = client.submit(&csrf, &form(), &linked).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
assert_eq!(
json["error"],
"\"assets/link\" is a symlink or special file"
);
}
async fn origin_gate(app: &Router, origin: Option<&str>) -> StatusCode {
let mut request = Request::post("/api/mods/submit");
if let Some(origin) = origin {
+15 -2
View File
@@ -376,16 +376,24 @@ pub struct MobEffect {
/// the vanilla entity types a mod's type may copy: thrown item
/// projectiles (`ThrowableItemProjectile`), then living mobs
/// ([`LIVING_TEMPLATES`]).
pub const ENTITY_TEMPLATES: [&str; 3] = [
pub const ENTITY_TEMPLATES: [&str; 4] = [
"minecraft:snowball",
"minecraft:villager",
"minecraft:wandering_trader",
"minecraft:zombie",
];
/// the [`ENTITY_TEMPLATES`] that are living mobs: a type copying one is a mob
/// of its template's class (NeoForge's `EntityType.Builder.of(Zombie::new,
/// ...)`), with its own default attributes (`EntityAttributeCreationEvent`).
pub const LIVING_TEMPLATES: [&str; 2] = ["minecraft:villager", "minecraft:zombie"];
/// The wandering trader is the neutral one whose behaviour is all goals,
/// which a native mod can replace (`register_goals`); a villager's is its
/// brain.
pub const LIVING_TEMPLATES: [&str; 3] = [
"minecraft:villager",
"minecraft:wandering_trader",
"minecraft:zombie",
];
/// an entity type the mod adds: a copy of a vanilla template's. A thrown one
/// is thrown from `item` and drawn as it; a living one is its template's mob
@@ -1677,6 +1685,10 @@ capabilities = ["registries"]
"data/ruby/horizon/entity_type/settler.json",
br#"{"template": "minecraft:villager"}"#,
),
(
"data/ruby/horizon/entity_type/merchant.json",
br#"{"template": "minecraft:wandering_trader"}"#,
),
];
let content = hmod("living", MANIFEST_TEXT, files).unwrap();
let types: Vec<_> = content
@@ -1705,6 +1717,7 @@ capabilities = ["registries"]
("minecraft:movement_speed", 0.3)
]
),
("ruby:merchant", "minecraft:wandering_trader", true, vec![]),
("ruby:settler", "minecraft:villager", true, vec![]),
]
);
+95 -2
View File
@@ -51,8 +51,13 @@ pub enum Kind {
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
ResourcePack,
/// A Horizon shader pack (`.hshader`, the native WGSL format of
/// docs/shaders.md; not the shaders/-folder packs, which stay on Modrinth).
/// docs/shaders.md).
Shader,
/// A `shaders/`-folder shader pack (`.zip`, the format `horizon-shaders` loads), listed by
/// its author under a licence that allows it.
ShaderPack,
/// A data pack (`.zip` with `pack.mcmeta` and `data/`), installed into a world.
DataPack,
Other(String),
}
@@ -64,6 +69,8 @@ impl Kind {
Kind::Modpack => "modpack",
Kind::ResourcePack => "resourcepack",
Kind::Shader => "shader",
Kind::ShaderPack => "shaderpack",
Kind::DataPack => "datapack",
Kind::Other(name) => name,
}
}
@@ -76,6 +83,7 @@ impl Kind {
Kind::Modpack => Some("hpack"),
Kind::ResourcePack => Some("zip"),
Kind::Shader => Some(horizon_shader_api::EXTENSION),
Kind::ShaderPack | Kind::DataPack => Some("zip"),
Kind::Other(_) => None,
}
}
@@ -89,6 +97,8 @@ impl From<String> for Kind {
"modpack" => Kind::Modpack,
"resourcepack" => Kind::ResourcePack,
"shader" => Kind::Shader,
"shaderpack" => Kind::ShaderPack,
"datapack" => Kind::DataPack,
_ => Kind::Other(name),
}
}
@@ -122,6 +132,15 @@ pub struct Project {
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// From the kind's list ([`page::categories`]), at most 3.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub categories: Vec<String>,
/// Free-form, lowercase, at most 10.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// `https://` addresses of PNG or JPEG images, in order, at most 8.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub screenshots: Vec<String>,
/// The author's minisign public key: every version must be signed with
/// it. None: unsigned (the SHA-256 still pins the reviewed file).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -258,6 +277,16 @@ impl Index {
if let Some(icon) = &p.icon {
https(icon).map_err(at)?;
}
if p.screenshots.len() > page::MAX_SCREENSHOTS {
return Err(at(format!(
"{} screenshots, at most {}",
p.screenshots.len(),
page::MAX_SCREENSHOTS
)));
}
for s in &p.screenshots {
https(s).map_err(at)?;
}
if let Some(key) = &p.key {
minisign_verify::PublicKey::from_base64(key.trim())
.map_err(|e| at(format!("key: {e}")))?;
@@ -625,6 +654,14 @@ pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
..Facts::default()
})
}
Kind::ShaderPack => Ok(Facts {
api: check_shader_folder_pack(path)?,
..Facts::default()
}),
Kind::DataPack => Ok(Facts {
api: check_data_pack(path)?,
..Facts::default()
}),
Kind::Other(name) => Err(format!("this Horizon can't read a {name} yet")),
}
}
@@ -633,8 +670,15 @@ pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
/// object, and only plain paths. Returns its pack format (`pack_format`, or
/// `min_format` in newer packs), empty when it names none.
fn check_resource_pack(path: &Path) -> Result<String, String> {
let (mut zip, _) = plain_zip(path)?;
pack_format(&mut zip)
}
/// A zip whose paths are all plain, and those paths.
fn plain_zip(path: &Path) -> Result<(zip::ZipArchive<std::fs::File>, Vec<String>), String> {
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
let mut names = Vec::with_capacity(zip.len());
for i in 0..zip.len() {
let name = zip
.by_index_raw(i)
@@ -646,7 +690,49 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
{
return Err(format!("{name:?} is not a plain path"));
}
names.push(name);
}
Ok((zip, names))
}
/// A data pack: a resource pack's rules, and a `data/` folder at the root.
fn check_data_pack(path: &Path) -> Result<String, String> {
let (mut zip, names) = plain_zip(path)?;
if !names.iter().any(|n| n.starts_with("data/")) {
return Err("no data/ folder at the zip's root: not a data pack".into());
}
pack_format(&mut zip)
}
/// A `shaders/`-folder shader pack: plain paths and a `shaders/` folder at the root. Its format
/// has no version of its own: the facts' `api` is empty.
fn check_shader_folder_pack(path: &Path) -> Result<String, String> {
let (_, names) = plain_zip(path)?;
if !names.iter().any(|n| n.starts_with("shaders/")) {
return Err("no shaders/ folder at the zip's root: not a shader pack".into());
}
Ok(String::new())
}
/// What a zip's file is, by its content: a `shaders/` folder makes a shader pack, a `data/` folder
/// without `assets/` a data pack, anything else a resource pack (checked as one).
pub fn zip_kind(path: &Path) -> Kind {
let Ok((_, names)) = plain_zip(path) else {
return Kind::ResourcePack;
};
let root = |dir: &str| names.iter().any(|n| n.starts_with(dir));
if root("shaders/") {
Kind::ShaderPack
} else if root("data/") && !root("assets/") {
Kind::DataPack
} else {
Kind::ResourcePack
}
}
/// `pack.mcmeta`'s pack format (`pack_format`, or `min_format` in newer packs), empty when it
/// names none; refused when it has no `pack` object.
fn pack_format(zip: &mut zip::ZipArchive<std::fs::File>) -> Result<String, String> {
let mut meta = String::new();
zip.by_name("pack.mcmeta")
.map_err(|_| "pack.mcmeta: missing".to_string())?
@@ -678,7 +764,13 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
/// formats.
pub fn open(kind: &Kind) -> Result<(), String> {
match kind {
Kind::Mod | Kind::NativeMod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
Kind::Mod
| Kind::NativeMod
| Kind::Modpack
| Kind::ResourcePack
| Kind::Shader
| Kind::ShaderPack
| Kind::DataPack => Ok(()),
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
}
}
@@ -689,6 +781,7 @@ fn names(caps: &[Capability]) -> String {
}
pub mod native;
pub mod page;
pub mod registry;
#[cfg(test)]
mod tests;
+406
View File
@@ -0,0 +1,406 @@
//! A project page's rules (docs/design/registry-v2.md): its categories, tags,
//! description and images. Shared by the registry's CI ([`super::registry`]),
//! the submission service and the index's readers.
use super::Kind;
/// At most this many categories, at least one once [`REQUIRED`].
pub const MAX_CATEGORIES: usize = 3;
pub const MAX_TAGS: usize = 10;
pub const MAX_TAG: usize = 24;
pub const MAX_SCREENSHOTS: usize = 8;
pub const MIN_DESCRIPTION: usize = 50;
pub const MAX_DESCRIPTION: usize = 10_000;
/// The biggest screenshot file.
pub const MAX_SCREENSHOT: u64 = 1 << 20;
/// A screenshot's smallest and biggest size, in pixels.
pub const SCREENSHOT_MIN: (u32, u32) = (320, 180);
pub const SCREENSHOT_MAX: (u32, u32) = (3840, 2160);
/// An icon is square, this many pixels a side.
pub const ICON_SIDE: std::ops::RangeInclusive<u32> = 64..=1024;
/// Whether a page must have a description, a category and an icon. Off until
/// every listed project has them (the rollout's step 4).
pub const REQUIRED: bool = false;
const MODS: &[&str] = &[
"adventure",
"decoration",
"economy",
"equipment",
"food",
"game-mechanics",
"library",
"magic",
"management",
"minigame",
"mobs",
"optimization",
"social",
"storage",
"technology",
"transportation",
"utility",
"worldgen",
];
const MODPACKS: &[&str] = &[
"adventure",
"challenging",
"combat",
"kitchen-sink",
"lightweight",
"magic",
"multiplayer",
"optimization",
"quests",
"technology",
];
const RESOURCE_PACKS: &[&str] = &[
"8x",
"16x",
"32x",
"48x",
"64x",
"128x",
"256x",
"512x",
"audio",
"blocks",
"combat",
"core-shaders",
"decoration",
"entities",
"environment",
"equipment",
"fonts",
"gui",
"items",
"locale",
"models",
"realistic",
"simplistic",
"themed",
"tweaks",
"utility",
"vanilla-like",
];
const SHADERS: &[&str] = &[
"cartoon",
"fantasy",
"realistic",
"semi-realistic",
"vanilla-like",
"atmosphere",
"bloom",
"colored-lighting",
"foliage",
"path-tracing",
"pbr",
"reflections",
"shadows",
"potato",
"low",
"medium",
"high",
"screenshot",
];
/// Words a tag may not hold: no listing is about gambling (Mojang's Usage
/// Guidelines name it for every use).
const REFUSED_TAG_WORDS: &[&str] = &[
"casino",
"gambl",
"slot-machine",
"lottery",
"betting",
"jackpot",
"roulette",
"poker",
];
/// The categories a project of `kind` picks from: Modrinth's lists, without
/// `cursed`. Empty for a kind this build doesn't know.
pub fn categories(kind: &Kind) -> &'static [&'static str] {
match kind {
Kind::Mod | Kind::NativeMod | Kind::DataPack => MODS,
Kind::Modpack => MODPACKS,
Kind::ResourcePack => RESOURCE_PACKS,
Kind::Shader | Kind::ShaderPack => SHADERS,
Kind::Other(_) => &[],
}
}
/// `[a-z0-9][a-z0-9-]*`, at most [`MAX_TAG`] long.
fn plain_tag(tag: &str) -> bool {
tag.len() <= MAX_TAG
&& tag
.bytes()
.next()
.is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
&& tag
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// Every problem with a page's description, categories and tags, each a
/// sentence a submitter can act on.
pub fn check(
kind: &Kind,
description: &str,
categories: &[String],
tags: &[String],
) -> Vec<String> {
let mut errors = Vec::new();
let length = description.trim().chars().count();
if length > MAX_DESCRIPTION {
errors.push(format!(
"the description is {length} characters: at most {MAX_DESCRIPTION}"
));
} else if REQUIRED && length < MIN_DESCRIPTION {
errors.push(format!(
"the description is {length} characters: write at least {MIN_DESCRIPTION} (what it does, how to use it)"
));
}
let known = self::categories(kind);
if REQUIRED && categories.is_empty() {
errors.push(format!("pick 1 to {MAX_CATEGORIES} categories"));
}
if categories.len() > MAX_CATEGORIES {
errors.push(format!(
"{} categories: pick at most {MAX_CATEGORIES}",
categories.len()
));
}
for (i, c) in categories.iter().enumerate() {
if !known.contains(&c.as_str()) {
errors.push(format!(
"{c:?} is not a category of a {}: {}",
kind.name(),
known.join(", ")
));
} else if categories[..i].contains(c) {
errors.push(format!("category {c:?} is listed twice"));
}
}
if tags.len() > MAX_TAGS {
errors.push(format!("{} tags: at most {MAX_TAGS}", tags.len()));
}
for (i, t) in tags.iter().enumerate() {
if !plain_tag(t) {
errors.push(format!(
"tag {t:?}: lowercase letters, digits and '-', at most {MAX_TAG} characters"
));
} else if REFUSED_TAG_WORDS.iter().any(|w| t.contains(w)) {
errors.push(format!(
"tag {t:?}: the registry lists nothing about gambling"
));
} else if tags[..i].contains(t) {
errors.push(format!("tag {t:?} is listed twice"));
}
}
errors
}
/// An image's format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Image {
Png,
Jpeg,
}
impl Image {
/// The extensions its files carry.
pub fn extensions(self) -> &'static [&'static str] {
match self {
Image::Png => &["png"],
Image::Jpeg => &["jpg", "jpeg"],
}
}
}
/// The format and size in pixels of a PNG or a JPEG, read from its header;
/// None for anything else.
pub fn image_size(bytes: &[u8]) -> Option<(Image, u32, u32)> {
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n";
if bytes.starts_with(PNG) && bytes.get(12..16) == Some(b"IHDR") {
let w = u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?);
let h = u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?);
return Some((Image::Png, w, h));
}
if !bytes.starts_with(&[0xff, 0xd8]) {
return None;
}
// JPEG: walk the segments to the first start-of-frame.
let mut at = 2;
loop {
while *bytes.get(at)? == 0xff && *bytes.get(at + 1)? == 0xff {
at += 1;
}
if *bytes.get(at)? != 0xff {
return None;
}
let marker = *bytes.get(at + 1)?;
let length = u16::from_be_bytes([*bytes.get(at + 2)?, *bytes.get(at + 3)?]) as usize;
let frame = matches!(marker, 0xc0..=0xcf) && !matches!(marker, 0xc4 | 0xc8 | 0xcc);
if frame {
let h = u16::from_be_bytes([*bytes.get(at + 5)?, *bytes.get(at + 6)?]);
let w = u16::from_be_bytes([*bytes.get(at + 7)?, *bytes.get(at + 8)?]);
return Some((Image::Jpeg, u32::from(w), u32::from(h)));
}
if length < 2 {
return None;
}
at += 2 + length;
}
}
/// Checks an icon's bytes: a square PNG of [`ICON_SIDE`] pixels.
pub fn check_icon(bytes: &[u8]) -> Result<(), String> {
match image_size(bytes) {
Some((Image::Png, w, h)) if w == h && ICON_SIDE.contains(&w) => Ok(()),
Some((Image::Png, w, h)) => Err(format!(
"the icon is {w}×{h}: make it square, {} to {} pixels a side",
ICON_SIDE.start(),
ICON_SIDE.end()
)),
_ => Err("the icon must be a PNG".into()),
}
}
/// Checks a screenshot's bytes and its file name's extension: a PNG or a
/// JPEG within [`SCREENSHOT_MIN`] and [`SCREENSHOT_MAX`], at most
/// [`MAX_SCREENSHOT`] bytes.
pub fn check_screenshot(name: &str, bytes: &[u8]) -> Result<(), String> {
if bytes.len() as u64 > MAX_SCREENSHOT {
return Err(format!(
"{name}: {} KiB, at most {} KiB",
bytes.len() >> 10,
MAX_SCREENSHOT >> 10
));
}
let Some((format, w, h)) = image_size(bytes) else {
return Err(format!("{name}: not a PNG or a JPEG"));
};
let extension = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
if !extension.is_some_and(|e| format.extensions().contains(&e.as_str())) {
return Err(format!(
"{name}: its extension isn't .{}",
format.extensions()[0]
));
}
let ((min_w, min_h), (max_w, max_h)) = (SCREENSHOT_MIN, SCREENSHOT_MAX);
if w < min_w || h < min_h || w > max_w || h > max_h {
return Err(format!(
"{name}: {w}×{h} pixels, from {min_w}×{min_h} to {max_w}×{max_h}"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn categories_are_the_kinds_own() {
let ok = check(&Kind::Mod, "", &strings(&["magic", "mobs"]), &[]);
assert!(ok.is_empty(), "{ok:?}");
// A shader category on a mod, a repeat, and one too many.
let wrong = check(&Kind::Mod, "", &strings(&["bloom"]), &[]);
assert!(wrong[0].contains("not a category of a mod"), "{wrong:?}");
let twice = check(&Kind::Shader, "", &strings(&["pbr", "pbr"]), &[]);
assert_eq!(twice, ["category \"pbr\" is listed twice"]);
let many = check(
&Kind::Mod,
"",
&strings(&["magic", "mobs", "food", "storage"]),
&[],
);
assert_eq!(many, ["4 categories: pick at most 3"]);
assert!(categories(&Kind::Other("world".into())).is_empty());
}
#[test]
fn tags_are_plain_and_never_about_gambling() {
let ok = check(&Kind::Mod, "", &[], &strings(&["bosses", "1-21"]));
assert!(ok.is_empty(), "{ok:?}");
for bad in ["Bosses", "two words", "-x", "", &"a".repeat(25)] {
assert_eq!(
check(&Kind::Mod, "", &[], &[bad.to_owned()]).len(),
1,
"{bad:?}"
);
}
let casino = check(&Kind::Mod, "", &[], &strings(&["casino-games"]));
assert!(casino[0].contains("gambling"), "{casino:?}");
let eleven: Vec<String> = (0..11).map(|i| format!("t{i}")).collect();
assert_eq!(check(&Kind::Mod, "", &[], &eleven), ["11 tags: at most 10"]);
}
#[test]
fn a_description_has_a_ceiling() {
let long = "x".repeat(MAX_DESCRIPTION + 1);
assert_eq!(check(&Kind::Mod, &long, &[], &[]).len(), 1);
assert!(check(&Kind::Mod, &"x".repeat(MAX_DESCRIPTION), &[], &[]).is_empty());
}
fn png(w: u32, h: u32) -> Vec<u8> {
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
b.extend(w.to_be_bytes());
b.extend(h.to_be_bytes());
b.extend([8, 6, 0, 0, 0]);
b
}
fn jpeg(w: u16, h: u16) -> Vec<u8> {
// SOI, an APP0 segment of 16 bytes, then a baseline SOF0.
let mut b = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16];
b.extend([0; 14]);
b.extend([0xff, 0xc0, 0, 17, 8]);
b.extend(h.to_be_bytes());
b.extend(w.to_be_bytes());
b.extend([3; 12]);
b
}
#[test]
fn image_headers_give_their_size() {
assert_eq!(image_size(&png(256, 128)), Some((Image::Png, 256, 128)));
assert_eq!(
image_size(&jpeg(1920, 1080)),
Some((Image::Jpeg, 1920, 1080))
);
assert_eq!(image_size(b"GIF89a"), None);
assert_eq!(image_size(&[0xff, 0xd8, 0xff]), None);
}
#[test]
fn icons_are_square_pngs() {
assert!(check_icon(&png(256, 256)).is_ok());
assert!(check_icon(&png(256, 128)).unwrap_err().contains("square"));
assert!(check_icon(&png(32, 32)).is_err());
assert!(check_icon(&jpeg(256, 256)).unwrap_err().contains("PNG"));
}
#[test]
fn screenshots_are_bounded() {
assert!(check_screenshot("1.jpg", &jpeg(1920, 1080)).is_ok());
assert!(check_screenshot("1.png", &png(1280, 720)).is_ok());
assert!(
check_screenshot("1.png", &jpeg(1920, 1080))
.unwrap_err()
.contains(".jpg")
);
assert!(
check_screenshot("1.png", &png(100, 100))
.unwrap_err()
.contains("pixels")
);
assert!(check_screenshot("1.png", &png(7680, 4320)).is_err());
let mut big = png(1920, 1080);
big.resize(MAX_SCREENSHOT as usize + 1, 0);
assert!(check_screenshot("1.png", &big).unwrap_err().contains("KiB"));
}
}
+87 -28
View File
@@ -7,7 +7,9 @@
//! What an entry can't say for itself (a mod's API, side and capabilities,
//! a modpack's mods, a file's size) is read from the file, never typed.
pub use super::open;
use super::{File, Index, Kind, Project, Revoked, SCHEMA, Version, hex64, inspect, valid_id};
use super::{
File, Index, Kind, Project, Revoked, SCHEMA, Version, hex64, inspect, page as rules, valid_id,
};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
@@ -57,6 +59,16 @@ pub struct Entry {
/// `icons/<id>.png` in the registry repository, or an `https://` address.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 1 to 3 of the kind's categories ([`rules::categories`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub categories: Vec<String>,
/// Free-form, lowercase, at most 10.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// At most 8, in order: `screenshots/<id>/<name>.png|.jpg` in the registry repository, or
/// `https://` addresses.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub screenshots: Vec<String>,
/// The author's minisign public key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
@@ -197,6 +209,19 @@ impl Entry {
if self.summary.chars().count() > 160 {
errors.push(format!("{id}: the summary is over 160 characters"));
}
for e in rules::check(&self.kind, &self.description, &self.categories, &self.tags) {
errors.push(format!("{id}: {e}"));
}
if rules::REQUIRED && self.icon.is_none() {
errors.push(format!("{id}: has no icon (icons/{id}.png, a square PNG)"));
}
if self.screenshots.len() > rules::MAX_SCREENSHOTS {
errors.push(format!(
"{id}: {} screenshots, at most {}",
self.screenshots.len(),
rules::MAX_SCREENSHOTS
));
}
errors
}
}
@@ -336,33 +361,15 @@ pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
if !valid_id(id) || RESERVED.contains(&id.as_str()) {
return Err(errors);
}
let icon = match entry.icon.as_deref() {
None => None,
Some(url) if url.starts_with("https://") => Some(url.to_owned()),
Some(path) => {
let ok = path
.strip_prefix("icons/")
.is_some_and(|name| super::plain_name(name) && name.ends_with(".png"));
let size = std::fs::metadata(src.registry.join(path)).map(|m| m.len());
match (ok, size) {
(true, Ok(n)) if n <= MAX_ICON => Some(format!("{}{path}", src.site)),
(true, Ok(_)) => {
errors.push(format!("{id}: {path} is over {} KiB", MAX_ICON >> 10));
None
}
(true, Err(e)) => {
errors.push(format!("{id}: {path}: {e}"));
None
}
(false, _) => {
errors.push(format!(
"{id}: icon {path:?} is neither icons/<name>.png nor https://"
));
None
}
}
}
};
let icon = entry
.icon
.as_deref()
.and_then(|icon| image(src, id, icon, "icons/", &mut errors));
let screenshots: Vec<String> = entry
.screenshots
.iter()
.filter_map(|s| image(src, id, s, &format!("screenshots/{id}/"), &mut errors))
.collect();
if entry.versions.is_empty() && cache.versions.is_empty() {
errors.push(format!("{id}: has no version"));
}
@@ -394,12 +401,64 @@ pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
license: entry.license.clone(),
source: entry.source.clone(),
icon,
categories: entry.categories.clone(),
tags: entry.tags.clone(),
screenshots,
key: entry.key.as_ref().map(|k| k.trim().to_owned()),
versions,
downloads: 0,
})
}
/// An icon (`folder` = `icons/`) or a screenshot (`screenshots/<id>/`) as the index lists it: an
/// `https://` address as it is, or a file in the registry repository checked and served from the
/// registry's site.
fn image(
src: &Sources,
id: &str,
path: &str,
folder: &str,
errors: &mut Vec<String>,
) -> Option<String> {
if path.starts_with("https://") {
return Some(path.to_owned());
}
let icon = folder == "icons/";
let name = path.strip_prefix(folder).filter(|n| super::plain_name(n));
let Some(name) = name else {
errors.push(format!(
"{id}: {path:?} is neither {folder}<name> nor https://"
));
return None;
};
let max = if icon {
MAX_ICON
} else {
rules::MAX_SCREENSHOT
};
let checked = std::fs::metadata(src.registry.join(path))
.map_err(|e| format!("{path}: {e}"))
.and_then(|m| {
if m.len() > max {
return Err(format!("{path} is over {} KiB", max >> 10));
}
let bytes =
std::fs::read(src.registry.join(path)).map_err(|e| format!("{path}: {e}"))?;
if icon {
rules::check_icon(&bytes).map_err(|e| format!("{path}: {e}"))
} else {
rules::check_screenshot(name, &bytes)
}
});
match checked {
Ok(()) => Some(format!("{}{path}", src.site)),
Err(e) => {
errors.push(format!("{id}: {e}"));
None
}
}
}
/// The kind of a version's file at `path`: the project's, but a `.hmod` without native code of a
/// mod that turned native (`review`) is from before it did.
fn file_kind(entry: &Entry, v: &EntryVersion, path: &Path) -> Kind {
@@ -86,6 +86,15 @@ reason = "Sent chat messages it didn't declare."
date = "2026-10-24"
"#;
/// The header of a PNG of `w`×`h` pixels: what the registry reads of an image.
fn png(w: u32, h: u32) -> Vec<u8> {
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
b.extend(w.to_be_bytes());
b.extend(h.to_be_bytes());
b.extend([8, 6, 0, 0, 0]);
b
}
/// A registry checkout and its downloaded files, in a scratch folder.
struct Scratch(PathBuf);
@@ -102,7 +111,7 @@ impl Scratch {
s.write("mods/cozy.toml", COZY);
s.write("mods/glow.toml", GLOW);
s.write("revoked.toml", REVOKED_TOML);
s.write("icons/coords_hud.png", "png");
s.write_bytes("icons/coords_hud.png", &png(128, 128));
for (name, hash) in [
("coords_hud-0.1.0.hmod", HMOD),
("cozy-1.0.0.hpack", HPACK),
@@ -117,6 +126,12 @@ impl Scratch {
std::fs::write(self.0.join("repo").join(path), text).unwrap();
}
fn write_bytes(&self, path: &str, bytes: &[u8]) {
let path = self.0.join("repo").join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn build(&self) -> Result<Index, Vec<String>> {
let (repo, files) = (self.0.join("repo"), self.0.join("files"));
build(&Sources {
@@ -335,10 +350,50 @@ fn a_mod_may_turn_native_once() {
);
}
#[test]
fn page_fields_and_screenshots_reach_the_index() {
let s = Scratch::new("page");
let page = "categories = [\"utility\"]\ntags = [\"hud\", \"coordinates\"]\n\
screenshots = [\"screenshots/coords_hud/1.png\", \"https://x.test/2.jpg\"]\n";
s.write("mods/coords_hud.toml", &format!("{page}{}", hud_entry()));
s.write_bytes("screenshots/coords_hud/1.png", &png(1280, 720));
let built = s.build().unwrap();
let p = built.project("coords_hud").unwrap();
assert_eq!(p.categories, ["utility"]);
assert_eq!(p.tags, ["hud", "coordinates"]);
assert_eq!(
p.screenshots,
[
"https://horizon-rust.github.io/horizon-mods/screenshots/coords_hud/1.png",
"https://x.test/2.jpg"
]
);
Index::parse(&serde_json::to_string(&built).unwrap()).unwrap();
// A category of another kind, a screenshot too small, one in another project's folder, and an
// icon that isn't square.
let bad = "categories = [\"bloom\"]\n\
screenshots = [\"screenshots/coords_hud/1.png\", \"screenshots/other/2.png\"]\n";
s.write("mods/coords_hud.toml", &format!("{bad}{}", hud_entry()));
s.write_bytes("screenshots/coords_hud/1.png", &png(100, 100));
s.write_bytes("icons/coords_hud.png", &png(128, 64));
let errors = s.build().unwrap_err();
let has = |part: &str| errors.iter().any(|e| e.contains(part));
assert!(has("\"bloom\" is not a category of a mod"), "{errors:?}");
assert!(has("1.png: 100×100 pixels"), "{errors:?}");
assert!(
has("\"screenshots/other/2.png\" is neither screenshots/coords_hud/"),
"{errors:?}"
);
assert!(has("the icon is 128×64"), "{errors:?}");
assert_eq!(errors.len(), 4, "{errors:?}");
}
#[test]
fn only_open_kinds_are_taken() {
assert!(open(&Kind::Mod).is_ok() && open(&Kind::ResourcePack).is_ok());
assert!(open(&Kind::Shader).is_ok());
assert!(open(&Kind::Shader).is_ok() && open(&Kind::ShaderPack).is_ok());
assert!(open(&Kind::DataPack).is_ok());
assert!(open(&Kind::Other("world".into())).is_err());
let mut submit: Entry = toml::from_str(COZY).unwrap();
submit.id = "submit".into();
+56
View File
@@ -182,6 +182,62 @@ fn resource_packs_need_a_pack_mcmeta() {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn data_packs_and_shader_folder_packs_are_read_by_their_content() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("horizon-index-dp-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut n = 0;
let mut pack = |files: &[&str]| {
n += 1;
let path = dir.join(format!("{n}.zip"));
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
for name in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
let text = if *name == "pack.mcmeta" {
r#"{"pack": {"description": "D", "pack_format": 94}}"#
} else {
""
};
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
path
};
let data = pack(&["pack.mcmeta", "data/x/function/a.mcfunction"]);
assert_eq!(inspect(&Kind::DataPack, &data).unwrap().api, "94");
assert_eq!(zip_kind(&data), Kind::DataPack);
let looks = pack(&["pack.mcmeta", "assets/x/textures/a.png"]);
let e = inspect(&Kind::DataPack, &looks).unwrap_err();
assert!(e.contains("data/"), "{e}");
assert_eq!(zip_kind(&looks), Kind::ResourcePack);
let no_meta = pack(&["data/x/function/a.mcfunction"]);
assert!(
inspect(&Kind::DataPack, &no_meta)
.unwrap_err()
.contains("missing")
);
let shaders = pack(&["shaders/composite.fsh", "shaders/lang/en_us.lang"]);
assert_eq!(inspect(&Kind::ShaderPack, &shaders).unwrap().api, "");
assert_eq!(zip_kind(&shaders), Kind::ShaderPack);
let e = inspect(&Kind::ShaderPack, &looks).unwrap_err();
assert!(e.contains("shaders/"), "{e}");
let sneaky = pack(&["shaders/a.fsh", "../../x"]);
assert!(
inspect(&Kind::ShaderPack, &sneaky)
.unwrap_err()
.contains("plain path")
);
// Both are zips, and a reader from before them keeps them as unknown kinds.
for kind in [Kind::DataPack, Kind::ShaderPack] {
assert_eq!(kind.extension(), Some("zip"));
assert!(open(&kind).is_ok());
assert_eq!(Kind::from(kind.name().to_owned()), kind);
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_newest_version_this_horizon_runs_is_installed() {
let mut index = sample();
+25 -10
View File
@@ -65,6 +65,27 @@ pub struct Row {
/// The same in plain words for a player. Empty in a `coverage.json` an older build wrote.
#[serde(default)]
pub plain: String,
/// Unported dependencies, kept apart so the launcher can translate the fixed sentence.
#[serde(default)]
pub waiting: usize,
}
impl Row {
/// The standalone coverage report stays English, including the dependency count.
pub fn plain_text(&self) -> String {
let mut plain = self.plain.clone();
match self.waiting {
0 => {}
1 => plain.push_str(" It also waits for 1 mod it depends on that is not ported yet."),
n => {
let _ = write!(
plain,
" It also waits for {n} mods it depends on that are not ported yet."
);
}
}
plain
}
}
/// The licence family of an SPDX id or a `mods.toml` licence text.
@@ -282,6 +303,7 @@ fn resource_row(path: &str, outcome: Option<&Outcome>, record: Option<&Record>)
} else {
Status::NotYet
},
waiting: 0,
plain: match (shader, outcome.map(|o| o.state)) {
(true, _) => "Shader packs are not converted yet.",
(false, Some(State::Available)) => "Copied as a resource pack; Horizon has not checked how it renders. Turn it on in the game's Resource Packs screen.",
@@ -337,6 +359,7 @@ pub fn build(records: &[Record], work: &Path) -> Vec<Row> {
license_kind: license_kind(&r.license).to_owned(),
status,
reason,
waiting: 0,
plain: match status {
Status::NotYet => plain_not_yet(r),
Status::NotNeeded => plain_not_needed(&r.class),
@@ -360,15 +383,7 @@ pub fn build(records: &[Record], work: &Path) -> Vec<Row> {
.collect();
if !waiting.is_empty() {
let _ = write!(row.reason, "; waits on {}", waiting.join(", "));
let _ = write!(
row.plain,
" It also waits for {} it depends on that {} not ported yet.",
match waiting.len() {
1 => "1 mod".to_owned(),
n => format!("{n} mods"),
},
if waiting.len() == 1 { "is" } else { "are" }
);
row.waiting = waiting.len();
}
}
for (path, outcome) in &resourcepacks.files {
@@ -508,7 +523,7 @@ pub fn markdown(pack: &str, rows: &[Row]) -> String {
r.license.replace('|', "/"),
r.status.label(),
r.reason.replace('|', "/"),
r.plain.replace('|', "/")
r.plain_text().replace('|', "/")
);
}
out
+27 -2
View File
@@ -28,6 +28,7 @@ fn row(class: &str, tier: &str, status: Status) -> Row {
status,
reason: String::new(),
plain: String::new(),
waiting: 0,
}
}
@@ -156,7 +157,9 @@ fn every_reason_has_a_plain_sentence_and_the_technical_reason_is_kept() {
assert!(said("machine").contains("block entities, menus"));
assert!(said("deep").contains("mobs, custom rendering"));
assert!(
said("deep").ends_with("It also waits for 2 mods it depends on that are not ported yet."),
by["deep"]
.plain_text()
.ends_with("It also waits for 2 mods it depends on that are not ported yet."),
"{}",
said("deep")
);
@@ -164,7 +167,9 @@ fn every_reason_has_a_plain_sentence_and_the_technical_reason_is_kept() {
assert!(said("odd").contains("has not worked out"));
assert!(said("addon").starts_with("It joins other mods together"));
assert!(
said("addon").ends_with("waits for 1 mod it depends on that is not ported yet."),
by["addon"]
.plain_text()
.ends_with("waits for 1 mod it depends on that is not ported yet."),
"{}",
said("addon")
);
@@ -239,5 +244,25 @@ fn a_coverage_json_from_before_the_plain_column_still_reads() {
"license_kind":"permissive","status":"partial","reason":"harness not run"}"#;
let row: Row = serde_json::from_str(old).unwrap();
assert_eq!(row.plain, "");
assert_eq!(row.waiting, 0);
assert_eq!(row.reason, "harness not run");
}
#[test]
fn dependency_counts_are_separate_from_the_translatable_sentence() {
let records = [
record("base", "content", "machines", &[]),
record("addon", "integration", "logic", &["base"]),
];
let rows = build(&records, &std::env::temp_dir());
let addon = &rows[1];
assert_eq!(
addon.plain,
"It joins other mods together, so it waits until those are ported."
);
assert_eq!(serde_json::to_value(addon).unwrap()["waiting"], 1);
assert!(
markdown("test", &rows)
.contains("It also waits for 1 mod it depends on that is not ported yet.")
);
}
+145 -11
View File
@@ -20,6 +20,11 @@ use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
pub const SUMS: &str = "SHA256SUMS";
/// Its signatures' asset name.
pub const SIGNATURE: &str = "SHA256SUMS.sig";
/// A Nightly's second signature file: the same checksum list signed for the Nightly's own version
/// (`horizon release nightly-<date>-<commit>`) rather than for the channel, so an older Nightly can't
/// be served as a newer one. Nightlies carry both while launchers that only read [`SIGNATURE`] are
/// in use (docs/security.md, "Nightly signatures"); current launchers and servers require this one.
pub const SIGNATURE_V2: &str = "SHA256SUMS.v2.sig";
const KEYS: &str = include_str!("../release-keys.txt");
const LEGACY: &str = include_str!("../legacy-releases.txt");
@@ -124,9 +129,10 @@ pub fn is_nightly(tag: &str) -> bool {
tag == NIGHTLY || tag.starts_with("nightly-")
}
/// The version a nightly installs as, from its release title ("Nightly 2026-10-28 · 0c02067"):
/// The version a nightly is listed as, from its release title ("Nightly 2026-10-28 · 0c02067"):
/// `nightly-2026-10-28-0c02067`. `published` (GitHub's `published_at`) dates a title without one.
/// The launcher and `horizon-server` name it the same way, and native mods read the commit back.
/// For listing only: what installs is checked against [`nightly_title_version`], which has no
/// fallback, since nothing signs `published`.
pub fn nightly_version(title: &str, published: &str) -> String {
let mut words = title
.strip_prefix("Nightly ")
@@ -182,15 +188,61 @@ pub fn signed_digest(
digest_in(tag, tag, asset, sums, signature, keys)
}
/// The signed SHA-256 of `asset` in the nightly build: its checksums must be signed for the
/// [`NIGHTLY`] channel, so a release's signed list (or one replayed from a release) is refused.
/// The version a Nightly's GitHub title names, strictly: `Nightly 2026-10-28 · 0c02067` (as
/// nightly-build.yml writes it) gives `nightly-2026-10-28-0c02067`. Anything else is None: a
/// Nightly is verified against the version it installs as, so a title that doesn't name its date
/// and commit is refused rather than dated by when GitHub says it was published.
pub fn nightly_title_version(title: &str) -> Option<String> {
let (date, commit) = title.strip_prefix("Nightly ")?.split_once(" · ")?;
let commit_ok = (7..=40).contains(&commit.len())
&& commit
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
(is_date(date) && commit_ok).then(|| format!("{NIGHTLY}-{date}-{commit}"))
}
/// Whether `version` is a Nightly build's full version, `nightly-<date>-<commit>`, the form its
/// [`SIGNATURE_V2`] is signed for.
pub fn is_nightly_build(version: &str) -> bool {
version
.strip_prefix("nightly-")
.and_then(|rest| rest.split_at_checked(10))
.is_some_and(|(date, commit)| {
is_date(date)
&& commit.strip_prefix('-').is_some_and(|c| {
(7..=40).contains(&c.len())
&& c.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
})
}
/// The signed SHA-256 of `asset` in the Nightly that installs as `version`
/// (`nightly-<date>-<commit>`, from [`nightly_title_version`]): `signature` is its
/// [`SIGNATURE_V2`], which must sign `sums` for exactly that version. A release's list, the channel
/// signature (`nightly`) and another Nightly's are all refused, so an older Nightly can't be passed off
/// as tonight's.
pub fn nightly_digest(
version: &str,
asset: &str,
sums: Option<&[u8]>,
signature: Option<&str>,
keys: &[PublicKey],
) -> Result<String, String> {
digest_in(NIGHTLY, "The Nightly build", asset, sums, signature, keys)
if !is_nightly_build(version) {
return Err(format!(
"{version:?} doesn't name a Nightly's date and commit, so it can't be checked: not installed."
));
}
let Some(signature) = signature else {
return Err(format!(
"{version} has no {SIGNATURE_V2} (its signature for this exact build): it was published \
before Nightlies were signed for their own version, so it can't be checked and was not \
installed. Pick a Nightly published since then; `horizon-server rollback` still goes back \
to a build a server already has."
));
};
digest_in(version, version, asset, sums, Some(signature), keys)
}
/// `asset`'s SHA-256 in `sums`, once one of `keys` is found to have signed them for `signed_for`;
@@ -404,30 +456,112 @@ mod tests {
/// round: one key signs both, and only the message's channel keeps them apart.
#[test]
fn nightly_and_release_signatures_never_pass_for_each_other() {
let (nightly, sums, signature) = signed(NIGHTLY);
const TONIGHT: &str = "nightly-2026-10-28-0c02067";
let (nightly, sums, signature) = signed(TONIGHT);
let keys = [nightly.public()];
let asset = "horizon-windows-x64.zip";
let digest = nightly_digest(asset, Some(sums.as_bytes()), Some(&signature), &keys);
let digest = nightly_digest(
TONIGHT,
asset,
Some(sums.as_bytes()),
Some(&signature),
&keys,
);
assert_eq!(digest, Ok(sha256_hex(b"windows")));
// Served under a release's tag: the message names the nightly channel, not v0.2.81.
// Served under a release's tag: the message names the Nightly, not v0.2.81.
for tag in ["v0.2.81", "v2026.10.28", "0.2.81"] {
let as_release =
signed_digest(tag, asset, Some(sums.as_bytes()), Some(&signature), &keys);
assert!(as_release.is_err(), "{tag} took a nightly's signature");
}
// Asked for by a nightly tag on the release channel: refused before any signature is read.
for tag in [NIGHTLY, "nightly-2026-10-27"] {
for tag in [NIGHTLY, "nightly-2026-10-27", TONIGHT] {
let err = signed_digest(tag, asset, Some(sums.as_bytes()), Some(&signature), &keys);
assert!(err.unwrap_err().contains("Nightly build"), "{tag}");
}
// A release's signed list replayed as tonight's nightly.
let (release, sums, signature) = signed("v0.2.81");
let keys = [release.public()];
assert!(nightly_digest(asset, Some(sums.as_bytes()), Some(&signature), &keys).is_err());
assert!(nightly_digest(asset, Some(sums.as_bytes()), None, &keys).is_err());
assert!(
nightly_digest(
TONIGHT,
asset,
Some(sums.as_bytes()),
Some(&signature),
&keys
)
.is_err()
);
assert!(nightly_digest(TONIGHT, asset, Some(sums.as_bytes()), None, &keys).is_err());
assert!(is_nightly("nightly-2026-10-27") && !is_nightly("v0.2.81"));
}
#[test]
fn an_older_nightly_is_not_tonights() {
let asset = "horizon-windows-x64.zip";
// Last week's build, re-served under tonight's title: its v2 signature names last week.
let (key, sums, old) = signed("nightly-2026-10-21-aaaaaaa");
let keys = [key.public()];
let tonight = nightly_title_version("Nightly 2026-10-28 · 0c02067").unwrap();
let err = nightly_digest(&tonight, asset, Some(sums.as_bytes()), Some(&old), &keys);
assert!(err.is_err(), "an older Nightly passed as {tonight}");
// The channel signature (SHA256SUMS.sig, for launchers from before v2) is no v2 signature.
let (key, sums, channel) = signed(NIGHTLY);
let keys = [key.public()];
assert!(
nightly_digest(
&tonight,
asset,
Some(sums.as_bytes()),
Some(&channel),
&keys
)
.is_err()
);
// And a version that isn't a full Nightly build is refused whatever signs it.
for version in [NIGHTLY, "nightly-2026-10-28", "v0.2.81"] {
let (key, sums, signature) = signed(version);
let err = nightly_digest(
version,
asset,
Some(sums.as_bytes()),
Some(&signature),
&[key.public()],
);
assert!(err.unwrap_err().contains("doesn't name"), "{version}");
}
}
#[test]
fn a_nightlys_title_is_parsed_strictly() {
assert_eq!(
nightly_title_version("Nightly 2026-10-28 · 0c02067").as_deref(),
Some("nightly-2026-10-28-0c02067")
);
assert_eq!(
nightly_title_version(&format!("Nightly 2026-10-28 · {}", "a".repeat(40))),
Some(format!("nightly-2026-10-28-{}", "a".repeat(40)))
);
for title in [
"Something else",
"Nightly 2026-10-28",
"Nightly 2026-10-28 · ",
"Nightly 2026-10-28 · 0c020", // too short
"Nightly 2026-10-28 · 0C02067", // GitHub's short SHAs are lowercase
"Nightly 2026-10-28 · 0c02067 x", // trailing words
"Nightly 2026-1-28 · 0c02067", // not a date
"nightly 2026-10-28 · 0c02067",
" Nightly 2026-10-28 · 0c02067",
"Nightly 2026-10-28 · 0c02067/../x",
] {
assert_eq!(nightly_title_version(title), None, "{title:?}");
}
assert!(is_nightly_build("nightly-2026-10-28-0c02067"));
assert!(!is_nightly_build("nightly-2026-10-28"));
assert!(!is_nightly_build("nightly-2026-10-28-"));
assert!(!is_nightly_build("nightly-2026-10-28-0c02067-x"));
}
#[test]
fn a_nightly_is_named_by_its_date_and_commit() {
let v = nightly_version("Nightly 2026-10-28 · 0c02067", "2026-10-28T20:40:00Z");
+17 -6
View File
@@ -3,10 +3,11 @@
//! job that holds the private key runs only the runner's sha256sum and OpenSSL, no code from the
//! repository.
//!
//! horizon-release-sign verify --tag v2026.10.19 [--key <base64>] [--publishing] <folder>
//! horizon-release-sign verify --tag v2026.10.19 [--key <base64>] [--signature <file>] [--publishing] <folder>
//! horizon-release-sign keygen --public-out key.pub | gh secret set RELEASE_SIGNING_KEY --env release
//!
//! `verify` checks SHA256SUMS.sig and every file SHA256SUMS lists with the keys built in from
//! `verify` checks SHA256SUMS.sig (or `--signature`, e.g. a Nightly's SHA256SUMS.v2.sig with
//! `--tag nightly-<date>-<commit>`) and every file SHA256SUMS lists with the keys built in from
//! release-keys.txt (or `--key`), as the launcher does. With `--publishing` (release.yml) a release
//! may go out unsigned, or signed with a key nobody can check yet, only while release-keys.txt
//! lists no key; once it lists one, anything launchers would refuse fails the release.
@@ -22,8 +23,8 @@ fn main() -> ExitCode {
Some("verify") => check_folder(&args[1..]),
Some("keygen") => keygen(&args[1..]),
_ => Err(
"usage: horizon-release-sign verify --tag <tag> [--key <base64>]... [--publishing] \
<folder> | keygen --public-out <file>"
"usage: horizon-release-sign verify --tag <tag> [--key <base64>]... [--signature <file>] \
[--publishing] <folder> | keygen --public-out <file>"
.into(),
),
};
@@ -76,6 +77,16 @@ fn file_hash(path: &Path) -> Result<String, String> {
fn check_folder(args: &[String]) -> Result<(), String> {
let (tag, rest) = tag_of(args)?;
let (keys, rest) = options(&rest, "--key");
let (signature_file, rest) = options(&rest, "--signature");
let signature_file = signature_file
.into_iter()
.next()
.unwrap_or_else(|| SIGNATURE.to_owned());
if signature_file.contains(['/', '\\']) {
return Err(format!(
"--signature names a file of the folder, not {signature_file:?}"
));
}
let publishing = rest.iter().any(|a| a == "--publishing");
let folder = PathBuf::from(
rest.iter()
@@ -88,7 +99,7 @@ fn check_folder(args: &[String]) -> Result<(), String> {
parse_keys(&keys.join("\n"))?
};
let sums = std::fs::read(folder.join(SUMS)).map_err(|e| format!("{SUMS}: {e}"))?;
let listed = match std::fs::read_to_string(folder.join(SIGNATURE)) {
let listed = match std::fs::read_to_string(folder.join(&signature_file)) {
Ok(signature) if !keys.is_empty() => verify(&tag, &sums, &signature, &keys)?,
Ok(_) if publishing => {
println!(
@@ -105,7 +116,7 @@ fn check_folder(args: &[String]) -> Result<(), String> {
Ok(_) => return Err("no key to check the signature with".into()),
Err(_) => {
return Err(format!(
"{tag} has no {SIGNATURE}: launchers would refuse it (is the release environment's \
"{tag} has no {signature_file}: launchers would refuse it (is the release environment's \
RELEASE_SIGNING_KEY set?)"
));
}
+73 -2
View File
@@ -1,5 +1,5 @@
//! The release steps as release.yml runs them: `sums`, tools/ci/sign_release.sh (OpenSSL), `verify`.
use horizon_release_sign::{SIGNATURE, SUMS, SigningKey, trusted_keys};
use horizon_release_sign::{SIGNATURE, SIGNATURE_V2, SUMS, SigningKey, trusted_keys};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
@@ -60,10 +60,14 @@ fn openssl() -> Option<String> {
}
fn sign_script(dir: &Path, env: &[(&str, &str)]) -> Output {
sign_script_for(TAG, dir, env)
}
fn sign_script_for(tag: &str, dir: &Path, env: &[(&str, &str)]) -> Output {
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tools/ci/sign_release.sh");
let mut cmd = Command::new("bash");
cmd.arg(script)
.arg(TAG)
.arg(tag)
.arg(dir.join("horizon-linux-x86_64.tar.gz"))
.arg(dir.join("horizon-windows-x64.zip"))
.env_remove("RELEASE_SIGNING_KEY")
@@ -182,3 +186,70 @@ fn keygen_writes_the_secret_to_a_pipe_and_the_public_key_to_a_file() {
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_nightly_is_signed_for_its_channel_and_for_its_version() {
if cfg!(windows) || openssl().is_none() {
eprintln!("skipped: tools/ci/sign_release.sh is tested on Linux (bash, OpenSSL 3)");
return;
}
const VERSION: &str = "nightly-2026-10-28-0c02067";
let dir = folder("nightly");
std::fs::remove_file(dir.join(SUMS)).unwrap();
let (key, secret) = SigningKey::generate().unwrap();
let out = sign_script_for(
"nightly",
&dir,
&[("RELEASE_SIGNING_KEY", &secret), ("ALSO_FOR", VERSION)],
);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let public = key.public_base64();
let check = |tag: &str, signature: &str| {
tool(
&[
"verify",
"--tag",
tag,
"--key",
&public,
"--signature",
signature,
],
&[&dir],
)
.status
.success()
};
// Launchers from before v2 check the channel's; current ones, the version's.
assert!(check("nightly", SIGNATURE));
assert!(check(VERSION, SIGNATURE_V2));
assert!(!check(VERSION, SIGNATURE));
assert!(!check("nightly", SIGNATURE_V2));
assert!(!check("nightly-2026-10-27-0c02067", SIGNATURE_V2));
// What a current launcher does with it.
let sums = std::fs::read(dir.join(SUMS)).unwrap();
let v2 = std::fs::read_to_string(dir.join(SIGNATURE_V2)).unwrap();
let digest = horizon_release_sign::nightly_digest(
VERSION,
"horizon-windows-x64.zip",
Some(&sums),
Some(&v2),
&[key.public()],
);
assert_eq!(
digest,
Ok(horizon_release_sign::sha256_hex(b"windows build"))
);
// A bad ALSO_FOR is refused before anything is signed.
let out = sign_script_for(
"nightly",
&dir,
&[("RELEASE_SIGNING_KEY", &secret), ("ALSO_FOR", "v0.2.81")],
);
assert!(!out.status.success());
std::fs::remove_dir_all(dir).unwrap();
}
+6 -1
View File
@@ -247,7 +247,7 @@ pub fn players(dir: &Path, as_json: bool) -> Result<(), String> {
}
/// `kick NAME [REASON]`, `ban NAME [REASON]`, `pardon NAME`, `ban-ip ADDRESS|NAME [REASON]`, `pardon-ip
/// ADDRESS`: the vanilla command on the running server, through the control API.
/// ADDRESS`: the vanilla command on a running server, or a file-backed edit while it is stopped.
pub fn moderate(dir: &Path, command: &str, words: &[String]) -> Result<(), String> {
let Some(target) = words.first() else {
return Err(format!(
@@ -259,6 +259,11 @@ pub fn moderate(dir: &Path, command: &str, words: &[String]) -> Result<(), Strin
}
));
};
// File-backed moderation uses the same stopped-server path as `op` and `whitelist`; while a
// supervisor is running, lists::run falls through to the game's vanilla console command.
if command != "kick" {
return crate::lists::run(dir, command, words);
}
let mut body = json!({});
let key = if target.parse::<std::net::IpAddr>().is_ok() {
"ip"
+15
View File
@@ -159,4 +159,19 @@ mod tests {
"Horizon server 0.1.0 · restarting"
);
}
#[test]
fn console_requires_a_running_server() {
let dir = std::env::temp_dir().join(format!(
"horizon-server-console-stopped-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(
run(&dir).unwrap_err(),
format!("no server is running in {}", dir.display())
);
std::fs::remove_dir_all(&dir).unwrap();
}
}
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more