mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Merge main into #9841 (lan_actions imports)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
c4b85eaed1
227 files changed
+9175
-787
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -12332,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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,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 {
|
||||
@@ -1300,6 +1303,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,
|
||||
@@ -5490,3 +5523,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,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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -390,28 +390,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 {
|
||||
@@ -695,11 +710,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]),
|
||||
@@ -1248,6 +1259,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3113,6 +3113,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 +3145,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 +3162,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));
|
||||
|
||||
@@ -245,6 +245,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;
|
||||
@@ -445,6 +446,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};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
@@ -772,8 +770,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 +822,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 +862,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);
|
||||
@@ -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(
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::{
|
||||
Dim, Game, GuestUse, Hit, InteractTarget, Mining, Screen,
|
||||
building_players::{BuildingPlayer, BuildingPlayers},
|
||||
lan::LanHost,
|
||||
lan_crafting::MenuRemoval,
|
||||
respawn_anchor::RespawnConfig,
|
||||
};
|
||||
use horizon_client::{
|
||||
@@ -1489,6 +1490,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 {
|
||||
@@ -1517,6 +1533,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
|
||||
@@ -1541,9 +1615,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()
|
||||
@@ -1558,10 +1648,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -3045,6 +3045,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")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `op`, `deop` and `whitelist`. While the server runs, the game owns the lists: the command goes to its
|
||||
//! console. While it is stopped, they edit vanilla's files with the code the game reads them with
|
||||
//! (horizon-server-files) and answer with vanilla's console messages.
|
||||
//! `op`, `deop`, `whitelist`, `ban`, `pardon`, `ban-ip` and `pardon-ip`. While the server runs, the game
|
||||
//! owns the lists: the command goes to its console. While it is stopped, they edit vanilla's files with the
|
||||
//! code the game reads them with (horizon-server-files) and answer with vanilla's console messages.
|
||||
//!
|
||||
//! One exception: `whitelist add` on a running offline server. The game asks Mojang for the name first, as
|
||||
//! vanilla does, so a name someone owns gets that account's UUID, and the player, who joins under the
|
||||
@@ -8,7 +8,9 @@
|
||||
//! reloads it (`whitelist reload`), as it would be written while the server is stopped.
|
||||
|
||||
use crate::{config, supervisor};
|
||||
use horizon_server_files::user_lists::{NameAndId, OpEntry, OpList, WhiteEntry, WhiteList};
|
||||
use horizon_server_files::user_lists::{
|
||||
BanEntry, BanList, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry, WhiteList,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn run(dir: &Path, command: &str, args: &[String]) -> Result<(), String> {
|
||||
@@ -135,15 +137,90 @@ fn edit_locked(
|
||||
)
|
||||
}
|
||||
}
|
||||
("ban", [name, rest @ ..]) => {
|
||||
let user = account(name, config.server.online_mode)?;
|
||||
let reason = reason(rest);
|
||||
let mut bans = load(BanList::new(dir.join("banned-players.json")))?;
|
||||
if bans.add(BanEntry::new(
|
||||
user,
|
||||
Some("Server".into()),
|
||||
None,
|
||||
reason.clone(),
|
||||
)) {
|
||||
(
|
||||
true,
|
||||
format!(
|
||||
"Banned {name}: {}",
|
||||
reason.as_deref().unwrap_or("Banned by an operator.")
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
false,
|
||||
"Nothing changed. The player is already banned".to_owned(),
|
||||
)
|
||||
}
|
||||
}
|
||||
("pardon", [name]) => {
|
||||
let mut bans = load(BanList::new(dir.join("banned-players.json")))?;
|
||||
let user = listed(bans.entries().iter().map(|e| &e.user), name);
|
||||
if user.as_ref().is_some_and(|user| bans.remove(user)) {
|
||||
(true, format!("Unbanned {name}"))
|
||||
} else {
|
||||
(false, "Nothing changed. The player isn't banned".to_owned())
|
||||
}
|
||||
}
|
||||
("ban-ip", [ip, rest @ ..]) => {
|
||||
if ip.parse::<std::net::IpAddr>().is_err() {
|
||||
return Err("Invalid IP address or unknown player".into());
|
||||
}
|
||||
let reason = reason(rest);
|
||||
let mut bans = load(IpBanList::new(dir.join("banned-ips.json")))?;
|
||||
if bans.add(BanEntry::new(
|
||||
(*ip).to_owned(),
|
||||
Some("Server".into()),
|
||||
None,
|
||||
reason.clone(),
|
||||
)) {
|
||||
(
|
||||
true,
|
||||
format!(
|
||||
"Banned IP {ip}: {}",
|
||||
reason.as_deref().unwrap_or("Banned by an operator.")
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
false,
|
||||
"Nothing changed. That IP is already banned".to_owned(),
|
||||
)
|
||||
}
|
||||
}
|
||||
("pardon-ip", [ip]) => {
|
||||
if ip.parse::<std::net::IpAddr>().is_err() {
|
||||
return Err("Invalid IP address".into());
|
||||
}
|
||||
let mut bans = load(IpBanList::new(dir.join("banned-ips.json")))?;
|
||||
if bans.remove(&(*ip).to_owned()) {
|
||||
(true, format!("Unbanned IP {ip}"))
|
||||
} else {
|
||||
(false, "Nothing changed. That IP isn't banned".to_owned())
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"use: op NAME, deop NAME, whitelist on|off|list, whitelist add|remove NAME".into(),
|
||||
"use: op NAME, deop NAME, whitelist on|off|list, whitelist add|remove NAME, ban NAME [REASON], pardon NAME, ban-ip ADDRESS [REASON], pardon-ip ADDRESS".into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
Ok(Edit { changed, message })
|
||||
}
|
||||
|
||||
/// The optional reason after a moderation target is the rest of the command line as one sentence.
|
||||
fn reason(words: &[&str]) -> Option<String> {
|
||||
(!words.is_empty()).then(|| words.join(" "))
|
||||
}
|
||||
|
||||
/// `whitelist add` on a running offline server: the offline entry, then the game reloads the list.
|
||||
fn add_offline(dir: &Path, name: &str) -> Result<(), String> {
|
||||
if crate::api::Client::open(dir)?.is_none() {
|
||||
@@ -244,4 +321,59 @@ mod tests {
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_moderation_replies_match_goldens() {
|
||||
let golden: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../testdata/server-admin-cli.json")).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-server-stopped-moderation-goldens-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(config::FILE), config::TEMPLATE).unwrap();
|
||||
config::update(&dir, "server", "online_mode", toml::Value::Boolean(false)).unwrap();
|
||||
|
||||
let check = |case: &str, command: &str, args: &[&str]| {
|
||||
let edit = edit_stopped(&dir, command, args).unwrap();
|
||||
let actual = serde_json::json!({
|
||||
"changed": edit.changed,
|
||||
"output": [edit.message],
|
||||
});
|
||||
assert_eq!(actual, golden[case], "{case}");
|
||||
};
|
||||
|
||||
check("ban", "ban", &["Alex", "griefing"]);
|
||||
let mut players = BanList::new(dir.join("banned-players.json"));
|
||||
players.load().unwrap();
|
||||
assert_eq!(players.entries().len(), 1);
|
||||
assert_eq!(players.entries()[0].user.name, "Alex");
|
||||
assert_eq!(players.entries()[0].source, "Server");
|
||||
assert_eq!(players.entries()[0].reason.as_deref(), Some("griefing"));
|
||||
check("ban_again", "ban", &["Alex", "griefing"]);
|
||||
check("pardon", "pardon", &["Alex"]);
|
||||
check("pardon_again", "pardon", &["Alex"]);
|
||||
check("ban_default", "ban", &["Alex"]);
|
||||
|
||||
check("ban_ip", "ban-ip", &["203.0.113.7", "abuse"]);
|
||||
let mut addresses = IpBanList::new(dir.join("banned-ips.json"));
|
||||
addresses.load().unwrap();
|
||||
assert_eq!(addresses.entries().len(), 1);
|
||||
assert_eq!(addresses.entries()[0].user, "203.0.113.7");
|
||||
assert_eq!(addresses.entries()[0].reason.as_deref(), Some("abuse"));
|
||||
check("ban_ip_again", "ban-ip", &["203.0.113.7", "abuse"]);
|
||||
check("pardon_ip", "pardon-ip", &["203.0.113.7"]);
|
||||
check("pardon_ip_again", "pardon-ip", &["203.0.113.7"]);
|
||||
for (command, message) in [
|
||||
("ban-ip", "Invalid IP address or unknown player"),
|
||||
("pardon-ip", "Invalid IP address"),
|
||||
] {
|
||||
match edit_stopped(&dir, command, &["not-an-ip"]) {
|
||||
Err(error) => assert_eq!(error, message),
|
||||
Ok(_) => panic!("{command} accepted an invalid address"),
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ Usage: horizon-server [--dir DIR] <command>
|
||||
players [--json]
|
||||
Who is online, the operators, the whitelist and the bans
|
||||
kick NAME [REASON], ban NAME [REASON], pardon NAME, ban-ip ADDRESS|NAME [REASON], pardon-ip ADDRESS
|
||||
Moderate the running server (vanilla's commands, as from its console)
|
||||
Moderate the running server, or edit ban files while it is stopped
|
||||
|
||||
--dir DIR The server folder (default: the current folder)
|
||||
|
||||
|
||||
@@ -341,15 +341,18 @@ fn pick_for_assets(
|
||||
}
|
||||
};
|
||||
let sums = attached(horizon_release_sign::SUMS)?;
|
||||
let signature = attached(horizon_release_sign::SIGNATURE)?
|
||||
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
|
||||
// A Nightly is signed for its channel, never for a tag, and installs under its date and commit.
|
||||
let text = |bytes: Vec<u8>| String::from_utf8_lossy(&bytes).into_owned();
|
||||
// A Nightly installs under its date and commit, which its title names (strictly: nothing signs
|
||||
// GitHub's publication time), and its SHA256SUMS.v2.sig must sign the checksums for exactly that
|
||||
// version, so an older Nightly can't be served as a newer one.
|
||||
let (tag, sha256) = if horizon_release_sign::is_nightly(tag) {
|
||||
let version = horizon_release_sign::nightly_version(
|
||||
release["name"].as_str().unwrap_or_default(),
|
||||
release["published_at"].as_str().unwrap_or_default(),
|
||||
);
|
||||
let title = release["name"].as_str().unwrap_or_default();
|
||||
let version = horizon_release_sign::nightly_title_version(title).ok_or_else(|| {
|
||||
format!("the Nightly's title {title:?} doesn't name its date and commit, so it can't be checked")
|
||||
})?;
|
||||
let signature = attached(horizon_release_sign::SIGNATURE_V2)?.map(text);
|
||||
let sha256 = horizon_release_sign::nightly_digest(
|
||||
&version,
|
||||
name,
|
||||
sums.as_deref(),
|
||||
signature.as_deref(),
|
||||
@@ -357,6 +360,7 @@ fn pick_for_assets(
|
||||
)?;
|
||||
(version, sha256)
|
||||
} else {
|
||||
let signature = attached(horizon_release_sign::SIGNATURE)?.map(text);
|
||||
let sha256 = horizon_release_sign::signed_digest(
|
||||
tag,
|
||||
name,
|
||||
@@ -684,6 +688,13 @@ pub fn command(dir: &Path, args: &[String]) -> Result<(), String> {
|
||||
println!("Already on the newest release ({current}).");
|
||||
return Ok(());
|
||||
}
|
||||
if newer(¤t, &release.tag) {
|
||||
// Asked for by --version: a rollback is allowed, never done without saying so.
|
||||
println!(
|
||||
"{} is older than {current}, which this server runs: installing it as a rollback.",
|
||||
release.tag
|
||||
);
|
||||
}
|
||||
println!("Downloading Horizon {} ({})...", release.tag, release.asset);
|
||||
let archive = download(&install, &release)?;
|
||||
(release.tag, archive, true)
|
||||
@@ -967,36 +978,56 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nightly_is_picked_with_its_channel_signature_and_named_by_its_build() {
|
||||
fn a_nightly_is_picked_with_its_version_signature_and_named_by_its_build() {
|
||||
use horizon_release_sign::{SigningKey, sha256_hex, sums_file};
|
||||
let Some(first) = ASSETS.first() else { return };
|
||||
let asset = |name: &str| json!({ "name": name, "browser_download_url": format!("https://example.invalid/{name}") });
|
||||
let (key, _) = SigningKey::generate().unwrap();
|
||||
let sums = sums_file(&[(first.to_string(), sha256_hex(b"server"))]);
|
||||
let serving = |signed_for: &str| {
|
||||
let (sums, signature) = (sums.clone(), key.sign(signed_for, sums.as_bytes()));
|
||||
// SHA256SUMS.sig signed for `channel`, SHA256SUMS.v2.sig for `v2`.
|
||||
let serving = |channel: &str, v2: &str| {
|
||||
let sums = sums.clone();
|
||||
let (one, two) = (
|
||||
key.sign(channel, sums.as_bytes()),
|
||||
key.sign(v2, sums.as_bytes()),
|
||||
);
|
||||
move |url: &str| -> Result<Vec<u8>, String> {
|
||||
match url.rsplit('/').next() {
|
||||
Some("SHA256SUMS") => Ok(sums.clone().into_bytes()),
|
||||
Some("SHA256SUMS.sig") => Ok(signature.clone().into_bytes()),
|
||||
Some("SHA256SUMS.sig") => Ok(one.clone().into_bytes()),
|
||||
Some("SHA256SUMS.v2.sig") => Ok(two.clone().into_bytes()),
|
||||
_ => Err("404".into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
const TONIGHT: &str = "nightly-2026-10-28-0c02067";
|
||||
let nightly = json!({
|
||||
"tag_name": "nightly", "name": "Nightly 2026-10-28 · 0c02067",
|
||||
"published_at": "2026-10-28T20:40:00Z",
|
||||
"assets": [asset(first), asset("SHA256SUMS"), asset("SHA256SUMS.sig")],
|
||||
"assets": [asset(first), asset("SHA256SUMS"), asset("SHA256SUMS.sig"), asset("SHA256SUMS.v2.sig")],
|
||||
});
|
||||
let picked = pick(&nightly, serving("nightly"), &[key.public()]).unwrap();
|
||||
assert_eq!(picked.tag, "nightly-2026-10-28-0c02067");
|
||||
let picked = pick(&nightly, serving("nightly", TONIGHT), &[key.public()]).unwrap();
|
||||
assert_eq!(picked.tag, TONIGHT);
|
||||
assert_eq!(picked.sha256, sha256_hex(b"server"));
|
||||
// An older Nightly's files under tonight's title: refused.
|
||||
let old = serving("nightly", "nightly-2026-10-21-aaaaaaa");
|
||||
assert!(pick(&nightly, old, &[key.public()]).is_err());
|
||||
// Only the channel's signature (a Nightly from before v2): refused.
|
||||
let mut v1_only = nightly.clone();
|
||||
v1_only["assets"] = json!([asset(first), asset("SHA256SUMS"), asset("SHA256SUMS.sig")]);
|
||||
let err = pick(&v1_only, serving("nightly", TONIGHT), &[key.public()]).unwrap_err();
|
||||
assert!(err.contains("SHA256SUMS.v2.sig"), "{err}");
|
||||
// A title that doesn't name its date and commit: refused, never dated by `published_at`.
|
||||
let mut untitled = nightly.clone();
|
||||
untitled["name"] = json!("Nightly");
|
||||
let err = pick(&untitled, serving("nightly", TONIGHT), &[key.public()]).unwrap_err();
|
||||
assert!(err.contains("doesn't name"), "{err}");
|
||||
// A release's signed list served as the Nightly: refused.
|
||||
assert!(pick(&nightly, serving("v0.2.95"), &[key.public()]).is_err());
|
||||
assert!(pick(&nightly, serving("v0.2.95", "v0.2.95"), &[key.public()]).is_err());
|
||||
// A Nightly's signed list served as a release: refused.
|
||||
let mut release = nightly.clone();
|
||||
release["tag_name"] = json!("v0.2.95");
|
||||
assert!(pick(&release, serving("nightly"), &[key.public()]).is_err());
|
||||
assert!(pick(&release, serving(TONIGHT, TONIGHT), &[key.public()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"ban": {"changed": true, "output": ["Banned Alex: griefing"]},
|
||||
"ban_again": {"changed": false, "output": ["Nothing changed. The player is already banned"]},
|
||||
"pardon": {"changed": true, "output": ["Unbanned Alex"]},
|
||||
"pardon_again": {"changed": false, "output": ["Nothing changed. The player isn't banned"]},
|
||||
"ban_default": {"changed": true, "output": ["Banned Alex: Banned by an operator."]},
|
||||
"ban_ip": {"changed": true, "output": ["Banned IP 203.0.113.7: abuse"]},
|
||||
"ban_ip_again": {"changed": false, "output": ["Nothing changed. That IP is already banned"]},
|
||||
"pardon_ip": {"changed": true, "output": ["Unbanned IP 203.0.113.7"]},
|
||||
"pardon_ip_again": {"changed": false, "output": ["Nothing changed. That IP isn't banned"]}
|
||||
}
|
||||
@@ -1236,6 +1236,12 @@ impl Loader<'_> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
notes.extend(
|
||||
program
|
||||
.notes
|
||||
.iter()
|
||||
.map(|e| format!("{}:{}: {}", e.file, e.line, e.message)),
|
||||
);
|
||||
self.post.config.read_vertex(&vertex);
|
||||
self.post.config.read(&fragment);
|
||||
let mut buffers =
|
||||
@@ -1384,6 +1390,12 @@ impl Loader<'_> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
notes.extend(
|
||||
program
|
||||
.notes
|
||||
.iter()
|
||||
.map(|e| format!("{}:{}: {}", e.file, e.line, e.message)),
|
||||
);
|
||||
self.post.config.read_vertex(vertex);
|
||||
self.post.config.read(fragment);
|
||||
let mut buffers =
|
||||
@@ -1449,7 +1461,15 @@ impl Loader<'_> {
|
||||
continue;
|
||||
}
|
||||
match translate_compute(&pp) {
|
||||
Ok(program) => out.push((name, program)),
|
||||
Ok(program) => {
|
||||
notes.extend(
|
||||
program
|
||||
.notes
|
||||
.iter()
|
||||
.map(|e| format!("{}:{}: {}", e.file, e.line, e.message)),
|
||||
);
|
||||
out.push((name, program));
|
||||
}
|
||||
Err(errors) => {
|
||||
notes.extend(
|
||||
errors
|
||||
@@ -1613,6 +1633,46 @@ fn flips(properties: &Properties, program: &str) -> Vec<(u32, bool)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unsupported_defaults_are_notes_without_skipping_render_or_compute() {
|
||||
let pack = Pack::from_zip_bytes(
|
||||
"original-default-control",
|
||||
crate::pack::tests::zip(&[
|
||||
("shaders/composite.vsh", "#version 430\nvoid main() { gl_Position = vec4(0.0); }"),
|
||||
("shaders/composite.fsh", "#version 430\nuniform double unknown = double(2.0);\nout vec4 color; void main() { color = vec4(1.0); }"),
|
||||
("shaders/composite.csh", "#version 430\nlayout(local_size_x=1) in; uniform double unknown = double(2.0); void main() {}"),
|
||||
]),
|
||||
).unwrap();
|
||||
let post = load_post(
|
||||
&pack,
|
||||
"",
|
||||
&Properties::default(),
|
||||
&Macros::standard(),
|
||||
&GameInputs::default(),
|
||||
);
|
||||
let program = post
|
||||
.programs
|
||||
.iter()
|
||||
.find(|p| p.name == "composite")
|
||||
.unwrap();
|
||||
assert!(program.program.is_some());
|
||||
assert_eq!(program.compute.len(), 1);
|
||||
for file in ["composite.fsh", "composite.csh"] {
|
||||
assert!(
|
||||
post.notes
|
||||
.iter()
|
||||
.any(|n| n.contains(file) && n.contains("unknown") && n.contains("using zero")),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!post.notes.iter().any(|n| n.contains("skipped")),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_dimension_folder_reaches_the_program_loader() {
|
||||
let pack = Pack::from_zip_bytes(
|
||||
|
||||
@@ -8,7 +8,8 @@ use super::{Error, scan::Stage};
|
||||
|
||||
pub(super) fn merge<'a>(
|
||||
stages: impl IntoIterator<Item = (&'a Stage<'a>, &'a Module)>,
|
||||
) -> Result<Vec<u8>, Vec<Error>> {
|
||||
) -> Result<(Vec<u8>, Vec<Error>), Vec<Error>> {
|
||||
let mut notes = Vec::new();
|
||||
let mut result = Vec::new();
|
||||
let mut seen = BTreeMap::<String, Vec<u8>>::new();
|
||||
for (stage, module) in stages {
|
||||
@@ -40,27 +41,28 @@ pub(super) fn merge<'a>(
|
||||
let constant = module.constants.iter().find_map(|(_, c)| {
|
||||
(c.name.as_deref() == Some(constant_name.as_str())).then_some(c)
|
||||
});
|
||||
let failure = || {
|
||||
vec![stage.error(
|
||||
*at,
|
||||
&format!("`{name}` needs a supported constant initializer"),
|
||||
)]
|
||||
};
|
||||
let constant = constant.ok_or_else(failure)?;
|
||||
let mut words = Vec::new();
|
||||
flatten(module, constant.init, &mut words).ok_or_else(failure)?;
|
||||
// Store into scratch bytes: an unsupported aggregate must not leave a
|
||||
// partially initialized uniform in the program's initial contents.
|
||||
let mut bytes = vec![0; span as usize];
|
||||
let mut words = words.into_iter();
|
||||
write(
|
||||
module,
|
||||
&module.types[member.ty].inner,
|
||||
&mut words,
|
||||
&mut bytes,
|
||||
member.offset as usize,
|
||||
)
|
||||
.ok_or_else(failure)?;
|
||||
if words.next().is_some() {
|
||||
return Err(failure());
|
||||
let stored = constant.and_then(|constant| {
|
||||
let mut words = Vec::new();
|
||||
flatten(module, constant.init, &mut words)?;
|
||||
let mut words = words.into_iter();
|
||||
write(
|
||||
module,
|
||||
&module.types[member.ty].inner,
|
||||
&mut words,
|
||||
&mut bytes,
|
||||
member.offset as usize,
|
||||
)?;
|
||||
words.next().is_none().then_some(())
|
||||
});
|
||||
if stored.is_none() {
|
||||
notes.push(stage.error(
|
||||
*at,
|
||||
&format!("`{name}` has an unsupported constant initializer; using zero"),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Some(previous) = seen.get(name) {
|
||||
if previous != &bytes {
|
||||
@@ -77,7 +79,7 @@ pub(super) fn merge<'a>(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
Ok((result, notes))
|
||||
}
|
||||
|
||||
fn flatten(module: &Module, value: Handle<Expression>, out: &mut Vec<[u8; 4]>) -> Option<()> {
|
||||
|
||||
@@ -89,6 +89,8 @@ pub struct Image {
|
||||
pub struct ComputeProgram {
|
||||
pub module: naga::Module,
|
||||
pub interface: Interface,
|
||||
/// Non-fatal translation diagnostics, located in the authored source.
|
||||
pub notes: Vec<Error>,
|
||||
pub work_groups: Option<[u32; 3]>,
|
||||
pub work_groups_render: [f32; 2],
|
||||
}
|
||||
@@ -119,7 +121,7 @@ pub struct Sampler {
|
||||
pub binding: u32,
|
||||
}
|
||||
|
||||
/// A program that failed, at a pack file and line.
|
||||
/// A translation diagnostic at a pack file and line.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Error {
|
||||
pub file: String,
|
||||
@@ -131,6 +133,8 @@ pub struct Program {
|
||||
pub vertex: naga::Module,
|
||||
pub fragment: naga::Module,
|
||||
pub interface: Interface,
|
||||
/// Non-fatal translation diagnostics, located in the authored source.
|
||||
pub notes: Vec<Error>,
|
||||
}
|
||||
|
||||
/// Where the program draws, which decides the conventions of 5.5.
|
||||
@@ -163,6 +167,10 @@ pub struct GbuffersInputs {
|
||||
/// The program may read `currentRenderedItemId` without declaring it, as Iris 1.11.7
|
||||
/// lets the hand's (observed with probe packs; terrain's may not).
|
||||
pub item_id_declared: bool,
|
||||
/// The depth test sees the far plane (window depth 1) unless the program writes
|
||||
/// `gl_FragDepth`, while `gl_FragCoord.z` stays the geometry's own: the sky shows only where
|
||||
/// nothing else drew, and reads its own depth as in Iris 1.11.7.
|
||||
pub far_depth: bool,
|
||||
}
|
||||
|
||||
/// A fragment stays when its output 0's alpha compares to `reference` as `op` says (a GLSL
|
||||
@@ -550,6 +558,9 @@ fn translate(
|
||||
for name in &f.inits {
|
||||
writeln!(fragment_main, " {name} = hz_init_{name}();").unwrap();
|
||||
}
|
||||
if gbuffers.is_some_and(|g| g.far_depth) {
|
||||
fragment_main.push_str(" gl_FragDepth = 1.0;\n");
|
||||
}
|
||||
fragment_main.push_str(" hz_main();\n");
|
||||
for copy in &f.out_copies {
|
||||
writeln!(fragment_main, " {copy}").unwrap();
|
||||
@@ -590,7 +601,7 @@ fn translate(
|
||||
(Ok(v), Ok(f)) => (v, f),
|
||||
(a, b) => return Err(a.err().into_iter().chain(b.err()).flatten().collect()),
|
||||
};
|
||||
let initial_values = defaults::merge([(&v, &vertex), (&f, &fragment)])?;
|
||||
let (initial_values, notes) = defaults::merge([(&v, &vertex), (&f, &fragment)])?;
|
||||
let (uniform_size, offsets) = block_layout(&fragment);
|
||||
let uniforms = offsets
|
||||
.into_iter()
|
||||
@@ -612,6 +623,7 @@ fn translate(
|
||||
mark_atomic(&vertex, &mut images);
|
||||
mark_atomic(&fragment, &mut images);
|
||||
Ok(Program {
|
||||
notes,
|
||||
interface: Interface {
|
||||
uniform_size,
|
||||
initial_values,
|
||||
@@ -804,7 +816,7 @@ pub fn translate_compute(compute: &Preprocessed) -> Result<ComputeProgram, Vec<E
|
||||
std::mem::take(&mut errors)
|
||||
})?;
|
||||
let (uniform_size, offsets) = block_layout(&module);
|
||||
let initial_values = defaults::merge([(&c, &module)])?;
|
||||
let (initial_values, notes) = defaults::merge([(&c, &module)])?;
|
||||
let mut interface = Interface {
|
||||
uniform_size,
|
||||
initial_values,
|
||||
@@ -831,6 +843,7 @@ pub fn translate_compute(compute: &Preprocessed) -> Result<ComputeProgram, Vec<E
|
||||
};
|
||||
mark_atomic(&module, &mut interface.images);
|
||||
Ok(ComputeProgram {
|
||||
notes,
|
||||
module,
|
||||
interface,
|
||||
work_groups,
|
||||
|
||||
@@ -165,3 +165,26 @@ fn compute_programs_receive_initial_contents_and_nonconstant_uniform_initializer
|
||||
);
|
||||
assert!(translate_composite(&source(VERTEX), &source("uniform float runtime; uniform float value = runtime; out vec4 c; void main(){ c=vec4(value); }"), Target::Buffers).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_double_defaults_keep_programs_and_supported_neighbors() {
|
||||
let p = program("uniform double unknown = double(2.0); uniform float kept = 0.625;");
|
||||
assert_eq!(words(&p, "unknown", 2), [0, 0]);
|
||||
assert_eq!(words(&p, "kept", 1), [0.625f32.to_bits()]);
|
||||
assert_eq!(p.notes.len(), 1);
|
||||
let note = &p.notes[0];
|
||||
assert_eq!((&*note.file, note.line), ("original.glsl", 1));
|
||||
assert!(note.message.contains("unknown") && note.message.contains("using zero"));
|
||||
let p = translate_compute(&source("layout(local_size_x=1) in; uniform double unknown = double(2.0); uniform float kept = 0.625; void main() {}" )).unwrap();
|
||||
assert_eq!(p.notes.len(), 1);
|
||||
let u = p
|
||||
.interface
|
||||
.uniforms
|
||||
.iter()
|
||||
.find(|u| u.name == "unknown")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&p.interface.initial_values[u.offset as usize..u.offset as usize + 8],
|
||||
&[0; 8]
|
||||
);
|
||||
}
|
||||
@@ -775,7 +775,17 @@ mod tests {
|
||||
hold: Some(gate.clone()),
|
||||
});
|
||||
let reg = pool.register(held.clone());
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
// A worker takes the job off the queue before holding it on the gate: wait for
|
||||
// that take, not a fixed sleep past it (a busy runner may start it later), so
|
||||
// `drop(reg)` below always has a running job to wait for.
|
||||
let picked = Instant::now();
|
||||
while lock(&held.jobs).len() > 1 {
|
||||
assert!(
|
||||
picked.elapsed() < Duration::from_secs(10),
|
||||
"no worker picked the job up"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
let opener = {
|
||||
let gate = gate.clone();
|
||||
std::thread::spawn(move || {
|
||||
|
||||
+15
-5
@@ -21,6 +21,7 @@ Level: preview ([stability.md](stability.md)). Schema 1.
|
||||
"id": "coords_hud", "kind": "mod", "name": "Coordinates HUD", "summary": "One line.",
|
||||
"description": "Markdown.", "authors": ["Horizon"], "owners": ["ludovic111"],
|
||||
"license": "MIT", "source": "https://github.com/...", "icon": "https://...png", "key": "RWT...", "downloads": 1234,
|
||||
"categories": ["utility"], "tags": ["hud"], "screenshots": ["https://...png"],
|
||||
"versions": [{
|
||||
"version": "0.1.0", "published": "2026-10-24", "changelog": "",
|
||||
"api": "0.1", "side": "client", "capabilities": ["registries"], "game": "26.3",
|
||||
@@ -36,11 +37,14 @@ Level: preview ([stability.md](stability.md)). Schema 1.
|
||||
| --- | --- |
|
||||
| `schema` | 1. A reader refuses a higher number with "update Horizon" rather than misread it. |
|
||||
| `projects[].id` | `[a-z0-9_]{1,64}`, unique; a mod's is its manifest's `id`. |
|
||||
| `kind` | `mod` (content), `native-mod`, `modpack`, `resourcepack`, `shader` (Horizon's own shader packs, not Iris or OptiFine packs). Other kinds (data packs, worlds, themes) may come without a new schema: a reader keeps a kind it doesn't know and doesn't offer it. |
|
||||
| `kind` | `mod` (content), `native-mod`, `modpack`, `resourcepack`, `shader` (Horizon's own `.hshader` packs), `shaderpack` (a `shaders/`-folder pack, the OptiFine/Iris format, `.zip`), `datapack` (`.zip`, installed into a world). Other kinds (worlds, themes) may come without a new schema: a reader keeps a kind it doesn't know and doesn't offer it. |
|
||||
| `summary`, `description` | One line for lists; Markdown for the project's page. |
|
||||
| `owners` | GitHub logins allowed to publish updates. |
|
||||
| `license`, `source` | An SPDX expression and an `https://` address of the source code: both required. |
|
||||
| `icon` | `https://`, optional. |
|
||||
| `categories` | 1 to 3 of the kind's list (`horizon_mods::index::page::categories`, [the lists](../design/registry-v2.md#categories)). Optional until every listed project has them. |
|
||||
| `tags` | Free-form: at most 10, each `[a-z0-9][a-z0-9-]{0,23}`, nothing about gambling. Optional. |
|
||||
| `screenshots` | `https://` addresses of PNG or JPEG images, in order, at most 8. Optional. |
|
||||
| `key` | The author's [minisign](https://jedisct1.github.io/minisign/) public key. When set, every version must be signed with it; an update signed with another key is refused. Absent: the project is unsigned and the launcher says so. |
|
||||
| `versions` | Newest first, at least one. |
|
||||
| `downloads` | How many times its files were downloaded, every version together: GitHub's count of each release asset, summed by the publish job, which runs every hour. Absent until a file is downloaded. Read-only: never in an entry. |
|
||||
@@ -110,7 +114,10 @@ repo = "horizon-rust/coords-hud" # GitHub owner/name: its releases are its versi
|
||||
owners = ["ludovic111"] # GitHub logins responsible for the project
|
||||
owner_ids = [12345678] # their numeric GitHub IDs: these decide who an owner is (a login can change hands)
|
||||
key = "RWT..." # optional: the minisign public key every version is signed with
|
||||
icon = "icons/coords_hud.png" # optional: a PNG of at most 256 KiB in the repository, or https://
|
||||
icon = "icons/coords_hud.png" # optional: a square PNG, 64 to 1024 px, at most 256 KiB in the repository, or https://
|
||||
categories = ["utility"] # 1 to 3 of the kind's list
|
||||
tags = ["hud", "coordinates"] # optional, at most 10, lowercase
|
||||
screenshots = ["screenshots/coords_hud/1.png"] # optional, at most 8: PNG or JPEG, 320x180 to 3840x2160, at most 1 MiB, or https://
|
||||
# The page. Each field may instead come from horizon-registry.toml in the repo (below); these win.
|
||||
name = "Coordinates HUD"
|
||||
summary = "One line, at most 160 characters."
|
||||
@@ -205,14 +212,17 @@ How the repository runs (its workflows are in `.github/workflows/`):
|
||||
- **Taking a file down** is a pull request adding it to `revoked.toml`. Removing an entry outright (a test, a project
|
||||
nobody installed) fails `registry review` on purpose and needs an administrator's merge.
|
||||
|
||||
Kinds open to submissions: `mod` (content only), `native-mod`, `modpack`, `resourcepack`, `shader`. A shader pack (`.hshader`) passes
|
||||
Kinds open to submissions: `mod` (content only), `native-mod`, `modpack`, `resourcepack`, `shader`, `shaderpack`,
|
||||
`datapack`. A data pack is a zip with `pack.mcmeta` (a `pack` object) and a `data/` folder; its facts' `api` is its pack
|
||||
format. A `shaderpack` is a zip with a `shaders/` folder, listed only by its author under a licence that allows
|
||||
redistribution (the reviewer checks both); Horizon loads it with `horizon-shaders`. A Horizon shader pack (`.hshader`) passes
|
||||
`horizon_shader_api::check`, the check the game runs before it loads one (`horizon-shader check`, [shaders.md](../shaders.md)):
|
||||
every stage validated by naga and translated for Vulkan, Metal and DirectX 12; its manifest's id, version and shader
|
||||
API line are its facts. Iris and OptiFine packs are for the Modrinth browser. A new kind gets its own check before it
|
||||
opens.
|
||||
API line are its facts. A new kind gets its own check before it opens.
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-11-05: kinds `shaderpack` and `datapack`; optional `categories`, `tags` and `screenshots` (entries and index). The index's schema is unchanged: readers from before keep the new kinds as unknown and ignore the new fields.
|
||||
- 2026-10-30: an entry names its GitHub repository (`repo`) and its versions are the repository's releases, read by the registry's CI; `file.url` may be a release asset of that repository. The index's schema is unchanged.
|
||||
- 2026-10-26: `projects[].downloads`, GitHub's download counts of a project's files, refreshed every hour.
|
||||
- 2026-10-26: the launcher installs `shader` files into `shaderpacks/`; the sample index lists one (`glow`).
|
||||
|
||||
@@ -66,7 +66,7 @@ Usage: horizon-server [--dir DIR] <command>
|
||||
players [--json]
|
||||
Who is online, the operators, the whitelist and the bans
|
||||
kick NAME [REASON], ban NAME [REASON], pardon NAME, ban-ip ADDRESS|NAME [REASON], pardon-ip ADDRESS
|
||||
Moderate the running server (vanilla's commands, as from its console)
|
||||
Moderate the running server, or edit ban files while it is stopped
|
||||
|
||||
--dir DIR The server folder (default: the current folder)
|
||||
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ address), op and deop, the whitelist, and metrics as Prometheus text.
|
||||
fixed address and token: `[api] bind` and `[api] token` in `server.toml` ([hosting.md](../hosting.md#a-bot-or-a-tool-of-your-own)).
|
||||
4. Moderate: `horizon-server players`, `horizon-server ban NAME [REASON]`, or `POST /api/v1/players/ban`. An
|
||||
action runs vanilla's console command on the running game, so it kicks, logs and saves `banned-players.json`
|
||||
exactly as an operator's `/ban` does; the reply says whether anything `changed` and has the console's lines.
|
||||
exactly as an operator's `/ban` does; the CLI also edits the vanilla list directly while stopped. The reply
|
||||
says whether anything `changed` and has the console's lines.
|
||||
5. Monitor: point Prometheus (or any scraper that sends a bearer token) at `GET /api/v1/metrics/prometheus`.
|
||||
6. Watch: `GET /api/v1/events` with `Accept: text/event-stream` streams what happens as server-sent events: every
|
||||
log line (`log`), the supervisor's `phase`, and the game's typed `join`, `leave`, `kick`, `chat` and `death`
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Mod registry v2
|
||||
|
||||
Every kind can be submitted, every project has a real page (description, categories, tags, icon, screenshots), and
|
||||
installed projects update themselves. Asked by Ludovic on 2026-11-05. This note fixes the schema, the kinds, where
|
||||
the launcher installs each one, the category lists and the rollout. The format itself stays in
|
||||
[docs/api/mod-index.md](../api/mod-index.md), updated by the PRs that change it.
|
||||
|
||||
## Kinds
|
||||
|
||||
| Kind | File | What it is | The registry's check | The launcher installs it in |
|
||||
|---|---|---|---|---|
|
||||
| `mod` | `.hmod` | Content mod (and sandboxed code) | manifest + content (today) | `mods/<id>.hmod` |
|
||||
| `native-mod` | `.hnmod` | Native source mod | bundle + pins (today) | the native build; rebuilt after an update |
|
||||
| `modpack` | `.hpack` | Registry mods played together | manifest + its mods in the index (today) | its own profile |
|
||||
| `resourcepack` | `.zip` | `pack.mcmeta` + `assets/` | plain paths + `pack.mcmeta` (today) | `resourcepacks/<file>` |
|
||||
| `shader` | `.hshader` | Horizon's WGSL shader pack | `horizon_shader_api::check` (today) | `shaderpacks/<file>` |
|
||||
| `shaderpack` | `.zip` | **new**: a `shaders/`-folder pack (the OptiFine/Iris format Horizon loads with `horizon-shaders`) | plain paths, a `shaders/` folder at the root | `shaderpacks/<file>` |
|
||||
| `datapack` | `.zip` | **new**: `pack.mcmeta` + `data/` | plain paths, `pack.mcmeta` with a `pack` object, a `data/` folder; its pack format | a world the player picks: `<world>/datapacks/<file>` |
|
||||
|
||||
- Why a separate `shaderpack` kind rather than `.zip` files under `shader`: launchers already released check every
|
||||
version's extension against its kind and refuse the whole index on a mismatch. A new kind name is read as
|
||||
`Kind::Other` by them: not offered, nothing refused. The same holds for `datapack`.
|
||||
- A `shaderpack` is accepted only when the submitter is the pack's author and its licence allows redistribution. The
|
||||
form asks for both as a ticked statement; the reviewer checks the licence and the author against the source. Ports
|
||||
of someone else's pack are refused (Mojang rules and [MOD-POLICY.md] in horizon-mods apply as for every kind).
|
||||
- Data packs: the game discovers `<world>/datapacks/*.zip` and turns them on at world open and on `/reload`
|
||||
(`datapack::discover`). The launcher already installs Modrinth data packs into a chosen world
|
||||
(`browser::installed::install_datapack`); registry data packs go through the same picker.
|
||||
|
||||
## Page fields
|
||||
|
||||
New fields, in the entry (`mods/<id>.toml`) and in the index's projects, under the same names:
|
||||
|
||||
```toml
|
||||
categories = ["magic", "mobs"] # 1 to 3, from the kind's list below
|
||||
tags = ["bosses", "dungeons"] # optional, at most 10: [a-z0-9][a-z0-9-]{0,23}, no repeats
|
||||
screenshots = ["screenshots/my_mod/1.png"] # optional, at most 8: screenshots/<id>/<name>.png|.jpg in the registry, or https://
|
||||
```
|
||||
|
||||
- **description**: Markdown, required, at least 50 characters once trimmed, at most 10,000.
|
||||
- **icon**: required. A PNG, square, 64 to 1024 pixels, at most 256 KiB, in `icons/<id>.png` (or `https://`).
|
||||
- **screenshots**: PNG or JPEG, 320×180 to 3840×2160 pixels, at most 1 MiB each, stored in the registry
|
||||
repository like icons and served from its Pages site; the index lists their `https://` addresses in order.
|
||||
- **tags**: free-form, lowercase. Tags naming gambling (`casino`, `gambling`, `slots`, `lottery`, `betting`...) are
|
||||
refused, as no category names it (Mojang's Usage Guidelines).
|
||||
- Old readers are unaffected: the index has no `deny_unknown_fields`, and `SCHEMA` stays 1. The entry has
|
||||
`deny_unknown_fields`, hence the rollout below.
|
||||
|
||||
## Categories
|
||||
|
||||
One fixed list per kind, Modrinth's, minus `cursed`:
|
||||
|
||||
- `mod`, `native-mod`, `datapack`: adventure, decoration, economy, equipment, food, game-mechanics, library, magic,
|
||||
management, minigame, mobs, optimization, social, storage, technology, transportation, utility, worldgen.
|
||||
- `modpack`: adventure, challenging, combat, kitchen-sink, lightweight, magic, multiplayer, optimization, quests,
|
||||
technology.
|
||||
- `resourcepack`: 8x, 16x, 32x, 48x, 64x, 128x, 256x, 512x (resolution); audio, blocks, combat, core-shaders,
|
||||
decoration, entities, environment, equipment, fonts, gui, items, locale, models, realistic, simplistic, themed,
|
||||
tweaks, utility, vanilla-like.
|
||||
- `shader`, `shaderpack`: cartoon, fantasy, realistic, semi-realistic, vanilla-like (style); atmosphere, bloom,
|
||||
colored-lighting, foliage, path-tracing, pbr, reflections, shadows (features); potato, low, medium, high,
|
||||
screenshot (performance).
|
||||
|
||||
They live in `horizon_mods::index::categories(kind)`; the site and the launcher read them from the index's projects
|
||||
and show the kind's list as filters.
|
||||
|
||||
## Submitting and editing
|
||||
|
||||
- The form (playhorizon.dev/mods/submit) offers every kind, requires description, 1 to 3 categories and an icon (an
|
||||
upload: the service writes `icons/<id>.png` on the submission branch), and takes up to 8 screenshot uploads
|
||||
(`screenshots/<id>/<n>.<ext>`). The service checks sizes, types and dimensions before anything reaches GitHub; the
|
||||
registry's CI checks them again.
|
||||
- **Edit project**: an owner (by `owner_ids`, never by login alone) changes the page without a new version:
|
||||
`POST /api/mods/edit`, page fields only (name, summary, description, authors, licence, source, categories, tags,
|
||||
icon, screenshots). The service opens a pull request `edit/<id>-<time>` labelled `owner-edit`; `registry review`
|
||||
refuses an edit PR that changes versions, owners, keys or the kind.
|
||||
|
||||
## Updates
|
||||
|
||||
- The launcher updates installed registry projects by default (setting "Update registry mods automatically", on).
|
||||
It checks when it starts and before play, and installs each project's `Index::installable` version.
|
||||
- It never updates: a mod inside a modpack profile (the pack pins it by SHA-256: the pack updates as a whole, when
|
||||
the player asks); a native mod when the native build is pinned to a Nightly; a data pack (it lives in a world: the
|
||||
player updates it); anything across Minecraft versions.
|
||||
- An update never breaks a world: a content mod's new version that drops a block, item or entity id the installed
|
||||
one has is held for a manual update with a warning; the replaced file is kept beside it until the next start so a
|
||||
failed install rolls back; native mods are rebuilt after an update before play.
|
||||
|
||||
## Rollout
|
||||
|
||||
The `cargo horizon-mod` pinned by horizon-mods (`HORIZON_REV`) refuses unknown entry fields (#9688), so:
|
||||
|
||||
1. horizon: the new fields as optional (serde defaults), the two kinds, the checks of what is present, the category
|
||||
lists and docs/api/mod-index.md. Nothing is required yet.
|
||||
2. horizon-mods: `gh variable set HORIZON_REV` to that merge.
|
||||
3. In parallel: the service (uploads, Edit project), the site (form, pages, filters, screenshots), the launcher
|
||||
(kinds, filters, screenshots, auto-update), and the backfill of every existing entry (description, categories,
|
||||
tags, icon) in batches of horizon-mods PRs. The service deploys from horizon main on merge, so its PR merges after
|
||||
step 2.
|
||||
4. horizon: description, categories and icon required in `check_fields`; bump `HORIZON_REV` again; the form
|
||||
requires them. One issue in horizon-mods tells each owner what was filled in for them and how to change it.
|
||||
|
||||
Progress: `agent-state/registry-v2.md` in the orchestration repo.
|
||||
|
||||
[MOD-POLICY.md]: https://github.com/horizon-rust/horizon-mods/blob/main/MOD-POLICY.md
|
||||
+3
-2
@@ -302,8 +302,9 @@ horizon-server rollback [--timeout SECONDS]
|
||||
```
|
||||
|
||||
While the server runs, `op`, `whitelist` and `cmd` go to the game's console, so they act exactly as vanilla's
|
||||
console commands do. While it's stopped, they edit vanilla's files directly. `kick`, `ban`, `pardon`, `ban-ip`
|
||||
and `pardon-ip` run the vanilla command on the running server, through its control API; `players` reads the
|
||||
console commands do. While it's stopped, they edit vanilla's files directly. `kick` runs the vanilla command
|
||||
on the running server, through its control API; `ban`, `pardon`, `ban-ip` and `pardon-ip` do the same while it
|
||||
is running and edit the vanilla files directly while it is stopped. `players` reads the
|
||||
lists from the files when the server is stopped. Any console command works through
|
||||
`cmd`: `horizon-server cmd ban Griefer`, `horizon-server cmd say Restarting in 5 minutes`.
|
||||
#### Temporary bans
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ using the "Shape" column. What a content pack needs:
|
||||
| Accessory slots (what Curios does) | — | Curios users (6 require it, more use it optionally) | **0.4** | built-in slots API |
|
||||
| Mod networking between its client and server halves | — | machines and up | **0.4** | `net` interface |
|
||||
| Custom recipe types: machine recipes with their own JSON | — | machines and up | **0.4** | recipe-type registry: the mod declares its JSON schema and a display layout for the recipe viewer |
|
||||
| Entities: types, attributes, AI goals and brains, spawning | — | deep (87) | 0.5 | `entity` interface, AI in the mod with vanilla goals reusable; thrown item projectiles, and mobs of a zombie's or a villager's class with their own attributes, are already data in 0.2 ([mods.md](mods.md)); a native mod gives a type its own goals with `register_goals` ([native-mods.md](native-mods.md#mob-goals)) |
|
||||
| Entities: types, attributes, AI goals and brains, spawning | — | deep (87) | 0.5 | `entity` interface, AI in the mod with vanilla goals reusable; thrown item projectiles, and mobs of a zombie's, a villager's or a wandering trader's class with their own attributes, are already data in 0.2 ([mods.md](mods.md)); a native mod gives a type its own goals with `register_goals` ([native-mods.md](native-mods.md#mob-goals)) |
|
||||
| Custom renderers and animated models (block-entity, entity, item; GeckoLib's format) | — | deep | 0.5 | data models (Bedrock geometry + animations), plus a budgeted draw-list interface for the rest |
|
||||
| Dimensions: dimension types, levels, chunk generators | — | about 10 (Twilight Forest, Aether, Ad Astra, Undergarden, ...) | 0.5 | JSON part with worldgen; custom generators as mod code |
|
||||
| New hooks into vanilla behaviour (what Mixins do) | — | deep | as asked | each becomes a named hook, added for everyone |
|
||||
|
||||
+5
-2
@@ -317,8 +317,11 @@ it. A world opened without the
|
||||
mod keeps the saved ones as they are, frozen in place. Not yet: a dispenser doesn't throw it, and a vanilla client on a
|
||||
server with it may be disconnected when one spawns.
|
||||
|
||||
A living type copies a mob, `minecraft:zombie` or `minecraft:villager`, the way NeoForge's
|
||||
`EntityType.Builder.of(Zombie::new, ...)` makes a type of a vanilla class. It has no `item` or `damage`. `attributes`
|
||||
A living type copies a mob, `minecraft:zombie`, `minecraft:villager` or `minecraft:wandering_trader`, the way NeoForge's
|
||||
`EntityType.Builder.of(Zombie::new, ...)` makes a type of a vanilla class. The wandering trader is the neutral one whose
|
||||
behaviour is all goals, so a native mod's [`register_goals`](native-mods.md#mob-goals) can replace all of it (a citizen,
|
||||
a guard, an NPC); its trades on a right click are still a wandering trader's until a native mod answers the
|
||||
interaction (`native_interact_entity`). A villager's behaviour is its brain, which stays. It has no `item` or `damage`. `attributes`
|
||||
sets default attribute values that differ from its template's (`EntityAttributeCreationEvent`), by attribute id, and
|
||||
only for attributes the template has; one it lacks stops the content root's build:
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ in vanilla's Anvil format.
|
||||
in the README scores each subsystem against vanilla and lists what is still
|
||||
missing; [Rust rewrite status](rust-parity.md) has the details.
|
||||
|
||||
On Windows, the launcher uses a stable executable path for each game home across
|
||||
release and native-mod updates. Its private runtime copy lives under the launcher's
|
||||
data folder in `run/<home key>/game/`; installed versions remain intact. Opening a
|
||||
world to other machines can still require an initial Windows Firewall permission.
|
||||
The launcher does not create firewall rules or change network-profile settings.
|
||||
|
||||
## musl Linux: manual installation
|
||||
|
||||
On x86-64 musl distributions, download `horizon-linux-musl-x86_64.tar.gz` from the
|
||||
|
||||
+25
-1
@@ -2584,7 +2584,8 @@ place; `native_server_tick` does not define one, so name the dimension and expec
|
||||
saved data is read. `mob.ai.goals` is vanilla's `goalSelector`, `mob.ai.targets` its `targetSelector`. A mod's living type
|
||||
([mods.md](mods.md)) comes with its template's goals, a zombie's attacks, strolls and targets: clear them to replace them, as a
|
||||
class whose `registerGoals` skips `super`'s, or add to them. A villager acts through its brain, which goals don't replace:
|
||||
a type from the villager template still works, sleeps and trades as a villager, next to the goals a mod adds. A goal is a
|
||||
a type from the villager template still works, sleeps and trades as a villager, next to the goals a mod adds. For a
|
||||
neutral mob whose goals are all the mod's, copy `minecraft:wandering_trader`. A goal is a
|
||||
Rust type implementing
|
||||
`horizon_client::mobs::goals::Goal` (a bundle compiles into the game, so nothing is loaded at run time): its `flags` (`MOVE`,
|
||||
`LOOK`, `JUMP`, `TARGET`), `can_use`, `can_continue`, `start`, `tick` and `stop` run by priority and flag as vanilla's
|
||||
@@ -2988,6 +2989,29 @@ pieces.
|
||||
The tints are the biome's own at the column's top, not blended over the biome blend radius as the game draws them, so
|
||||
near a biome border they can differ from what players see by a few blocks.
|
||||
|
||||
### Structure templates
|
||||
|
||||
`crates/horizon-client/src/game/native_structures.rs`: a helper for server mods that reads a structure template without
|
||||
placing it, for a builder that places it a block at a time (a colony's builder, a schematic cannon). API
|
||||
@API:structure_template@.
|
||||
|
||||
- `native_structure_template(id, at, rotation, mirror)` (`Host::structure_template`) finds template `id` (`mymod:hut`)
|
||||
as vanilla's `StructureTemplateManager.get` does: the world's saved structures (`generated/<namespace>/structures/`,
|
||||
what a structure block saves), then the game's and the content mods' (`data/<namespace>/structure/`). It answers what
|
||||
`/place template id` would place at `at` with that `Rotation` and `Mirror`: `NativeStructureTemplate` holds the
|
||||
template's `size` after the rotation and its `blocks`. Each block has 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 (`nbt`:
|
||||
a chest's items, a sign's text), for the mod to apply.
|
||||
- The blocks come in the order `StructureTemplate.placeInWorld` places them: full blocks, then the others, then those
|
||||
with a block entity, each group by y, then x, then z. Of a template with several palettes, it is the one
|
||||
`/place template` picks at `at`. No processor runs, nothing is placed, and the template's entities are left out.
|
||||
- It answers `None` on a client, for an id that doesn't parse or has a `.` or `..` part, and for a template the game
|
||||
doesn't have.
|
||||
|
||||
A builder reads the template once when it starts and keeps it. Then, each time it places a block, it walks there (its
|
||||
goals, [Mob goals](#mob-goals)) and calls `native_set_block(dimension, pos, state, 3)`, skipping blocks that are already
|
||||
right. It keeps how far it got in an attachment on the builder, so the build goes on after a save.
|
||||
|
||||
### Chunk tickets
|
||||
|
||||
`crates/horizon-client/src/game/native_chunk_tickets.rs`: `native_force_chunk(owner, [x, z], hold)` holds a chunk of the
|
||||
|
||||
@@ -4,6 +4,13 @@ How parity work is run, checked and merged. The harnesses themselves are describ
|
||||
|
||||
## Running the vanilla harnesses
|
||||
|
||||
Same-machine fixtures created with `lab.game_env` use `HORIZON_TEST_LOOPBACK=1`: Horizon
|
||||
listens on loopback and does not scan or announce LAN worlds. This avoids Windows Firewall
|
||||
permission prompts for newly compiled fixture executables. Set `HORIZON_TEST_LOOPBACK=0`
|
||||
explicitly when testing real network interfaces or multicast discovery. Local vanilla server
|
||||
fixtures also set `server-ip=127.0.0.1`; use one installed JDK via `JAVA_HOME` and its `bin`
|
||||
on `PATH`, rather than copying Java to each fixture directory. Keep the firewall enabled.
|
||||
|
||||
`tools/parity.py` runs the `tools/test_*_parity.py` harnesses in parallel and reruns only what a change can affect:
|
||||
|
||||
```sh
|
||||
|
||||
+18
-1
@@ -66,6 +66,22 @@ and some are waiting for Ludovic:
|
||||
a secret of GitHub's `release` environment, which only real releases from main can use. The job that signs runs
|
||||
nothing from the repository but a short script calling OpenSSL, so no pull request can read the key by changing
|
||||
the code that gets built. Nobody, Ludovic included, ever needs to see it.
|
||||
- **Nightly signatures.** A Nightly carries two signatures over the same `SHA256SUMS`. `SHA256SUMS.sig` is signed
|
||||
for the channel (`horizon release nightly`), for launchers and servers from before this change, which read only
|
||||
that one. `SHA256SUMS.v2.sig` is signed for the Nightly's own version (`horizon release nightly-<date>-<commit>`),
|
||||
which its release title names (`Nightly <date> · <commit>`). Current launchers, `horizon-server update` and
|
||||
`install.sh` parse that title strictly, refuse a Nightly whose title doesn't parse, and install it only with a v2
|
||||
signature for exactly that version. That way an older Nightly can't be served as tonight's: its signature names its own date. Installing
|
||||
an older Nightly on purpose still works as a rollback, with a warning, and never happens as an automatic update.
|
||||
**The changeover:** Nightlies published before this change have no `SHA256SUMS.v2.sig`, so they can't be installed
|
||||
or picked with `--version` any more; they leave GitHub with the 7-day rotation. Until a v2 Nightly exists, a
|
||||
launcher has nothing to update to, so the first Nightly is built right after this merges. A server can still go back
|
||||
to a build it already has with `horizon-server rollback`, which checks nothing it hasn't already checked.
|
||||
**When the old signature can go:** once every launcher and server still updating reads v2. That is, when the
|
||||
telemetry live count shows no launcher (and the server update log no `horizon-server`) older than the first build
|
||||
with this change, or at the latest 60 days after it shipped. Then drop `SHA256SUMS.sig` from nightly-build.yml's
|
||||
publish step and the `--tag nightly` check before it, and remove `NIGHTLY` from horizon-release-sign. Releases (not
|
||||
Nightlies) keep their single `SHA256SUMS.sig`, which is already signed for their tag.
|
||||
- **The release machinery needs Ludovic.** The workflows, the release and signing tools, the update-checking code
|
||||
and the installers are listed in `.github/CODEOWNERS`. Once main's protection requires code owners' reviews, a
|
||||
change to any of them waits for Ludovic's approval. Everything else still merges as soon as its checks pass.
|
||||
@@ -100,7 +116,8 @@ and some are waiting for Ludovic:
|
||||
not a deliberate attack. The protection is review: only agents and maintainers push to this repository, and a
|
||||
maintainer reads a fork's change before labelling it.
|
||||
- **The first install** with `install.sh` checks the signature when OpenSSL 3 is installed: it verifies
|
||||
`SHA256SUMS.sig` against the release key built into the script, then the archive against `SHA256SUMS`. With
|
||||
`SHA256SUMS.sig` (a Nightly's `SHA256SUMS.v2.sig`, for its own version) against the release key built into the
|
||||
script, then the archive against `SHA256SUMS`. With
|
||||
an older OpenSSL, LibreSSL (what macOS ships) or none, it says so and checks only the SHA-256 GitHub
|
||||
publishes. `install.ps1` still checks only that SHA-256. Every update after that checks the signature.
|
||||
|
||||
|
||||
+3
-2
@@ -446,8 +446,9 @@ horizon-server prepare --server-jar PATH
|
||||
|
||||
- Every command takes `--dir` (the server folder; default: the current folder).
|
||||
- While the server runs, management commands go through the control API, so the engine applies them as
|
||||
vanilla's console would and its lists stay right. While it's stopped, they edit the vanilla files directly
|
||||
with the same code the engine uses (`horizon_server_files::user_lists`).
|
||||
vanilla's console would and its lists stay right. While it's stopped, `op`, `deop`, `whitelist`, `ban`,
|
||||
`pardon`, `ban-ip` and `pardon-ip` edit the vanilla files directly with the same code the engine uses
|
||||
(`horizon_server_files::user_lists`).
|
||||
- **The TUI** is `horizon-server console` (built, `console.rs`): the recent log and then the live one (the
|
||||
control API's event stream), a one-line header (TPS, MSPT, players, RAM) that updates
|
||||
every second, and a command line. It uses plain ANSI with no TUI framework, and falls back to plain lines
|
||||
|
||||
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user