mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
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:
commit
8bbedb4d4d
255 files changed
+10942
-989
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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, ¯os, &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, ¯os, &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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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, ®istry(), &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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?))
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<'_, '_, '_> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+3
-44
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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![]),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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?)"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in new issue
Block a user