Merge main into #9841 (lan_actions imports)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-05 13:34:28 +01:00
commit c4b85eaed1
227 files changed
+9175 -787

No files matched your search

+15 -2
View File
@@ -165,11 +165,15 @@ jobs:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
- name: Sign the checksums for the nightly channel (SHA256SUMS.sig) and for this build (SHA256SUMS.v2.sig)
# SHA256SUMS.sig is for launchers from before v2 (signed for "nightly"); SHA256SUMS.v2.sig is signed for
# nightly-<date>-<commit>, the version the release title names, which current launchers and servers
# require (docs/security.md, "Nightly signatures"). The publish step titles the release from the same values.
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
ALSO_FOR: nightly-${{ needs.pick.outputs.date }}-${{ needs.pick.outputs.short }}
run: |
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
@@ -208,12 +212,17 @@ jobs:
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
- name: The signatures, checked as launchers check them
env:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
DATE: ${{ needs.pick.outputs.date }}
SHORT: ${{ needs.pick.outputs.short }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
# The version's own signature, which current launchers require: the title below names the same version.
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "nightly-${DATE}-${SHORT}" \
--signature SHA256SUMS.v2.sig --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
# only reports what a real run would do (that is how a blocker's fix is tried).
@@ -230,12 +239,16 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
SHORT: ${{ needs.pick.outputs.short }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
[[ -f dist/SHA256SUMS.v2.sig ]] && signature+=(dist/SHA256SUMS.v2.sig)
# The title is what launchers parse (strictly) into the version SHA256SUMS.v2.sig is signed for.
[[ "${SHA:0:7}" == "$SHORT" ]] || { echo "::error::the build's commit isn't the one signed"; exit 1; }
# The ARM64 archive only when its build succeeded (the sign job left it out of SHA256SUMS otherwise).
arm64=()
if [[ -f dist/horizon-windows-arm64.zip ]]; then
+4 -2
View File
@@ -9,8 +9,10 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
folders in `HORIZON_HOME` (logs, crash reports, telemetry consent, recordings; not saves), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Saves, `options.txt` and
caches go in the game folder, by default the working directory: start the game from a scratch folder with
`--game-dir` pointing at it, or they land in the repo root, hidden by `.gitignore`. For the
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
+14 -33
View File
@@ -206,7 +206,7 @@ Manage a server from a console, ban and pardon players, and drive it from other
<details><summary>Technical</summary>
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The last pieces merged: bans edited while the server is stopped ([#9767](https://github.com/horizon-rust/horizon/issues/9767)) and the MCP tools' contracts pinned by a test ([#9748](https://github.com/horizon-rust/horizon/issues/9748)). The panel's later features and vanilla's management protocol are their own item (server-panel-management). [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
@@ -396,6 +396,16 @@ A Datapacks tab beside Mods and Resource packs: install into a chosen world's da
### Servers and multiplayer
#### Server panel extras and vanilla's management protocol
More of a server managed from its web panel (bans, worlds, logs and crash reports, modpacks), and vanilla's own server-management tools working with a Horizon server.
<details><summary>Technical</summary>
The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. Split from server-admin. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
@@ -600,16 +610,6 @@ An output device list in Music & Sounds, falling back to the system default. [#1
</details>
#### Play in your language
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn.
</details>
### Rendering
#### A light default shader pack
@@ -860,30 +860,11 @@ More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/pe
</details>
### Gameplay parity
#### End city banners keep their pattern
Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526). The templates' block entities now keep their patterns ([#1928](https://github.com/horizon-rust/horizon/issues/1928)).
</details>
#### Enchanted loot in End cities
Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660). The loot's enchantment functions now reach every table that rolls ([#1911](https://github.com/horizon-rust/horizon/issues/1911), [#2037](https://github.com/horizon-rust/horizon/issues/2037)).
</details>
## Recently shipped
- **Enchanted loot in End cities** (v0.2.73): Armour and tools in End city chests should come enchanted, as in vanilla. [#1437](https://github.com/horizon-rust/horizon/issues/1437) · [#1660](https://github.com/horizon-rust/horizon/issues/1660) · [#1911](https://github.com/horizon-rust/horizon/issues/1911) · [#2037](https://github.com/horizon-rust/horizon/issues/2037)
- **End city banners keep their pattern** (v0.2.67): Banners in End cities show as plain magenta instead of with their pattern. [#1438](https://github.com/horizon-rust/horizon/issues/1438) · [#526](https://github.com/horizon-rust/horizon/issues/526) · [#1928](https://github.com/horizon-rust/horizon/issues/1928)
- **Play in your language** (v0.2.65): Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English. [#1806](https://github.com/horizon-rust/horizon/issues/1806) · [#1594](https://github.com/horizon-rust/horizon/issues/1594) · [#1672](https://github.com/horizon-rust/horizon/issues/1672)
- **Chickens flap while they fall** (v0.2.67): Chickens should flap their wings while they fall, not stand still in the air. [#1706](https://github.com/horizon-rust/horizon/issues/1706) · [#1892](https://github.com/horizon-rust/horizon/issues/1892)
- **Ctrl+Backspace and scroll bars in menus** (v0.2.67): Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable. [#1764](https://github.com/horizon-rust/horizon/issues/1764) · [#1782](https://github.com/horizon-rust/horizon/issues/1782) · [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
- **A pointing hand over buttons** (v0.2.67): The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3. [#1347](https://github.com/horizon-rust/horizon/issues/1347) · [#1897](https://github.com/horizon-rust/horizon/issues/1897)
+1 -1
View File
@@ -93,7 +93,7 @@ pub fn kind_of(path: &Path) -> Option<Kind> {
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"zip" => Some(horizon_mods::index::zip_kind(path)),
"hshader" => Some(Kind::Shader),
_ => None,
}
+25 -3
View File
@@ -90,9 +90,20 @@ pub fn effect_lines<'a>(
);
}
let instant = effect(e).is_some_and(|d| d.instant);
let scaled = (duration as f32 * scale) as i32;
if !instant && scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
if !instant {
if duration == -1 {
// MobEffectInstance.isInfiniteDuration: formatDuration says
// the infinite sign, not a time.
line = format!(
"{line} ({})",
translate("effect.duration.infinite").unwrap_or_else(|| "∞".to_owned())
);
} else {
let scaled = (duration as f32 * scale) as i32;
if scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
}
}
}
(line, effect(e).map_or(Category::Neutral, |d| d.category))
})
@@ -562,4 +573,15 @@ mod tests {
assert!(lines[0].0.starts_with("Замедление IV ("), "{lines:?}");
assert!(lines[1].0.starts_with("Сопротивление III ("), "{lines:?}");
}
#[test]
fn infinite_effects_say_the_infinite_sign() {
// MobEffectInstance.isInfiniteDuration: a -1 duration says the
// infinite sign, not a time; a short one still says nothing.
let translate = |key: &str| (key == "effect.duration.infinite").then(|| "∞".to_owned());
let lines = effect_lines([("minecraft:resistance", -1, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance (∞)");
let lines = effect_lines([("minecraft:resistance", 15, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance");
}
}
+51
View File
@@ -307,6 +307,15 @@ fn world_pass_after_culling<'e>(
..Default::default()
})
}
/// The point a `shaders/` pack's terrain is drawn relative to: the camera with the fraction of
/// each coordinate rounded to the nearest 1/65536 block. Read back with a probe pack of ours,
/// Iris 1.11.7's terrain `gl_Vertex` is the block's position minus this, for cameras from 13
/// to 40000 blocks out (#8464); `cameraPosition` and the matrices keep the exact camera. Off by
/// up to 2^-17 blocks, a fragment on a texel boundary of the shadow map reads the other texel.
fn pack_terrain_camera(camera: DVec3) -> DVec3 {
let whole = camera.floor();
whole + ((camera - whole) * 65536.0).round() / 65536.0
}
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
@@ -7665,6 +7674,21 @@ impl Renderer {
height(b).total_cmp(&height(a))
});
}
// A `shaders/` pack that draws the terrain, or its shadow, sees it as Iris does.
let shaders_pack = !frame.title
&& self
.pack_post
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_terrain() || p.has_shadow());
if shaders_pack {
let from = pack_terrain_camera(camera);
for v in visible.iter_mut().chain(shadow_visible.iter_mut()) {
v.origin = resident::origin(v.key, self.sections.min_y(), from)
.as_vec3()
.to_array();
}
}
if self.animated_textures {
self.active_animated_layers.fill(0);
for v in &visible {
@@ -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, &macros, &Default::default())
}
#[test]
fn atlas_names_read_colortex0_in_deferred_composite_and_final() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [8, 8]);
for (i, p) in pixels.iter().enumerate() {
let name = match i % 8 {
0..2 => "gtexture in composite",
2..4 => "tex in composite1",
4..6 => "a name of the pack's in composite2",
_ => "texture in final",
};
assert_eq!(*p, [0, 200, 40, 255], "{name}, pixel {i}");
}
}
@@ -8,7 +8,7 @@ pub(super) const SIZE: [u32; 2] = [16, 16];
pub(super) struct Gpu {
adapter: wgpu::Adapter,
pub(super) device: wgpu::Device,
queue: wgpu::Queue,
pub(super) queue: wgpu::Queue,
}
impl Gpu {
pub(super) fn new() -> Option<Self> {
@@ -92,7 +92,12 @@ impl Gpu {
);
built
}
fn clear(&self, color: &wgpu::TextureView, depth: &wgpu::TextureView, reverse_depth: f32) {
pub(super) fn clear(
&self,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
reverse_depth: f32,
) {
let mut encoder = self.device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Independent original fixture clear"),
@@ -316,7 +321,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
@@ -677,6 +682,7 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: false,
}),
..Default::default()
};
@@ -783,3 +789,68 @@ fn shadowtex_nearest_settings_read_the_nearest_texel() {
}
}
}
/// The sky program covers the horizon and downward viewing directions too.
/// This original probe reports sky colour in RGB and window depth in alpha.
#[test]
#[ignore = "requires a host GPU"]
fn sky_covers_the_horizon_without_writing_depth_or_overpainting_terrain() {
let gpu = Gpu::new().expect("GPU required for the sky coverage regression");
assert_ne!(gpu.adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
let files = vec![
("gbuffers_skybasic.vsh".into(), "#version 120\nvoid main(){gl_Position=ftransform();}".into()),
("gbuffers_skybasic.fsh".into(), "#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){gl_FragData[0]=vec4(1.0,0.0,0.0,1.0);}".into()),
("final.vsh".into(), VERTEX.into()),
("final.fsh".into(), "#version 120\nuniform sampler2D colortex0;uniform sampler2D depthtex0;varying vec2 uv;\nconst vec4 colortex0ClearColor=vec4(0.0,1.0,0.0,1.0);\nvoid main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,texture2D(depthtex0,uv).r);}".into()),
];
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
sky: Some(crate::gfx::pack_sky::inputs()),
..Default::default()
},
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.skybox = true;
frame.distance = 512.0;
frame.detail_distance = 512.0;
frame.depth_far = 2048.0;
for yaw in [0.0, 45.0, 90.0, 180.0, 270.0] {
for pitch in [-60.0, 0.0, 21.1, 60.0] {
frame.yaw = yaw;
frame.pitch = pitch;
gpu.clear(&color, &depth, 0.0);
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
all_pixels(
&pixels,
[255, 0, 0, 255],
&format!("sky yaw={yaw} pitch={pitch}"),
);
}
}
// A pre-existing foreground depth must survive the sky, as must its colour.
gpu.clear(&color, &depth, 0.5);
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
all_pixels(
&pixels,
[16, 16, 16, 128],
"foreground colour and depth survive sky",
);
}
@@ -45,6 +45,7 @@ fn pack() -> Post {
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
@@ -0,0 +1,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,
}
}
+117
View File
@@ -1559,6 +1559,8 @@ pub struct Cushion {
pub color: u8,
/// BlockAttachedEntity.ticksSinceLastCheck.
pub check_interval: u32,
/// `Entity.getCustomName` (`CustomName`), copied onto the dropped cushion.
pub custom_name: Option<horizon_world::nbt::Tag>,
}
impl Cushion {
/// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25.
@@ -1577,6 +1579,16 @@ impl Cushion {
pub fn item(&self) -> String {
format!("minecraft:{}_cushion", self.color_name())
}
/// `Cushion.getCushionItemStackWithData`: the coloured item with
/// `DataComponents.CUSTOM_NAME` from `getCustomName`, as the item frame
/// beside it does.
pub fn item_stack_with_data(&self) -> ItemStack {
let mut stack = ItemStack::new(&self.item(), 1);
if let Some(name) = &self.custom_name {
stack.data_mut().custom_name = Some(name.clone());
}
stack
}
/// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment
/// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`.
pub fn seat(&self) -> Vec3 {
@@ -1783,6 +1795,7 @@ impl ArmorStand {
};
self.tick_settle(medium, step);
}
self.apply_honey_slide(scene.world, facts);
}
}
impl ArmorStand {
@@ -2113,6 +2126,49 @@ impl ArmorStand {
self.velocity = velocity;
Some(StandStep { falling, friction })
}
/// `HoneyBlock.entityInside`: side-slide movement after the stand's travel.
/// `ArmorStand.hasPhysics` / `Entity.isAffectedByBlocks` exclude markers
/// and NoGravity stands. Slide effects are separate from this movement.
pub fn apply_honey_slide(&mut self, world: &World, facts: &BlockFacts) {
let old_delta_y = |dy: f64| dy / f64::from(0.98_f32) + 0.08;
if self.marker || self.no_gravity || self.on_ground || old_delta_y(self.velocity.y) >= -0.08
{
return;
}
// Entity.checkInsideBlocks: test the deflated body against block cells,
// not their inset collision shape (the stand slides beside that shape).
let bounds = self.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let overlap = 0.4375 + if self.small { 0.125 } else { 0.25 };
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
if &*facts.get(world.block(x, y, z)).name != "minecraft:honey_block"
|| self.position.y > f64::from(y) + 0.9375 - 1.0e-7
{
continue;
}
let dx = (f64::from(x) + 0.5 - self.position.x).abs();
let dz = (f64::from(z) + 0.5 - self.position.z).abs();
if dx + 1.0e-7 <= overlap && dz + 1.0e-7 <= overlap {
continue;
}
// HoneyBlock.doSlideMovement, including its float-widened
// getOldDeltaY/getNewDeltaY conversion around air travel.
let old = old_delta_y(self.velocity.y);
if old < -0.13 {
let factor = -0.05 / old;
self.velocity.x *= factor;
self.velocity.z *= factor;
}
self.velocity.y = (-0.05 - 0.08) * f64::from(0.98_f32);
self.enchantment_fall_distance = 0.0;
// The new oldDeltaY is -0.05, so another honey cell cannot slide again.
return;
}
}
}
}
/// The rest of the stand's travel: the water slowdown, the lava slowdown
/// and sink, or the air drag and gravity. The blocks' push (a bubble
/// column) comes after this, as `LivingEntity.aiStep` applies the block
@@ -3467,6 +3523,67 @@ mod entity_collision_tests {
mod armor_stand_motion_tests {
use super::*;
#[test]
fn honey_slide_runs_after_stand_travel_and_preserves_no_physics_controls() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
let mut world = World::new(assets.registry.default_state("minecraft:air").unwrap(), 3);
world.chunks.insert(
(0, 0),
std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)),
);
for (z, name) in [(4, "minecraft:honey_block"), (10, "minecraft:stone")] {
for y in 100..=120 {
world
.set(6, y, z, assets.registry.default_state(name).unwrap())
.unwrap();
}
}
// Independently captured Java values after five actual ticks.
for (z, no_gravity, marker, y, vy, grounded) in [
(
4.5,
false,
false,
117.51156798899842,
-0.12740000247955321,
false,
),
(10.5, false, false, 118.0, -0.0784000015258789, true),
(4.5, true, false, 118.0, 0.0, false),
(4.5, false, true, 118.0, 0.0, false),
] {
let mut stand = ArmorStand::new(1, Vec3::new(5.78, 118.0, z), 0.0);
// Match the freshly summoned entity rather than the placement constructor.
stand.on_ground = false;
stand.no_gravity = no_gravity;
stand.marker = marker;
let mut scene = SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
for _ in 0..5 {
stand.tick(&mut scene, &facts, &motion.climbing, false, None);
}
assert_eq!(
stand.position,
Vec3::new(5.78, y, z),
"NoGravity={no_gravity}, Marker={marker}"
);
assert_eq!(stand.velocity, Vec3::new(0.0, vy, 0.0));
assert_eq!(stand.on_ground, grounded);
}
}
#[test]
fn a_name_tag_names_a_live_stand_and_unnamed_tags_pass() {
let mut stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
+5
View File
@@ -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));
+2
View File
@@ -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()
+31 -6
View File
@@ -278,6 +278,7 @@ impl Game<'_> {
yaw: snap_yaw(self.yaw),
color,
check_interval: 0,
custom_name: None,
});
self.world
.modified
@@ -396,12 +397,7 @@ impl Game<'_> {
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
return;
}
let stack = ItemStack {
item: std::sync::Arc::from(c.item()),
count: 1,
damage: 0,
extra: None,
};
let stack = c.item_stack_with_data();
self.spawn_item(stack, [p.x, p.y, p.z], [0.0; 3], 10);
}
@@ -607,3 +603,32 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::Tag;
/// `Cushion.getCushionItemStackWithData`: a named cushion drops named.
#[test]
fn a_named_cushion_drops_with_its_name() {
let c = Cushion {
id: 1,
position: Vec3::ZERO,
pos: [0, 64, 0],
yaw: 0.0,
color: 1,
check_interval: 0,
custom_name: Some(Tag::String("Seat".into())),
};
let stack = c.item_stack_with_data();
assert_eq!(&*stack.item, "minecraft:orange_cushion");
assert!(stack.data().is_some_and(|d| d.custom_name.is_some()));
// An unnamed one gains no component.
let c = Cushion {
custom_name: None,
..c
};
assert!(c.item_stack_with_data().data().is_none());
}
}
+3 -19
View File
@@ -67,7 +67,7 @@ impl Game<'_> {
return Ok(false);
};
if state.properties.is_empty() {
self.debug_stick_message(text(
self.send_overlay_message(text(
"item.minecraft.debug_stick.empty",
&[state.name.as_str()],
));
@@ -121,7 +121,7 @@ impl Game<'_> {
);
});
self.process_level_events()?;
self.debug_stick_message(text(
self.send_overlay_message(text(
"item.minecraft.debug_stick.update",
&[property.as_str(), value.as_str()],
));
@@ -135,7 +135,7 @@ impl Game<'_> {
let property = keys[relative_index(keys.len(), current, backward)].clone();
let value = state.properties.get(&property).cloned().unwrap_or_default();
self.set_debug_stick_property(&state.name, &property);
self.debug_stick_message(text(
self.send_overlay_message(text(
"item.minecraft.debug_stick.select",
&[property.as_str(), value.as_str()],
));
@@ -151,22 +151,6 @@ impl Game<'_> {
stack.data_mut().set_debug_stick_property(block, property);
stack.tidy();
}
/// `ServerPlayer.sendOverlayMessage`: the steady player sees it on this
/// game's HUD; a server client receives it as a packet.
fn debug_stick_message(&mut self, message: Text) {
if self.lan_acting.is_some() {
self.guest_packets
.push(horizon_protocol::packets::play::Clientbound::SystemChat(
horizon_protocol::packets::play::SystemChat {
content: horizon_client::lan::data::component_nbt(&message.component()),
overlay: true,
},
));
} else if self.options.dedicated.is_none() {
self.titles.set_overlay(message, false);
}
}
}
#[cfg(test)]
+28 -11
View File
@@ -355,12 +355,10 @@ impl Game<'_> {
}
// Server path of `use`: `dropAllLeashConnections(null)`, and when that
// drops anything `playSound(null, player, LEAD_BREAK, NEUTRAL, 1, 1)`.
// `SoundEvents.LEAD_BREAK` is `minecraft:item.lead.break`. A position
// is what the server thread forwards, so the window hears the snap.
if self.drop_all_leash_connections()
&& let Some(p) = self.player.as_ref()
{
self.lead_sound("break", [p.position.x, p.position.y, p.position.z]);
// The entity overload follows the player and excludes nobody, including
// the guest whose action is running while its hub is swapped out.
if self.drop_all_leash_connections() {
self.play_player_bound_sound("minecraft:item.lead.break", 6, 1.0, 1.0, false);
}
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let mut rocket = self.new_rocket(at, stack, true);
@@ -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);
+4 -3
View File
@@ -489,7 +489,7 @@ fn mob_variant_meta(
/// from the defaults the client constructs), with vanilla's entry indices:
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
/// `defaults` gets the defaults of the entries that can go back to theirs.
fn mob_meta(
pub(super) fn mob_meta(
m: &Mob,
hub: &Hub,
player_entity: Option<i32>,
@@ -2083,7 +2083,7 @@ impl Game<'_> {
let conn = guest.conn;
let at = host.hub.guests.len();
host.hub.guests.push(*guest);
self.guest_close_all(&mut host, conn);
self.guest_disconnected(&mut host, conn);
*guest = host.hub.guests.remove(at);
}
if let Err(e) = self.enter_level(Some(&mut host), back) {
@@ -3687,6 +3687,7 @@ mod tests {
for (template, id) in [
("minecraft:zombie", "ruby:ghoul"),
("minecraft:villager", "ruby:settler"),
("minecraft:wandering_trader", "ruby:merchant"),
] {
assert_eq!(
meta_of(id),
@@ -3702,7 +3703,7 @@ mod tests {
let sync = std::sync::Arc::new(data::SyncData::empty());
let config = NetConfig {
announce: false,
..NetConfig::lan(0, name.into(), 2)
..NetConfig::loopback(0, name.into(), 2)
};
let lan = Lan::start_with(sync.clone(), config).unwrap();
let encoder = ChunkEncoder::new(
+99 -2
View File
@@ -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);
}
+24 -1
View File
@@ -14,6 +14,14 @@ use horizon_client::{
};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// `AbstractContainerMenu.dropOrPlaceInInventory`: actual network disconnects
/// drop inputs; a live close still returns them to the inventory.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum MenuRemoval {
Live,
Disconnected,
}
/// The guest's menus the game runs: kept between clicks, so a drag (quick
/// craft) spans them as in vanilla.
#[derive(Default)]
@@ -675,6 +683,18 @@ impl Game<'_> {
host: &mut LanHost,
conn: u32,
window: Option<i32>,
) -> bool {
self.lan_crafting_removed(host, conn, window, MenuRemoval::Live)
}
/// `AbstractContainerMenu.clearContainer`: only input slots are removed;
/// `dropOrPlaceInInventory` chooses drops for a disconnected player.
pub(super) fn lan_crafting_removed(
&mut self,
host: &mut LanHost,
conn: u32,
window: Option<i32>,
removal: MenuRemoval,
) -> bool {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return false;
@@ -695,7 +715,10 @@ impl Game<'_> {
}
};
if !grid.is_empty() {
self.lan_place_back(host, gi, grid);
match removal {
MenuRemoval::Live => self.lan_place_back(host, gi, grid),
MenuRemoval::Disconnected => self.drop_guest_items(host, gi, grid),
}
}
true
}
@@ -917,7 +917,7 @@ mod tests {
fn hitting_shulker_bullet_sends_15_crit_particles_to_players() {
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "ShulkerBulletTest".into(), 2)
..NetConfig::loopback(0, "ShulkerBulletTest".into(), 2)
};
let mut sync = data::SyncData::empty();
sync.builtin.registries.insert(
+31 -16
View File
@@ -95,6 +95,9 @@ pub(super) struct Sweep {
pub(super) hit_box: Aabb,
/// The entity id of the player the sweep started from, which it spares.
pub(super) skip: Option<i32>,
/// The weapon's `minecraft:ignite` (Fire Aspect) on each swept player, in
/// seconds (`doPostAttackEffects` after a sweep hit lands).
ignite: f32,
}
impl Strike {
@@ -155,22 +158,7 @@ impl Game<'_> {
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
};
let ignite = self.held().map_or(0.0, |held| {
let ctx = EffectContext {
this: Some("minecraft:player"),
attacker: Some("minecraft:player"),
direct_attacker: Some("minecraft:player"),
is_direct: true,
items: Some(world_items!(self)),
..Default::default()
};
let mut random = self.world.random.clone();
world_stations!(self).enchantments.ignite_seconds(
held.enchantments(),
&ctx,
&mut random,
)
});
let ignite = self.weapon_ignite_seconds();
Some(Strike {
attacker: self.lan_acting,
amount,
@@ -188,6 +176,26 @@ impl Game<'_> {
})
}
/// The held weapon's `minecraft:ignite` (Fire Aspect) seconds on a player
/// victim (`EnchantmentHelper.doPostAttackEffects`).
fn weapon_ignite_seconds(&mut self) -> f32 {
let Some(held) = self.held().cloned() else {
return 0.0;
};
let ctx = EffectContext {
this: Some("minecraft:player"),
attacker: Some("minecraft:player"),
direct_attacker: Some("minecraft:player"),
is_direct: true,
items: Some(world_items!(self)),
..Default::default()
};
let mut random = self.world.random.clone();
world_stations!(self)
.enchantments
.ignite_seconds(held.enchantments(), &ctx, &mut random)
}
/// The guests by connection, with their entity ids.
pub(super) fn lan_guest_entities(&self) -> Vec<(u32, u32)> {
self.lan.as_ref().map_or_else(Vec::new, |host| {
@@ -453,6 +461,7 @@ impl Game<'_> {
team: self.player_team().cloned(),
hit_box: Aabb::new(Vec3::ZERO, Vec3::ZERO),
skip: None,
ignite: self.weapon_ignite_seconds(),
}
}
@@ -509,6 +518,12 @@ impl Game<'_> {
}
if g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack") {
g.knock_back_player(0.4, xd, zd, PVP_SOURCE);
// EnchantmentHelper.doPostAttackEffects, after the knockback.
if sweep.ignite > 0.0
&& let Some(p) = g.player.as_mut()
{
let _ = p.ignite_for_ticks((sweep.ignite * 20.0) as i32);
}
}
};
for (victim, id, team) in victims {
+10
View File
@@ -258,6 +258,9 @@ impl Game<'_> {
// The causing entity's aim, for what turns a blow by where it points
// (`SulfurCube.knockback`).
self.mobs[index].attacker_aim = aim;
// Absorbed tenths left by a hit that came another way (a player's projectile) are
// not this player's.
self.mobs[index].dealt_absorbed = 0;
let hurt = match player_uuid {
Some(uuid) => {
self.mobs[index].damage_with_player_uuid(amount, source, uuid, Some(&mut breach))
@@ -265,6 +268,13 @@ impl Game<'_> {
None => self.mobs[index].damage_with(amount, source, Some(&mut breach)),
};
self.mobs[index].attacker_aim = None;
// LivingEntity.actuallyHurt: Stats.DAMAGE_DEALT_ABSORBED for the attacking player,
// awarded here, during that player's own attack (a server's guest has its own
// statistics only inside `as_guest`).
let absorbed = std::mem::take(&mut self.mobs[index].dealt_absorbed);
if absorbed > 0 {
self.award_custom_stat("damage_dealt_absorbed", absorbed);
}
if hurt && self.mobs[index].health != old_health {
self.mob_damage_event(self.mobs[index].id);
}
+40
View File
@@ -447,12 +447,16 @@ pub(super) fn living_mod_types()
r#""width":0.6,"height":1.95,"eye_height":1.74,"category":"monster","fire_immune":false"#;
let villager =
r#""width":0.6,"height":1.95,"eye_height":1.62,"category":"misc","fire_immune":false"#;
let trader =
r#""width":0.6,"height":1.95,"eye_height":1.62,"category":"creature","fire_immune":false"#;
let table = format!(
r#"{{"minecraft:zombie":{{{zombie},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.23000000417232513}}}},
"minecraft:villager":{{{villager},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.5}}}},
"minecraft:wandering_trader":{{{trader},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.7}}}},
"minecraft:snowball":{{"width":0.25,"height":0.25,"eye_height":0.0,"category":"misc","fire_immune":false}},
"ruby:ghoul":{{{zombie},"attributes":{{"minecraft:max_health":40.0,"minecraft:movement_speed":0.23000000417232513}},"template":"minecraft:zombie"}},
"ruby:settler":{{{villager},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.5}},"template":"minecraft:villager"}},
"ruby:merchant":{{{trader},"attributes":{{"minecraft:max_health":20.0,"minecraft:movement_speed":0.7}},"template":"minecraft:wandering_trader"}},
"ruby:bolt":{{"width":0.25,"height":0.25,"eye_height":0.0,"category":"misc","fire_immune":false,"template":"minecraft:snowball"}}}}"#
);
// A folder per call: tests run in parallel in one process, and each removes its own.
@@ -541,6 +545,42 @@ mod tests {
);
}
/// A content mod's type from the wandering trader, the neutral template
/// whose behaviour is all goals: a trader under its own id, with a
/// trader's goals, that saves what a trader saves and loads back.
#[test]
fn a_mod_trader_type_is_a_wandering_trader_under_its_own_id() {
use horizon_client::mobs::{Mob, Species};
use horizon_world::nbt::Tag;
let types = living_mod_types();
let merchant = &types["ruby:merchant"];
assert_eq!(merchant.species, Species::WanderingTrader);
assert_eq!(merchant.class_id(), "minecraft:wandering_trader");
let at = horizon_core::geometry::Vec3::new(0.5, 64.0, 0.5);
let trader = Mob::new(1, types["minecraft:wandering_trader"].clone(), at, 0.0);
let merchant = Mob::new(2, merchant.clone(), at, 0.0);
assert!(merchant.ai.goals.has("Float") && trader.ai.goals.has("Float"));
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
let keys = |tag: &Tag| -> Vec<String> {
let Tag::Compound(map) = tag else {
panic!("a mob saves a compound");
};
map.keys().map(|k| k.to_string_lossy()).collect()
};
let tag = saved(&merchant);
assert_eq!(
keys(&tag),
keys(&saved(&trader)),
"it saves what a trader saves"
);
let loaded =
horizon_client::entity_nbt::mob_from_nbt(&tag, &types, 3, None).expect("it loads back");
assert_eq!(
(loaded.kind.id.as_str(), loaded.species()),
("ruby:merchant", Species::WanderingTrader)
);
}
/// Only a build whose `native-mods.json` lists a mod counts as built with mods.
#[test]
fn only_a_listed_mod_makes_a_build_a_mod_build() {
+116 -9
View File
@@ -153,10 +153,17 @@ impl Game<'_> {
.into_iter()
.chain(self.own_name_tag())
.map(|(m, name)| (m, name, true));
// Who is riding whom: asked once per tag below, and rescanning every mob
// for each tag made this quadratic in the number of loaded mobs.
let ridden: std::collections::HashSet<u32> =
self.mobs.iter().filter_map(|o| o.extra.mount).collect();
// Who is riding whom: asked once per tag below, and rescanning every entity
// for each tag made this quadratic in the number of loaded entities.
let local_entity_id = self.remote.as_ref().map(|r| r.entity_id as u32);
let ridden = collect_vehicle_ids(
&self.mobs,
self.text_displays.values().filter_map(|d| d.vehicle),
self.armor_stands.iter().filter_map(|a| a.vehicle),
self.interactions.values().filter_map(|i| i.vehicle),
self.riding,
local_entity_id,
);
for (m, name, player) in named.chain(players) {
if !m.alive() {
continue;
@@ -207,7 +214,9 @@ impl Game<'_> {
.then(|| m.extra.description.component())
.flatten()
.map(|tag| horizon_client::entity_nbt::name_text(&tag).resolve_legacy());
let is_own = Some(m.id) == own;
let name_shown = sb::name_shown(team, viewer, view)
&& (!is_own || !view.is_vehicle)
&& (player || m.name_visible || Some(m.id) == target)
&& (player || m.custom_name.is_some());
let mut visible = name_shown;
@@ -622,11 +631,45 @@ impl Game<'_> {
}
}
/// `EntityAttachment.NAME_TAG`: `(0, bbHeight, 0)` of the box of the entity's
/// current pose, which the submitter lifts 0.5 more, between the last two tick
/// positions. The player box is `Pose::dimensions(1.0)`.
/// shortcut: the scale attribute is not in the tag height, add `model_scale`
/// when tags on scaled players matter.
/// Collect entity IDs of all vehicles: entities that have passengers
/// (mobs, players, text displays, armor stands, or interactions riding them).
///
/// On multiplayer servers (e.g. `mcpvp.club`), server plugins hang custom nametags
/// (ranks, prefixes, stats) over players using `TextDisplay` or `ArmorStand` passengers.
/// In vanilla `LivingEntityRenderer.shouldShowName`, a vehicle does not show its default
/// name tag (`!entity.isVehicle()`).
///
/// If the local player's remote entity ID is in the set, ID 0 (`player_model`) is also
/// added so that Horizon's own name tag extra (`ownNameTag`) recognizes that the player
/// is ridden and suppresses the duplicate tag (#9477).
fn collect_vehicle_ids(
mobs: &[Mob],
text_display_vehicles: impl IntoIterator<Item = u32>,
armor_stand_vehicles: impl IntoIterator<Item = u32>,
interaction_vehicles: impl IntoIterator<Item = u32>,
local_riding: Option<u32>,
local_entity_id: Option<u32>,
) -> std::collections::HashSet<u32> {
let mut ridden: std::collections::HashSet<u32> = mobs
.iter()
.filter_map(|o| o.extra.mount)
.chain(mobs.iter().filter_map(|m| m.extra.rider.map(|_| m.id)))
.chain(text_display_vehicles)
.chain(armor_stand_vehicles)
.chain(interaction_vehicles)
.collect();
if let Some(riding) = local_riding {
ridden.insert(riding);
}
if let Some(id) = local_entity_id
&& ridden.contains(&id)
{
// The local player's model has id 0 (player_model).
ridden.insert(0);
}
ridden
}
/// The entity ids the player-list pass names, which the custom-name pass must
/// therefore skip.
///
@@ -644,6 +687,11 @@ impl Game<'_> {
fn named_by_player_list(players: &[(&Mob, String)]) -> std::collections::HashSet<u32> {
players.iter().map(|(player, _)| player.id).collect()
}
/// `EntityAttachment.NAME_TAG`: `(0, bbHeight, 0)` of the box of the entity's
/// current pose, which the submitter lifts 0.5 more, between the last two tick
/// positions. The player box is `Pose::dimensions(1.0)`.
/// shortcut: the scale attribute is not in the tag height, add `model_scale`
/// when tags on scaled players matter.
pub(super) fn name_tag_at(m: &Mob, alpha: f64) -> glam::DVec3 {
glam::DVec3::new(
m.previous.x + (m.position.x - m.previous.x) * alpha,
@@ -785,4 +833,63 @@ mod tests {
m.extra.crouching = false;
assert!((at_y(&m) - 2.3).abs() < 1e-6, "1.8 + 0.5");
}
/// #9477: When server plugins attach a TextDisplay or ArmorStand to a player
/// (e.g. rank prefixes on multiplayer lobbies), vanilla's `LivingEntityRenderer.shouldShowName`
/// hides the default nametag because `entity.isVehicle()` is true.
///
/// For the local player with `ownNameTag: true`, `player_model` has ID 0 while
/// the server sends passengers for `local_entity_id`. `collect_vehicle_ids`
/// maps `local_entity_id -> 0` so that `ownNameTag` is not drawn twice over the
/// server's rider nametag.
#[test]
fn vehicle_with_text_display_or_armor_stand_passenger_is_marked_ridden() {
let mut remote_player = player_mob();
remote_player.id = 42;
let mobs = vec![remote_player];
// 1. TextDisplay riding remote player 42
let ridden = collect_vehicle_ids(
&mobs,
vec![42],
std::iter::empty(),
std::iter::empty(),
None,
Some(100),
);
assert!(
ridden.contains(&42),
"remote player 42 is ridden by text display"
);
assert!(!ridden.contains(&100), "local player 100 has no passenger");
assert!(!ridden.contains(&0), "local model 0 has no passenger");
// 2. ArmorStand riding local player 100 -> model 0 must be in ridden
let ridden_local = collect_vehicle_ids(
&mobs,
std::iter::empty(),
vec![100],
std::iter::empty(),
None,
Some(100),
);
assert!(
ridden_local.contains(&100),
"local player 100 is ridden by armor stand"
);
assert!(
ridden_local.contains(&0),
"local player model (id 0) must be mapped to vehicle so ownNameTag is suppressed (#9477)"
);
// 3. Name tag is suppressed for vehicles when team is None
let view_vehicle = sb::NameView {
is_vehicle: true,
..Default::default()
};
assert!(
!sb::name_shown(None, None, view_vehicle),
"vanilla shouldShowName hides tag for vehicles with no team"
);
}
}
@@ -0,0 +1,262 @@
//! Structure templates read for server mods (vanilla's
//! `StructureTemplateManager.get`, and what `StructureTemplate.placeInWorld`
//! would place) without placing them: a builder reads a template once, then
//! places its blocks one at a time (docs/native-mods.md, "Structure
//! templates").
use super::Game;
use horizon_client::structure_block::parse_identifier;
use horizon_core::position::BlockPos;
use horizon_core::random::{LegacyRandom, RandomSource, position_seed};
use horizon_world::nbt::Compound;
use horizon_world::registry::BlockStateId;
use horizon_world::worldgen::structure::{Mirror, Rotation, StateTransforms, StructureTemplate};
/// What `native_structure_template` read: the template's size after the
/// rotation (`StructureTemplate.getSize`), and the blocks it places, in the
/// order `StructureTemplate.placeInWorld` places them.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct NativeStructureTemplate {
pub size: [i32; 3],
pub blocks: Vec<NativeStructureBlock>,
}
/// One block a template places: its position in the level, its state after
/// the mirror and the rotation (as `native_set_block` takes it), and the
/// block entity data the template saved with it (a chest's items, a sign's
/// text), without its position.
#[derive(Clone, Debug, PartialEq)]
pub struct NativeStructureBlock {
pub pos: [i32; 3],
pub state: BlockStateId,
pub nbt: Option<Compound>,
}
// Native mods use this; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// For a server mod: structure template `id` (`mymod:hut`, found as
/// `StructureTemplateManager.get` finds it: the world's saved structures,
/// then the game's and the mods' own) as `/place template id` would place
/// it at `at` with `rotation` and `mirror`, without placing anything. The
/// blocks come in vanilla's placement order (full blocks, the others, then
/// those with a block entity, each by y, x, z), from the palette
/// `/place template` picks at `at`; no processor runs, and the template's
/// entities are left out. None for an id that doesn't parse or has a `.`
/// or `..` part, a template the game doesn't have, and on a client.
/// API @API:structure_template@.
pub(super) fn native_structure_template(
&mut self,
id: &str,
at: [i32; 3],
rotation: Rotation,
mirror: Mirror,
) -> Option<NativeStructureTemplate> {
if !self.native_authoritative() {
return None;
}
let id = template_id(id)?;
let entry = self.template_entry(&id)?;
let transforms = self.state_transforms()?;
let template =
StructureTemplate::from_nbt(&entry.tag, self.pack.assets.registry, transforms);
Some(placements(&template, transforms, at, rotation, mirror))
}
}
/// `Identifier.tryParse`, less the paths the world's `generated` folder
/// refuses (`FileUtil.isPathNormalized`): one with a `.` or `..` part.
fn template_id(id: &str) -> Option<String> {
let id = parse_identifier(id)?;
let (_, path) = id.split_once(':')?;
path.split('/')
.all(|part| part != "." && part != "..")
.then_some(id)
}
/// The blocks `StructureTemplate.placeInWorld` places for `template` at `at`
/// with the pivot at its origin and no processors, as `/place template` and
/// the structure block place it: the palette `getRandomPalette` draws from a
/// random seeded with `at`, each position transformed and moved to `at`, each
/// state mirrored, then rotated. A template with no palette or a side below 1
/// places nothing.
fn placements(
template: &StructureTemplate,
transforms: &StateTransforms,
at: [i32; 3],
rotation: Rotation,
mirror: Mirror,
) -> NativeStructureTemplate {
let size = template.size(rotation);
let palettes = template.palettes();
if palettes.is_empty() || template.size.iter().any(|side| *side < 1) {
return NativeStructureTemplate {
size,
blocks: Vec::new(),
};
}
let palette = LegacyRandom::new(position_seed(BlockPos::new(at[0], at[1], at[2])))
.next_int_bound(palettes.len() as i32)
.unwrap_or(0) as usize;
let blocks = palettes[palette]
.blocks()
.iter()
.map(|block| {
let relative = StructureTemplate::transform(block.pos, mirror, rotation, [0; 3]);
NativeStructureBlock {
pos: std::array::from_fn(|i| relative[i] + at[i]),
state: transforms.rotate(transforms.mirror(block.state, mirror), rotation),
nbt: block.nbt.clone(),
}
})
.collect();
NativeStructureTemplate { size, blocks }
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::version::DATA_VERSION;
use horizon_world::nbt::Tag;
use horizon_world::registry::BlockRegistry;
// Air, stone, then a block facing north, east, south and west.
const NAMES: [&str; 6] = [
"minecraft:air",
"minecraft:stone",
"test:north",
"test:east",
"test:south",
"test:west",
];
fn registry() -> BlockRegistry {
let state = |name: &str| Tag::compound([("id", Tag::String(name.into()))]);
BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(NAMES.iter().map(|n| state(n)).collect()),
),
(
"defaults",
Tag::compound(NAMES.iter().zip(0..).map(|(n, i)| (*n, Tag::Int(i)))),
),
]))
.unwrap()
}
/// Vanilla's turns of a facing: clockwise 90, 180 and counterclockwise 90
/// (north, east, south, west), and its mirrors: left-right swaps north
/// and south, front-back east and west.
fn transforms() -> StateTransforms {
StateTransforms::from_json(
r#"{"rotate":[[0,0,0],[1,1,1],[3,4,5],[4,5,2],[5,2,3],[2,3,4]],
"mirror":[[0,0],[1,1],[4,2],[3,5],[2,4],[5,3]],
"full_block":[false,true,false,false,false,false],
"loot_containers":[],"block_entities":{}}"#,
NAMES.len(),
)
.unwrap()
}
fn named(name: &str) -> Tag {
Tag::compound([("Name", Tag::String(name.into()))])
}
fn block(pos: [i32; 3], state: i32, nbt: Option<Compound>) -> Tag {
let pos = Tag::List(pos.iter().map(|v| Tag::Int(*v)).collect());
let mut fields = vec![("pos", pos), ("state", Tag::Int(state))];
if let Some(nbt) = nbt {
fields.push(("nbt", Tag::Compound(nbt)));
}
Tag::compound(fields)
}
fn template(size: [i32; 3], palettes: Vec<Vec<Tag>>, blocks: Vec<Tag>) -> StructureTemplate {
let size = Tag::List(size.iter().map(|v| Tag::Int(*v)).collect());
let palettes = Tag::List(palettes.into_iter().map(Tag::List).collect());
let tag = Tag::compound([
("size", size),
("palettes", palettes),
("blocks", Tag::List(blocks)),
]);
StructureTemplate::from_nbt(&tag, &registry(), &transforms())
}
#[test]
fn a_template_reads_as_place_template_turns_mirrors_and_orders_it() {
let mut chest = Compound::new();
chest.insert("id".into(), Tag::String("minecraft:chest".into()));
let template = template(
[2, 1, 3],
vec![vec![
named("minecraft:stone"),
named("test:north"),
named("test:east"),
]],
vec![
block([0, 0, 1], 2, Some(chest.clone())),
block([1, 0, 2], 1, None),
block([0, 0, 0], 0, None),
],
);
let read = placements(
&template,
&transforms(),
[10, 64, 20],
Rotation::Clockwise90,
Mirror::FrontBack,
);
// A quarter turn swaps the sides along x and z.
assert_eq!(read.size, [3, 1, 2]);
// StructureTemplate.transform: front-back negates x, then a clockwise
// quarter turn about the origin takes (x, y, z) to (-z, y, x). Each
// state is mirrored, then turned: north stays north, then faces east;
// east faces west, then north. The full block comes first, the block
// entity last.
let at = |pos: [i32; 3], name: &str, nbt: Option<Compound>| NativeStructureBlock {
pos,
state: BlockStateId(NAMES.iter().position(|n| *n == name).unwrap() as u32),
nbt,
};
assert_eq!(
read.blocks,
[
at([10, 64, 20], "minecraft:stone", None),
at([8, 64, 19], "test:east", None),
at([9, 64, 20], "test:north", Some(chest)),
]
);
}
#[test]
fn a_template_with_palettes_gives_the_one_place_template_picks_there() {
let template = template(
[1, 1, 1],
vec![vec![named("minecraft:stone")], vec![named("test:west")]],
vec![block([0, 0, 0], 0, None)],
);
// Mth.getSeed(0, 0, 0) is 0, and `new Random(0).nextInt(2)` is 1
// (its first `nextInt()` is -1155484576): the second palette.
let read = placements(
&template,
&transforms(),
[0, 0, 0],
Rotation::None,
Mirror::None,
);
assert_eq!(read.blocks.len(), 1);
assert_eq!(read.blocks[0].state, BlockStateId(5));
}
#[test]
fn template_ids_parse_as_identifiers_without_dot_parts() {
assert_eq!(template_id("hut").as_deref(), Some("minecraft:hut"));
assert_eq!(
template_id("mymod:houses/hut").as_deref(),
Some("mymod:houses/hut")
);
assert_eq!(template_id("mymod:../saves/hut"), None);
assert_eq!(template_id("mymod:houses/./hut"), None);
}
}
@@ -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);
+1
View File
@@ -74,6 +74,7 @@ impl Game<'_> {
}
/// `new SocialInteractionsScreen(lastScreen)`: it opens on All, with an empty search.
pub(super) fn open_social(&mut self, from_pause: bool) {
self.hide_social_tutorial();
self.social.page = Page::All;
self.social.search.set_value("");
self.social.scroll = 0.0;
+3
View File
@@ -399,6 +399,8 @@ impl<'a> Split<'a> {
}
};
self.host.renderer = Some(renderer);
// The new atlas has no downloaded skins: queue them again, as after device loss.
self.host.skin_slots.forget();
self.host.split_window = !self.host.options.offscreen;
self.host.options.offscreen = true;
self.host.options.paced_by_split = true;
@@ -474,6 +476,7 @@ impl<'a> Split<'a> {
};
drop(old);
self.host.renderer = Some(renderer);
self.host.skin_slots.forget();
self.host.options.offscreen = false;
self.host.split_window = false;
self.host.options.paced_by_split = false;
@@ -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 {
+11
View File
@@ -49,6 +49,17 @@ impl Game<'_> {
if !container || self.chest_menu_blocked(key, &name) {
return;
}
// BaseContainerBlockEntity.canOpen: a spectator opens no container that still
// has a loot table to unpack (it clears while it rolls, on first open).
if self
.world
.chunks
.get(&(key.0 >> 4, key.2 >> 4))
.and_then(|c| c.saved.as_ref())
.is_some_and(|s| s.rolled_loot.iter().any(|r| r.pos == key))
{
return;
}
let Some(entity) = BlockEntity::for_block(&name) else {
return;
};
+15
View File
@@ -11,6 +11,21 @@ use horizon_client::{
};
impl Game<'_> {
/// `ServerPlayer.sendOverlayMessage`: the steady player sees it on this
/// game's HUD; a server client receives it as a packet.
pub(super) fn send_overlay_message(&mut self, message: Text) {
if self.lan_acting.is_some() {
self.guest_packets
.push(horizon_protocol::packets::play::Clientbound::SystemChat(
horizon_protocol::packets::play::SystemChat {
content: horizon_client::lan::data::component_nbt(&message.component()),
overlay: true,
},
));
} else if self.options.dedicated.is_none() {
self.titles.set_overlay(message, false);
}
}
/// `Hud.extractOverlayMessage`: the action bar.
pub(super) fn draw_overlay_message(&self, batch: &mut HudBatch, bar_height: i32) {
let (w, h) = (batch.width as i32, batch.height as i32);
+14 -1
View File
@@ -166,7 +166,9 @@ impl Game<'_> {
pub(super) fn tnt_prime_allowed(&mut self, pos: BlockPos) -> bool {
let mut cancel = false;
self.native_tnt_primed(self.lan_acting_uuid, [pos.x, pos.y, pos.z], &mut cancel);
!cancel
// `TntBlock.prime` leaves the block alone with `tnt_explodes` off, for
// redstone, fire and an unstable block breaking alike.
!cancel && self.ui.rules.bool("tnt_explodes")
}
/// Primes TNT a level event announced, or, when the mods refuse, puts
/// back the block the fire or signal had already taken out.
@@ -283,6 +285,10 @@ impl Game<'_> {
let (owner, guest, power) = (t.owner, t.guest, t.power);
let in_water = self.bounds_in_water(bounds);
self.tnts.remove(i);
// PrimedTnt.tick: with `tnt_explodes` off it goes away without exploding.
if !self.ui.rules.bool("tnt_explodes") {
return Ok(());
}
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
let interaction = self.destroy_type("tnt_explosion_drop_decay");
self.combat.explosion_in_water = in_water;
@@ -321,6 +327,13 @@ impl Game<'_> {
return Ok(false);
}
let pos = BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]);
if !self.ui.rules.bool("tnt_explodes") {
// TntBlock.useItemOn: the rule is off, the acting player's overlay
// says so, and PASS lets the item's own useOn light a fire there.
let message = Text::translate("block.minecraft.tnt.disabled", &[]);
self.send_overlay_message(message);
return Ok(false);
}
if !self.tnt_prime_allowed(pos) {
return Ok(true);
}
+71 -2
View File
@@ -32,6 +32,7 @@ pub(super) enum Hint {
PunchTree,
OpenInventory,
CraftPlanks,
SocialInteractions,
}
/// The observations needed on the first tick of each step.
@@ -255,6 +256,19 @@ impl super::Game<'_> {
{
return;
}
// Minecraft.tick: also show this in a single-player world once it is
// published to LAN, but not merely because its client uses memory networking.
if !self.settings.file.bool("joinedFirstServer")
&& (!self.has_singleplayer_server() || self.is_published())
{
let toast = self.tutorial_toast(Hint::SocialInteractions, Some(0.0));
self.tutorial_toasts.push(toast);
self.settings.file.set(
"joinedFirstServer",
horizon_client::options_file::V::Bool(true),
);
self.settings.save(&Self::options_path());
}
if self.screen == super::Screen::Playing
&& ([
KeyCode::KeyW,
@@ -341,6 +355,14 @@ pub(super) struct HintToast {
}
impl super::Game<'_> {
pub(super) fn hide_social_tutorial(&mut self) {
for toast in &mut self.tutorial_toasts {
if toast.hint == Hint::SocialInteractions {
toast.wanted = false;
}
}
}
pub(super) fn tutorial_toast_slots(&self) -> u32 {
self.tutorial_toasts
.iter()
@@ -392,6 +414,13 @@ impl super::Game<'_> {
"Press %1$s",
vec![],
),
Hint::SocialInteractions => (
"tutorial.socialInteractions.title",
"Social Interactions",
"tutorial.socialInteractions.description",
"Press %1$s to open social interactions",
vec![],
),
Hint::CraftPlanks => (
"tutorial.craft_planks.title",
"Craft wooden planks",
@@ -412,6 +441,7 @@ impl super::Game<'_> {
Hint::Move => vec![key("key.jump")],
Hint::PunchTree => vec![key("key.attack")],
Hint::OpenInventory => vec![key("key.inventory")],
Hint::SocialInteractions => vec![key("key.socialInteractions")],
_ => vec![],
};
let description = substitute(self.translate(description_key, description), &desc_args);
@@ -442,6 +472,9 @@ impl super::Game<'_> {
) {
let wanted = self.tutorial.hint();
for t in &mut self.tutorial_toasts {
if t.hint == Hint::SocialInteractions {
continue;
}
t.wanted = wanted.is_some_and(|(hint, _)| hint == t.hint);
if t.wanted {
t.progress = wanted.and_then(|(_, progress)| progress);
@@ -480,7 +513,9 @@ impl super::Game<'_> {
}
continue;
}
let (sound, finished) = t.slide.update_now(|_| t.wanted);
let (sound, finished) = t.slide.update_now(|fully| {
t.wanted && (t.hint != Hint::SocialInteractions || social_hint_progress(fully).0)
});
if let Some(show) = sound {
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
self.audio
@@ -490,7 +525,11 @@ impl super::Game<'_> {
self.tutorial_toasts.remove(i);
continue;
}
if let Some(progress) = t.progress {
if t.hint == Hint::SocialInteractions {
let (_, progress) = social_hint_progress(t.slide.fully_for);
t.progress = Some(progress);
t.smooth = progress;
} else if let Some(progress) = t.progress {
let delta = ((t.slide.fully_for - t.last_smooth) as f32 / 100.0).clamp(0.0, 1.0);
t.smooth += (progress - t.smooth) * delta;
t.last_smooth = t.slide.fully_for;
@@ -515,6 +554,7 @@ impl super::Game<'_> {
Hint::FindTree | Hint::PunchTree => "tree",
Hint::OpenInventory => "recipe_book",
Hint::CraftPlanks => "wooden_planks",
Hint::SocialInteractions => "social_interactions",
};
batch.sprite_scaled(
atlas,
@@ -562,10 +602,39 @@ impl super::Game<'_> {
}
}
/// TutorialToast.update uses the time fully visible (not the slide-in time),
/// and hides only after 8000 ms. Its timed bar does not use progress smoothing.
fn social_hint_progress(fully_visible_ms: i64) -> (bool, f32) {
(
fully_visible_ms <= 8000,
(fully_visible_ms as f32 / 8000.0).min(1.0),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn social_hint_fills_for_eight_seconds_before_hiding() {
assert_eq!(social_hint_progress(0), (true, 0.0));
assert_eq!(social_hint_progress(4000), (true, 0.5));
assert_eq!(social_hint_progress(8000), (true, 1.0));
assert_eq!(social_hint_progress(8001), (false, 1.0));
}
#[test]
fn social_hint_counts_from_full_visibility_and_finishes_sliding_out() {
let mut slide = horizon_client::toasts::Slide::default();
for (now, visible) in [(0, true), (600, true), (8600, true), (8601, false)] {
let (_, finished) = slide.update(now, |fully| social_hint_progress(fully).0);
assert!(!finished);
assert_eq!(social_hint_progress(slide.fully_for).0, visible);
}
let (_, finished) = slide.update(9202, |fully| social_hint_progress(fully).0);
assert!(finished);
}
#[test]
fn craft_planks_hint_toast_appears_after_delay_and_clears_on_planks() {
let mut tutorial = Tutorial::new(Step::CraftPlanks);
+1 -1
View File
@@ -578,7 +578,7 @@ mod hub_tests {
fn hub() -> Hub {
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "ArrivalsTest".into(), 4)
..NetConfig::loopback(0, "ArrivalsTest".into(), 4)
};
let mut sync = data::SyncData::empty();
// The entity types the tracker names: the player, as id 0.
+2 -2
View File
@@ -1686,7 +1686,7 @@ mod tests {
announce: false,
listen: false,
accepts_transfers: false,
..crate::lan::NetConfig::lan(0, "Transfers".into(), 8)
..crate::lan::NetConfig::loopback(0, "Transfers".into(), 8)
};
let lan =
crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config)
@@ -1771,7 +1771,7 @@ mod tests {
let _ = never.recv();
Vec::new()
}))),
..crate::lan::NetConfig::lan(0, "Owner - World".into(), 8)
..crate::lan::NetConfig::loopback(0, "Owner - World".into(), 8)
};
let lan =
crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config)
+5 -1
View File
@@ -136,7 +136,11 @@ impl Scanner {
pub fn start() -> Self {
let games: Games = Arc::default();
let stop = Arc::new(AtomicBool::new(false));
if let Ok(socket) = bind_listener() {
// Constructing multiplayer screens in unit tests must not open the LAN.
if !cfg!(test)
&& !super::test_loopback()
&& let Ok(socket) = bind_listener()
{
let (games, stop) = (games.clone(), stop.clone());
let _ = std::thread::Builder::new()
.name("horizon-lan-scan".into())
+1 -1
View File
@@ -4578,7 +4578,7 @@ mod tests {
fn hub_with_sync(sync: SyncData) -> (Hub, std::sync::mpsc::Receiver<crate::lan::Out>) {
let config = crate::lan::NetConfig {
announce: false,
..crate::lan::NetConfig::lan(0, "EffectSyncTest".into(), 2)
..crate::lan::NetConfig::loopback(0, "EffectSyncTest".into(), 2)
};
let sync = std::sync::Arc::new(sync);
let lan = Lan::start_with(sync.clone(), config).unwrap();
+50 -7
View File
@@ -218,6 +218,15 @@ pub struct NetConfig {
pub levels: crate::chunk::LevelShapes,
}
impl NetConfig {
/// A real TCP server confined to this machine, without multicast announcements.
/// Use this for socket tests; `lan` retains the public LAN behaviour.
pub fn loopback(port: u16, motd: String, max_players: usize) -> Self {
Self {
bind: Some(std::net::Ipv4Addr::LOCALHOST.into()),
announce: false,
..Self::lan(port, motd, max_players)
}
}
/// MinecraftServer.enforceSecureProfile.
pub fn enforces_secure_chat(&self) -> bool {
self.secure_chat.as_ref().is_some_and(|s| s.enforced())
@@ -635,7 +644,13 @@ impl Lan {
/// The socket [`Lan::listen`] accepts on, bound ahead so that a failure
/// can be reported before the hub is at hand.
pub fn bind(address: Option<std::net::IpAddr>, port: u16) -> io::Result<TcpListener> {
let bind = address.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
let bind = address.unwrap_or_else(|| {
if test_loopback() {
std::net::Ipv4Addr::LOCALHOST.into()
} else {
std::net::Ipv4Addr::UNSPECIFIED.into()
}
});
let listener = TcpListener::bind((bind, port))?;
listener.set_nonblocking(true)?;
Ok(listener)
@@ -654,7 +669,7 @@ impl Lan {
let thread = std::thread::Builder::new()
.name("horizon-lan-accept".into())
.spawn(move || accept_loop(listener, accept_shared, &accept_stop))?;
self._pinger = if announce {
self._pinger = if announce && !test_loopback() {
discovery::Pinger::start(self.shared.motd(), port).ok()
} else {
None
@@ -931,13 +946,41 @@ fn handle(stream: TcpStream, addr: SocketAddr, counted: Counted) {
/// HttpUtil.getAvailablePort: a free local port.
pub fn available_port() -> u16 {
TcpListener::bind(("0.0.0.0", 0))
let bind = if cfg!(test) || test_loopback() {
"127.0.0.1"
} else {
"0.0.0.0"
};
TcpListener::bind((bind, 0))
.and_then(|l| l.local_addr())
.map_or(25565, |a| a.port())
}
/// Same-machine harnesses opt in; real LAN runs keep the normal network interfaces.
pub(super) fn test_loopback() -> bool {
std::env::var_os("HORIZON_TEST_LOOPBACK").is_some_and(|v| v == "1")
}
#[cfg(test)]
mod tests {
#[test]
fn a_local_server_accepts_only_the_requested_loopback_address() {
let lan = super::Lan::start_with(
std::sync::Arc::new(super::data::SyncData::empty()),
super::NetConfig::loopback(0, "Local test".into(), 1),
)
.unwrap();
let local = std::net::TcpStream::connect(("127.0.0.1", lan.port)).unwrap();
let other = "127.0.0.2".parse::<std::net::IpAddr>().unwrap();
assert!(
std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::new(other, lan.port),
std::time::Duration::from_millis(500),
)
.is_err()
);
drop(local);
}
use super::conn::MEMORY_ADDRESS;
use super::*;
use std::{io::Read, time::Instant};
@@ -949,7 +992,7 @@ mod tests {
fn a_flood_of_silent_sockets_is_turned_away() {
let config = NetConfig {
announce: false,
..NetConfig::lan(0, "Flood".into(), 1)
..NetConfig::loopback(0, "Flood".into(), 1)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let address = (std::net::Ipv4Addr::LOCALHOST, lan.port);
@@ -994,7 +1037,7 @@ mod tests {
let config = NetConfig {
announce: false,
listen: false,
..NetConfig::lan(0, "Status".into(), 8)
..NetConfig::loopback(0, "Status".into(), 8)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
assert!(lan.shared.try_admit(MEMORY_ADDRESS, 1, "Player").is_none());
@@ -1012,7 +1055,7 @@ mod tests {
let config = NetConfig {
announce: false,
listen: false,
..NetConfig::lan(0, "Share".into(), 20)
..NetConfig::loopback(0, "Share".into(), 20)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
@@ -1046,7 +1089,7 @@ mod tests {
lang: Arc::new(Default::default()),
log_ips: true,
}),
..NetConfig::lan(0, "Room".into(), 1)
..NetConfig::loopback(0, "Room".into(), 1)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let shared = &lan.shared;
+21 -2
View File
@@ -1410,6 +1410,9 @@ pub struct Mob {
pub last_hurt: f32,
/// `LivingEntity.getAbsorptionAmount`: golden hearts that soak damage before health.
pub absorption: f32,
/// Tenths of absorbed damage a player's hit dealt, which the attack awards as
/// `Stats.DAMAGE_DEALT_ABSORBED` to the player whose turn it is (see [`Mob::soak_hit`]).
pub dealt_absorbed: i32,
pub last_damage: Option<(Damage, i64)>,
/// `DamageSource.getDirectEntity` of `last_damage` when [`Damage`] cannot name it (a
/// thrown potion, an area effect cloud): its entity type.
@@ -3053,6 +3056,7 @@ impl Mob {
air: 300,
last_hurt: 0.0,
absorption: 0.0,
dealt_absorbed: 0,
last_damage: None,
last_direct: None,
combat: combat::Tracker::default(),
@@ -3408,7 +3412,7 @@ impl Mob {
if absorbed {
self.hurt_wolf_armor(dealt);
} else {
let dealt = self.soak_absorption(dealt);
let dealt = self.soak_hit(dealt, &source);
self.health -= dealt;
}
self.last_hurt = amount;
@@ -3420,7 +3424,7 @@ impl Mob {
if absorbed {
self.hurt_wolf_armor(amount);
} else {
let through = self.soak_absorption(amount);
let through = self.soak_hit(amount, &source);
self.health -= through;
}
self.hurt_time = 10;
@@ -3784,6 +3788,21 @@ impl Mob {
|| present_ambient != self.extra.ambient_effects.contains(&effect)
|| was_visible != visible)
}
/// [`Mob::soak_absorption`] for a hit from `source`: `LivingEntity.actuallyHurt`
/// credits a `ServerPlayer` attacker with the absorbed damage in tenths.
fn soak_hit(&mut self, damage: f32, source: &Damage) -> f32 {
let through = self.soak_absorption(damage);
let absorbed = damage - through;
if absorbed > 0.0
&& absorbed < 3.402_823_5e37
&& matches!(source.attacker, Some(Who::Player))
{
self.dealt_absorbed = self
.dealt_absorbed
.saturating_add((absorbed * 10.0).round() as i32);
}
through
}
/// `LivingEntity.actuallyHurt`: the absorption amount takes the damage first;
/// what is left reaches the health.
pub fn soak_absorption(&mut self, damage: f32) -> f32 {
+19
View File
@@ -788,6 +788,25 @@ mod crack_sound_tests {
k.max_health = 100.0;
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
/// `LivingEntity.actuallyHurt`: absorbed damage from a player is credited in
/// tenths; other attackers and a hit with nothing absorbed credit nothing.
#[test]
fn player_hit_credits_dealt_absorbed() {
let mut m = golem();
m.absorption = 4.0;
assert!(m.damage(1.5, Damage::player([0.0, 0.0, 1.0])));
assert_eq!(m.dealt_absorbed, 15);
m.invulnerable = 0;
assert!(m.damage(10.0, Damage::player([0.0, 0.0, 1.0])));
assert_eq!(m.dealt_absorbed, 15 + 25);
m.invulnerable = 0;
m.damage(2.0, Damage::player([0.0, 0.0, 1.0]));
assert_eq!(m.dealt_absorbed, 40);
let mut other = golem();
other.absorption = 4.0;
other.damage(1.5, Damage::of(crate::mobs::DamageKind::Generic));
assert_eq!(other.dealt_absorbed, 0);
}
/// One hit of `amount` past the golem's hurt frames: how often it played
/// `entity.iron_golem.damage`.
fn cracks(m: &mut Mob, amount: f32) -> usize {
+2 -1
View File
@@ -26,7 +26,7 @@ pub use crate::game::{
NativeCriterion, NativeDataMapType, NativeExperienceOrb, NativeFalling, NativeHeightmap,
NativeHeldItem, NativeHit, NativeItemEntity, NativeMapDecoration, NativeMapInfo,
NativeMobRender, NativeModEvent, NativePlayer, NativePlayerRender, NativeReloadListener,
NativeShake, NativeWorldDraw,
NativeShake, NativeStructureBlock, NativeStructureTemplate, NativeWorldDraw,
};
pub use crate::gfx::native_render::{
BlockSpriteArrays, RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent,
@@ -65,6 +65,7 @@ pub use horizon_world::{
loot::{LootStack, NativeLootContext},
nbt::{Compound, Tag},
registry::BlockStateId,
worldgen::structure::{Mirror, Rotation},
};
pub use serde_json;
use serde_json::{Map, Value};
+31
View File
@@ -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")
+8 -1
View File
@@ -118,8 +118,15 @@ impl PlayerMotion {
}
Ok(())
}
/// `LocalPlayer.isMovingSlowly`: crouching, or `Entity.isVisuallyCrawling`
/// (visually swimming out of water). `LivingEntity.isVisuallySwimming`
/// counts the swimming pose and the fall-flying pose a glide that just
/// ended still holds, so the tick after an elytra lands moves at sneaking
/// speed (#8295).
pub fn moving_slowly(&self) -> bool {
self.local_crouching || (self.pose == Pose::Swimming && !self.fluids.was_touching_water)
let visually_swimming =
self.pose == Pose::Swimming || (!self.fall_flying && self.pose == Pose::FallFlying);
self.local_crouching || (visually_swimming && !self.fluids.was_touching_water)
}
}
impl MotionWorld<'_, '_, '_> {
+496 -49
View File
@@ -10,7 +10,10 @@
pub mod client;
use crate::hud::{HudAtlas, HudBatch, rgba};
use client::{PendingInviteDto, RealmAddress, RealmServer, RealmsClient, RealmsError};
use crate::text::Text;
use client::{
PendingInviteDto, RealmAddress, RealmServer, RealmsClient, RealmsError, Subscription,
};
use horizon_client_render::auth::{self, AuthSession};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -21,6 +24,8 @@ const HEADER_HEIGHT: f32 = 44.0;
const CONTENT_WIDTH: f32 = 308.0;
const ROW_WIDTH: f32 = 300.0;
const ROW_HEIGHT: f32 = 36.0;
/// `RealmsConfigureWorldScreen`'s `MenuTabBar` height (`gui/tab_header_background`).
const TAB_HEIGHT: f32 = 24.0;
/// `hud::rgba` packing (ABGR): 160 grey, vanilla's red and `RealmsPopups.COLOR_INFO`.
const GREY: u32 = 0xFFA0_A0A0;
const RED: u32 = 0xFF55_55FF;
@@ -142,6 +147,88 @@ fn extend_link(server: &RealmServer, profile: u128) -> String {
}
}
/// `RealmsConfigureWorldScreen`'s tabs, in `MenuTabBar` order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfigureTab {
Worlds,
Players,
Subscription,
Settings,
}
impl ConfigureTab {
/// The tabs in `MenuTabBar` order, which is also their index.
const ALL: [Self; 4] = [
Self::Worlds,
Self::Players,
Self::Subscription,
Self::Settings,
];
/// `RealmsWorldsTab.TITLE` and the other tabs' own titles.
fn title(self) -> &'static str {
match self {
Self::Worlds => "mco.configure.worlds.title",
Self::Players => "mco.configure.world.players.title",
Self::Subscription => "mco.configure.world.subscription.tab",
Self::Settings => "mco.configure.world.settings.title",
}
}
/// `RealmsConfigureWorldScreen.init`'s `setTabActiveState(3, !serverData.expired)`: an
/// expired Realm's settings tab is off (with the expired tooltip).
fn enabled(self, expired: bool) -> bool {
self != Self::Settings || !expired
}
}
/// The tab bar's geometry over `MenuTabBar.builder(this, width)`: the bar is at most 400
/// wide, centred, and each of the four tabs is an eighth of its inner width rounded up to an
/// even number (`Java`'s integer division before the rounding).
fn tab_geometry(w: f32) -> (f32, f32) {
let width = w.min(400.0) - 28.0;
let x = (((w - width) / 2.0).floor() / 2.0).ceil() * 2.0;
let tab = ((width / 4.0).floor() / 2.0).ceil() * 2.0;
(x, tab)
}
/// `RealmsSubscriptionTab.daysLeftPresentation`: what the days-left line says, in the
/// caller's language; empty when nothing is left to say.
fn days_left_text(days_left: i32, expired: bool) -> String {
let t = crate::lang::get;
if days_left < 0 && expired {
return t("mco.configure.world.subscription.expired");
}
if days_left <= 1 {
return t("mco.configure.world.subscription.less_than_a_day");
}
let (months, days) = (days_left / 30, days_left % 30);
match (months > 0, days > 0) {
(true, true) => crate::lang::tr(
"mco.configure.world.subscription.remaining.months.days",
&[&months, &days],
),
(true, false) => crate::lang::tr(
"mco.configure.world.subscription.remaining.months",
&[&months],
),
(false, true) => {
crate::lang::tr("mco.configure.world.subscription.remaining.days", &[&days])
}
(false, false) => String::new(),
}
}
/// `RealmsSubscriptionTab.localPresentation`: the start date in the player's own local
/// time. The process has no locale to format with, so the shape is fixed (`%Y-%m-%d`)
/// where vanilla asks for the locale's medium date.
fn start_date_text(epoch_millis: i64) -> String {
let Some(time) = chrono::DateTime::from_timestamp_millis(epoch_millis) else {
return String::new();
};
time.with_timezone(&chrono::Local)
.format("%Y-%m-%d")
.to_string()
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LayoutState {
Loading,
@@ -165,6 +252,8 @@ pub enum Popup {
Leave(RealmServer),
/// `GetServerDetailsTask.resourcePackDownloadConfirmationScreen`.
ResourcePack { pack: RealmsPack },
/// `RealmsSubscriptionTab`'s delete question (only offered once the Realm has expired).
Delete(RealmServer),
}
#[derive(Clone, Debug)]
@@ -181,7 +270,14 @@ pub enum SubScreen {
},
Configure {
server: RealmServer,
tab: ConfigureTab,
/// `RealmsClient.subscriptionFor` on a worker, then its answer (the subscription tab).
subscription: Option<Result<Subscription, RealmsError>>,
subscription_job: Option<Job<Result<Subscription, RealmsError>>>,
/// `RealmsClient.deleteRealm` on a worker, from the subscription tab's question.
deleting: Option<Job<Result<(), RealmsError>>>,
error: Option<String>,
/// `RealmsClient.open`/`close` on a worker.
job: Option<Job<Result<(), RealmsError>>>,
},
AddRealm,
@@ -337,7 +433,15 @@ impl Screen {
*invites = Some(result);
}
}
SubScreen::Configure { server, error, job } => {
SubScreen::Configure {
server,
subscription,
subscription_job,
deleting,
error,
job,
..
} => {
if let Some(result) = job.as_ref().and_then(take) {
*job = None;
match result {
@@ -349,6 +453,22 @@ impl Screen {
Err(e) => *error = Some(e.text()),
}
}
if let Some(result) = subscription_job.as_ref().and_then(take) {
*subscription_job = None;
*subscription = Some(result);
}
if let Some(result) = deleting.as_ref().and_then(take) {
*deleting = None;
match result {
// The Realm is gone: the list reloads without it.
Ok(()) => {
self.subscreen = SubScreen::Main;
self.selected = None;
refresh = true;
}
Err(e) => *error = Some(e.text()),
}
}
}
SubScreen::Connecting { .. } => return self.poll_join(),
_ => {}
@@ -455,9 +575,17 @@ impl Screen {
/// Play: `RealmsMainScreen.play` → `GetServerDetailsTask` on a worker. The screen
/// shows "Connecting to the Realm..." and stays responsive.
fn play_selected(&mut self) -> Outcome {
let Some(server) = self.selected_server().filter(|s| s.is_open()).cloned() else {
match self.selected_server() {
Some(server) => self.start_join(server.clone()),
None => Outcome::None,
}
}
/// `RealmsMainScreen.play` for `server`, the list's or the configuration screen's.
fn start_join(&mut self, server: RealmServer) -> Outcome {
if !server.is_open() {
return Outcome::None;
};
}
let Some(client) = client() else {
return Outcome::None;
};
@@ -473,6 +601,26 @@ impl Screen {
Outcome::None
}
/// `RealmsConfigureWorldScreen.init`'s `fetchSubscription`: the subscription tab's
/// data, on a worker.
fn start_configure_subscription(&mut self) {
let SubScreen::Configure {
server,
subscription_job,
..
} = &mut self.subscreen
else {
return;
};
if subscription_job.is_some() {
return;
}
if let Some(client) = client() {
let id = server.id;
*subscription_job = Some(spawn(move || client.subscription_for(id)));
}
}
pub fn click(&mut self, hit: Option<u8>, x: f32, y: f32, (w, h): (f32, f32)) -> Outcome {
match hit {
Some(1) => self.play_selected(),
@@ -481,9 +629,14 @@ impl Screen {
if let Some(server) = self.selected_server().cloned() {
self.subscreen = SubScreen::Configure {
server,
tab: ConfigureTab::Worlds,
subscription: None,
subscription_job: None,
deleting: None,
error: None,
job: None,
};
self.start_configure_subscription();
}
Outcome::None
}
@@ -538,7 +691,9 @@ impl Screen {
Outcome::None
}
Some(30) => {
if let SubScreen::Configure { server, error, job } = &mut self.subscreen
if let SubScreen::Configure {
server, error, job, ..
} = &mut self.subscreen
&& job.is_none()
&& let Some(client) = client()
{
@@ -554,6 +709,40 @@ impl Screen {
}
Outcome::None
}
// The configuration screen's tabs (60..=63) and its footer: Play (64).
Some(sel @ 60..=63) => {
if let SubScreen::Configure { server, tab, .. } = &mut self.subscreen
&& let Some(wanted) = ConfigureTab::ALL.get(usize::from(sel - 60))
&& wanted.enabled(server.expired)
{
*tab = *wanted;
}
Outcome::None
}
Some(64) => match &self.subscreen {
SubScreen::Configure { server, .. } => self.start_join(server.clone()),
_ => Outcome::None,
},
// The subscription tab's own buttons: Delete (expired only, after its question)
// and Extend.
Some(65) => {
if let SubScreen::Configure {
server, deleting, ..
} = &self.subscreen
&& server.expired
&& deleting.is_none()
{
let server = server.clone();
self.subscreen = SubScreen::Popup(Popup::Delete(server));
}
Outcome::None
}
Some(66) => match (&self.subscreen, session()) {
(SubScreen::Configure { server, .. }, Some(s)) => {
Outcome::OpenUri(extend_link(server, s.uuid))
}
_ => Outcome::None,
},
Some(40) => Outcome::OpenUri(BUY_REALMS.to_owned()),
Some(50) => self.popup_continue(),
Some(btn @ 100..=200) => {
@@ -576,6 +765,21 @@ impl Screen {
}
Outcome::None
}
// The tab bar draws its own tabs (`MenuTabBar`), so a click there carries no
// button id: the tab bar's geometry decides.
None if matches!(self.subscreen, SubScreen::Configure { .. }) => {
let (bar_x, tab_w) = tab_geometry(w);
if y < TAB_HEIGHT && x >= bar_x {
let index = ((x - bar_x) / tab_w).floor() as usize;
if let SubScreen::Configure { server, tab, .. } = &mut self.subscreen
&& let Some(wanted) = ConfigureTab::ALL.get(index)
&& wanted.enabled(server.expired)
{
*tab = *wanted;
}
}
Outcome::None
}
_ => self.click_list(x, y, (w, h)),
}
}
@@ -615,6 +819,27 @@ impl Screen {
}
Outcome::Connect(address)
}
// RealmsSubscriptionTab.deleteRealm: the question was asked over the
// configuration screen, so it answers there; the list returns once the
// Realm is gone.
Popup::Delete(server) => {
let Some(client) = client() else {
return Outcome::None;
};
let id = server.id;
self.subscreen = SubScreen::Configure {
server,
tab: ConfigureTab::Subscription,
subscription: None,
subscription_job: None,
deleting: Some(spawn(move || client.delete_realm(id))),
error: None,
job: None,
};
// A failed delete stays here and the tab reads again.
self.start_configure_subscription();
Outcome::None
}
}
}
@@ -710,22 +935,53 @@ impl Screen {
}
}
}
SubScreen::Configure { server, job, .. } => {
let label = if server.is_open() {
SubScreen::Configure {
server,
tab,
subscription,
deleting,
job,
..
} => {
// The footer: Play, the open/close toggle (vanilla keeps it on the settings
// tab; it stays here until that tab lands) and Back.
buttons.push((
cx - 155.0,
h - 26.0,
100.0,
t("mco.selectServer.play"),
server.is_open(),
64,
None,
));
let toggle = if server.is_open() {
t("mco.configure.world.buttons.close")
} else {
t("mco.configure.world.buttons.open")
};
buttons.push((
cx - 100.0,
mid + 10.0,
200.0,
label,
job.is_none(),
30,
None,
));
buttons.push((cx - 100.0, h - 36.0, 200.0, t("gui.back"), true, 32, None));
buttons.push((cx - 50.0, h - 26.0, 100.0, toggle, job.is_none(), 30, None));
buttons.push((cx + 55.0, h - 26.0, 100.0, t("gui.back"), true, 32, None));
// RealmsSubscriptionTab's own two buttons.
if *tab == ConfigureTab::Subscription && matches!(subscription, Some(Ok(_))) {
buttons.push((
cx - 100.0,
92.0,
200.0,
t("mco.configure.world.subscription.extend"),
true,
66,
None,
));
buttons.push((
cx - 100.0,
118.0,
200.0,
t("mco.configure.world.delete.button"),
server.expired && deleting.is_none(),
65,
None,
));
}
}
SubScreen::AddRealm => {
buttons.push((
@@ -895,19 +1151,6 @@ impl Screen {
buttons
}
/// `RealmsServer` expiry tooltip text (`RealmsMainScreen` status line).
fn expiry_text(server: &RealmServer) -> String {
if server.expired {
crate::lang::get("mco.selectServer.expired")
} else if server.days_left <= 0 {
crate::lang::get("mco.selectServer.expires.soon")
} else if server.days_left == 1 {
crate::lang::get("mco.selectServer.expires.day")
} else {
crate::lang::tr("mco.selectServer.expires.days", &[&server.days_left])
}
}
#[allow(clippy::too_many_arguments)]
pub fn draw(
&self,
@@ -915,7 +1158,7 @@ impl Screen {
atlas: &HudAtlas,
caret: &mut crate::ui::Caret,
focused_at: Instant,
_cursor: (f32, f32),
cursor: (f32, f32),
w: f32,
h: f32,
) {
@@ -1005,19 +1248,151 @@ impl Screen {
}
}
}
SubScreen::Configure { server, error, job } => {
let title = format!("{} {}", t("mco.configure.world.title"), server.name);
centered(batch, atlas, &title, cx, 20.0, u32::MAX);
let info_y = mid - 60.0;
centered(batch, atlas, &server.motd, cx, info_y, GREY);
centered(
SubScreen::Configure {
server,
tab,
subscription,
deleting,
error,
job,
..
} => {
// `RealmsConfigureWorldScreen`: the tab bar over `gui/tab_header_background`,
// the header separators either side of it, and the footer's separator.
crate::ui::tiled(
batch,
atlas,
&Self::expiry_text(server),
cx,
info_y + 14.0,
GREY,
"gui/tab_header_background",
0.0,
0.0,
w,
TAB_HEIGHT,
16.0,
u32::MAX,
);
let (bar_x, tab_w) = tab_geometry(w);
let now = crate::hud_overlays::now_ms();
for (i, which) in ConfigureTab::ALL.iter().enumerate() {
let x = bar_x + i as f32 * tab_w;
let selected = which == tab;
let hover = crate::ui::hovered(cursor, x, 0.0, tab_w, TAB_HEIGHT);
let sprite = match (selected, hover) {
(true, true) => "gui/sprites/widget/tab_selected_highlighted",
(true, false) => "gui/sprites/widget/tab_selected",
(false, true) => "gui/sprites/widget/tab_highlighted",
(false, false) => "gui/sprites/widget/tab",
};
crate::ui::nine_slice(
batch,
atlas,
sprite,
x,
0.0,
tab_w,
TAB_HEIGHT,
u32::MAX,
);
if selected {
crate::ui::menu_background(
batch,
atlas,
x + 2.0,
2.0,
tab_w - 4.0,
22.0,
false,
);
batch.fill(atlas, x + 2.0, 22.0, tab_w - 4.0, 1.0, u32::MAX);
}
crate::ui::menu_tab_label(
batch,
atlas,
&Text::translate(which.title(), &[]),
(x, 0.0, tab_w, TAB_HEIGHT),
selected,
which.enabled(server.expired),
now,
);
}
for (left, width) in [(0.0, bar_x), (bar_x + 4.0 * tab_w, w - bar_x - 4.0 * tab_w)]
{
crate::ui::tiled_sized(
batch,
atlas,
"gui/header_separator",
left,
TAB_HEIGHT - 2.0,
width,
2.0,
[32.0, 2.0],
u32::MAX,
);
}
crate::ui::tiled_sized(
batch,
atlas,
"gui/footer_separator",
0.0,
h - 35.0,
w,
2.0,
[32.0, 2.0],
u32::MAX,
);
let left = cx - 100.0;
match tab {
ConfigureTab::Subscription => match subscription {
Some(Ok(sub)) => {
let mut label = |y: f32, text: &str, color: u32| {
batch.text(atlas, text, left, y, color, true);
};
label(26.0, &t("mco.configure.world.subscription.start"), u32::MAX);
label(41.0, &start_date_text(sub.start_date), GREY);
let days_label = if sub.kind == "RECURRING" {
t("mco.configure.world.subscription.recurring.daysleft")
} else {
t("mco.configure.world.subscription.timeleft")
};
label(56.0, &days_label, u32::MAX);
label(71.0, &days_left_text(sub.days_left, server.expired), GREY);
for (i, line) in wrap_lines(
&t("mco.configure.world.subscription.recurring.info"),
atlas,
200.0,
)
.iter()
.enumerate()
{
label(146.0 + i as f32 * 10.0, line, GREY);
}
}
Some(Err(e)) => {
for (i, line) in
wrap_lines(&e.text(), atlas, w - 40.0).iter().enumerate()
{
centered(batch, atlas, line, cx, 60.0 + i as f32 * 10.0, RED);
}
}
None => centered(
batch,
atlas,
&t("mco.configure.world.loading"),
cx,
60.0,
u32::MAX,
),
},
// The other tabs land in follow-ups; vanilla shows this while its own
// fetches run.
_ => centered(
batch,
atlas,
&t("mco.configure.world.loading"),
cx,
60.0,
u32::MAX,
),
}
let status = if job.is_some() {
Some((
t(if server.is_open() {
@@ -1027,19 +1402,14 @@ impl Screen {
}),
u32::MAX,
))
} else if deleting.is_some() {
Some((t("mco.configure.world.buttons.delete"), u32::MAX))
} else {
error.clone().map(|e| (e, RED))
};
if let Some((text, color)) = status {
for (i, line) in wrap_lines(&text, atlas, w - 40.0).iter().enumerate() {
centered(
batch,
atlas,
line,
cx,
info_y + 32.0 + i as f32 * 10.0,
color,
);
centered(batch, atlas, line, cx, h - 60.0 + i as f32 * 10.0, color);
}
}
}
@@ -1062,6 +1432,7 @@ impl Screen {
let message = match popup {
Popup::Leave(_) => t("mco.configure.world.leave.question.line1"),
Popup::ResourcePack { .. } => t("mco.configure.world.resourcepack.question"),
Popup::Delete(_) => t("mco.configure.world.delete.question.line1"),
};
let lines = wrap_lines(&message, atlas, CONTENT_WIDTH);
let top = mid - 10.0 - 10.0 * lines.len() as f32;
@@ -1455,4 +1826,80 @@ mod tests {
assert!(!screen.escape());
assert!(matches!(screen.subscreen, SubScreen::Main));
}
/// `RealmsConfigureWorldScreen`'s `MenuTabBar`: Worlds, Players, Subscription,
/// Settings, and an expired Realm's settings tab is off.
#[test]
fn configure_tabs_follow_the_vanilla_order_and_expiry() {
assert_eq!(
ConfigureTab::ALL,
[
ConfigureTab::Worlds,
ConfigureTab::Players,
ConfigureTab::Subscription,
ConfigureTab::Settings,
]
);
for tab in ConfigureTab::ALL {
assert!(tab.enabled(false), "{}", tab.title());
}
assert!(!ConfigureTab::Settings.enabled(true));
for tab in [
ConfigureTab::Worlds,
ConfigureTab::Players,
ConfigureTab::Subscription,
] {
assert!(tab.enabled(true), "{}", tab.title());
}
}
/// `MenuTabBar.builder(this, width)`: at most 400 wide, centred, four even tabs.
#[test]
fn the_tab_bar_is_centred_and_four_tabs_wide() {
// 854x480 at GUI scale 2 is 427x240: 372 inner width, 28 from the left, 94 a tab.
assert_eq!(tab_geometry(427.0), (28.0, 94.0));
// A window narrower than 400 keeps its own width less the bar's margins:
// 272 inner width over four tabs, 14 from the left.
assert_eq!(tab_geometry(300.0), (14.0, 68.0));
}
/// `RealmsSubscriptionTab.daysLeftPresentation`, compared with the same translations
/// so the check reads in any language.
#[test]
fn days_left_says_what_vanilla_says() {
let t = crate::lang::get;
assert_eq!(
days_left_text(-1, true),
t("mco.configure.world.subscription.expired")
);
let less = t("mco.configure.world.subscription.less_than_a_day");
assert_eq!(days_left_text(0, false), less);
assert_eq!(days_left_text(1, false), less);
assert_eq!(
days_left_text(45, false),
crate::lang::tr(
"mco.configure.world.subscription.remaining.months.days",
&[&1, &15]
)
);
assert_eq!(
days_left_text(60, false),
crate::lang::tr("mco.configure.world.subscription.remaining.months", &[&2])
);
assert_eq!(
days_left_text(5, false),
crate::lang::tr("mco.configure.world.subscription.remaining.days", &[&5])
);
// An expired Realm still shows its remaining time while it has some.
assert_eq!(
days_left_text(60, true),
crate::lang::tr("mco.configure.world.subscription.remaining.months", &[&2])
);
}
/// An unreadable start date says nothing rather than a wrong date.
#[test]
fn an_unreadable_start_date_is_empty() {
assert_eq!(start_date_text(i64::MAX), "");
}
}
@@ -120,6 +120,20 @@ struct PendingInvitesResponse {
invites: Vec<PendingInviteDto>,
}
/// `Subscription`: the realm's own, as `RealmsClient.subscriptionFor` reads it.
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Subscription {
/// `Subscription.startDate`: epoch milliseconds.
#[serde(default, deserialize_with = "nullable")]
pub start_date: i64,
#[serde(default, deserialize_with = "nullable")]
pub days_left: i32,
/// `Subscription.SubscriptionType`: `NORMAL` or `RECURRING`.
#[serde(rename = "type", default, deserialize_with = "nullable")]
pub kind: String,
}
/// Why a Realms call failed (`RealmsServiceException` and its `RealmsError`). None of them
/// holds a request header: the text a screen shows can't contain the token.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -400,6 +414,19 @@ impl RealmsClient {
Self::execute(self.request("PUT", &url, READ_TIMEOUT), Some("")).map(drop)
}
/// `RealmsClient.subscriptionFor`: the configuration screen's subscription tab.
pub fn subscription_for(&self, realm_id: i64) -> Result<Subscription, RealmsError> {
let url = self.url(&format!("worlds/{realm_id}/subscription"));
let json = Self::execute(self.request("GET", &url, READ_TIMEOUT), None)?;
serde_json::from_str(&json).map_err(|_| RealmsError::Parse)
}
/// `RealmsClient.deleteRealm` (`Request.delete`).
pub fn delete_realm(&self, realm_id: i64) -> Result<(), RealmsError> {
let url = self.url(&format!("worlds/{realm_id}"));
Self::execute(self.request("DELETE", &url, READ_TIMEOUT), None).map(drop)
}
/// `RealmsClient.redeemInviteCode`: the invite-code service, with the token in
/// `x-mojangauth` as `frontendV2Request` sets it.
pub fn redeem_invite_code(&self, code: &str) -> Result<(), RealmsError> {
+4 -4
View File
@@ -208,7 +208,7 @@ impl Server {
}
fn server(resources: &Path) -> Server {
server_with(resources, NetConfig::lan(0, "Test - World".into(), 8))
server_with(resources, NetConfig::loopback(0, "Test - World".into(), 8))
}
fn server_with(resources: &Path, net: NetConfig) -> Server {
@@ -295,7 +295,7 @@ fn a_lan_guest_sees_the_signed_in_owners_skin() {
let net = NetConfig {
announce: false,
owner_profile: Some(profile.clone()),
..NetConfig::lan(0, "Test - World".into(), 8)
..NetConfig::loopback(0, "Test - World".into(), 8)
};
let mut s = server_with(&resources, net);
s.hub.singleplayer_owner = Some(offline_uuid("Owner"));
@@ -484,7 +484,7 @@ fn neoforge_shapes_items_reach_two_guests() {
sync.id("minecraft:item", "minecraft:raw_copper").unwrap(),
sync.id("minecraft:item", "minecraft:redstone").unwrap(),
);
let mut s = server_sync(sync, NetConfig::lan(0, "Test - World".into(), 8));
let mut s = server_sync(sync, NetConfig::loopback(0, "Test - World".into(), 8));
let port = s.hub.lan.port;
// Guest one joins first and stays while guest two joins and reads its
// own join packets, then guest one reads its own.
@@ -741,7 +741,7 @@ fn a_signed_in_hosts_lan_authenticates_guests_but_not_its_own_player() {
let net = NetConfig {
online: Some(Arc::new(OnlineMode::generate(false).unwrap())),
announce: false,
..NetConfig::lan(0, "Test - World".into(), 8)
..NetConfig::loopback(0, "Test - World".into(), 8)
};
let lan = Lan::start_with(sync, net).unwrap();
let guest = Connection::connect("127.0.0.1", lan.port, Duration::from_secs(5)).unwrap();
+83 -2
View File
@@ -689,9 +689,20 @@ pub fn parse_stack(snbt: &str) -> Result<Option<ItemStack>, String> {
let (tag, _) = horizon_world::nbt::parse_snbt(snbt)?;
Ok(crate::item_nbt::item_from_nbt(&tag))
}
/// The `components` compound for a stack, or None when every component is default.
/// The saved `DataComponentPatch.CODEC` compound, or None when default.
/// A filled map's empty decorations equal its prototype (`Items.mapProperties`).
pub fn encode_components(stack: &ItemStack) -> Option<Tag> {
components_tag(stack.damage, stack.data())
let mut components = components_tag(stack.damage, stack.data())?;
if &*stack.item == "minecraft:filled_map"
&& matches!(components.get("minecraft:map_decorations"), Some(Tag::Compound(entries)) if entries.is_empty())
&& let Tag::Compound(entries) = &mut components
{
entries.remove(&JavaString::from("minecraft:map_decorations"));
if entries.is_empty() {
return None;
}
}
Some(components)
}
/// `encode_components` from the damage value and extra components.
pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option<Tag> {
@@ -1108,6 +1119,76 @@ pub fn command_components(text: &str) -> Result<Tag, String> {
mod tests {
use super::*;
#[test]
fn empty_map_decoration_patch_uses_filled_map_prototype() {
let parse = |text| horizon_world::nbt::parse_snbt(text).unwrap().0;
let empty = parse(r#"{"minecraft:map_decorations":{}}"#);
assert!(decode_components(None).1.is_none());
assert!(encode_components(&ItemStack::new("minecraft:filled_map", 1)).is_none());
let mut fresh = ItemStack::new("minecraft:filled_map", 1);
fresh
.data_mut()
.other
.push(("minecraft:map_decorations".into(), parse("{}")));
// The value codec has no item prototype; only the saved patch elides it.
assert_eq!(components_tag(0, fresh.data()), Some(empty.clone()));
assert!(encode_components(&fresh).is_none());
let (damage, extra) = decode_components(Some(&empty));
let loaded = ItemStack {
damage,
extra,
..ItemStack::new("minecraft:filled_map", 1)
};
let saved = crate::item_nbt::item_to_nbt(&loaded);
assert_eq!(saved, parse(r#"{id:"minecraft:filled_map",count:1}"#));
let back = crate::item_nbt::item_from_nbt(&saved).unwrap();
assert!(encode_components(&back).is_none());
let keep = parse(r#"{"minecraft:map_decorations":{},"minecraft:custom_data":{keep:7}}"#);
let (damage, extra) = decode_components(Some(&keep));
let stack = ItemStack {
damage,
extra,
..ItemStack::new("minecraft:filled_map", 1)
};
assert_eq!(
encode_components(&stack),
Some(parse(r#"{"minecraft:custom_data":{keep:7}}"#))
);
}
#[test]
fn nondefault_map_decoration_patches_round_trip() {
let parse = |text| horizon_world::nbt::parse_snbt(text).unwrap().0;
let nonempty = parse(
r#"{"minecraft:map_decorations":{"a+b":{type:"minecraft:red_x",x:-1.25d,z:2.75d,rotation:-45.5f}},"minecraft:custom_data":{keep:7}}"#,
);
let removed =
parse(r#"{"!minecraft:map_decorations":{},"minecraft:custom_data":{keep:7}}"#);
let empty = parse(r#"{"minecraft:map_decorations":{}}"#);
for (item, components) in [
("minecraft:filled_map", &nonempty),
("minecraft:stone", &nonempty),
("minecraft:filled_map", &removed),
("minecraft:stone", &empty),
("minecraft:map", &empty),
] {
let (damage, extra) = decode_components(Some(components));
let stack = ItemStack {
damage,
extra,
..ItemStack::new(item, 1)
};
assert_eq!(
encode_components(&stack),
Some(components.clone()),
"{item}"
);
let back =
crate::item_nbt::item_from_nbt(&crate::item_nbt::item_to_nbt(&stack)).unwrap();
assert_eq!(encode_components(&back), Some(components.clone()), "{item}");
}
}
#[test]
fn components_round_trip() {
let mut stack = ItemStack::new("minecraft:diamond_sword", 1);
+3 -44
View File
@@ -8,15 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -209,12 +200,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
version = "0.2.4"
@@ -231,17 +216,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -491,7 +465,7 @@ dependencies = [
"toml",
"tower",
"ureq",
"zip 2.6.1",
"zip",
]
[[package]]
@@ -506,7 +480,7 @@ dependencies = [
"sha2",
"toml",
"wasmparser",
"zip 9.1.3",
"zip",
]
[[package]]
@@ -520,7 +494,7 @@ dependencies = [
"serde_json",
"sha2",
"toml",
"zip 9.1.3",
"zip",
]
[[package]]
@@ -1778,21 +1752,6 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "zip"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zip"
version = "9.1.3"
+2 -2
View File
@@ -27,11 +27,11 @@ getrandom = "0.3"
rusqlite = { version = "0.37", features = ["bundled"] }
# Draining a refused upload (already one of axum's).
http-body-util = "0.1"
# The upload's zip checked before any reader opens it (src/archive.rs): horizon-mods' own.
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
# A native mod's `.hnmod` for the tests (already one of horizon-mods').
zip = { version = "2", default-features = false, features = ["deflate"] }
[profile.release]
strip = true
+197
View File
@@ -0,0 +1,197 @@
//! The upload's zip, checked once before any kind's own reader opens it: every
//! reader (registry, launcher, game) then sees the same plain files.
//!
//! Refused: a local header that disagrees with the central directory (one
//! zip that reads as two), two entries for one path, overlapping entries,
//! symlinks and other special files, encrypted entries, and more entries or
//! declared bytes than any kind's reader takes.
use std::{collections::HashSet, path::Path};
/// Entries: a content mod's 65 536 files and their folders.
const MAX_ENTRIES: usize = 100_000;
/// Declared uncompressed bytes: above every kind's own total (a content mod's
/// 512 MiB and a native bundle's 64 MiB).
const MAX_UNCOMPRESSED: u64 = 1 << 30;
/// File type bits of a Unix mode: a regular file, a folder, or none named.
const S_IFMT: u32 = 0o170000;
const S_IFREG: u32 = 0o100000;
const S_IFDIR: u32 = 0o040000;
pub fn check(path: &Path) -> Result<(), String> {
check_with(path, MAX_ENTRIES, MAX_UNCOMPRESSED)
}
fn check_with(path: &Path, max_entries: usize, max_bytes: u64) -> Result<(), String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("not a zip: {e}"))?;
if zip.len() > max_entries {
return Err(format!("over {max_entries} entries in the zip"));
}
// Duplicate central names, local headers that differ from the central
// directory (name, sizes, method, flags), entries sharing bytes.
zip.validate_header_consistency()
.map_err(|e| e.to_string())?;
let mut seen = HashSet::new();
let mut total = 0u64;
for i in 0..zip.len() {
let entry = zip.by_index_raw(i).map_err(|e| e.to_string())?;
let name = entry.name().map_err(|e| e.to_string())?.into_owned();
if entry
.unix_mode()
.is_some_and(|m| ![0, S_IFREG, S_IFDIR].contains(&(m & S_IFMT)))
{
return Err(format!("{name:?} is a symlink or special file"));
}
if entry.encrypted() {
return Err(format!("{name:?} is encrypted"));
}
// One path on a case-insensitive disk, a file and a folder alike.
if !seen.insert(name.trim_end_matches('/').to_lowercase()) {
return Err(format!("{name:?} is in the zip twice"));
}
total = total.saturating_add(entry.size());
if total > max_bytes {
return Err(format!("the zip expands beyond {} MiB", max_bytes >> 20));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Cursor, Write};
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
fn stored() -> SimpleFileOptions {
SimpleFileOptions::default().compression_method(CompressionMethod::Stored)
}
fn zip_of(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in files {
zip.start_file(*name, stored()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// Replaces the `nth` (0-based, `None` = every) occurrence of `from` by
/// `to` (same length): a name in a local header comes before its central
/// directory record.
fn patch(bytes: &mut [u8], from: &[u8], to: &[u8], nth: Option<usize>) {
assert_eq!(from.len(), to.len());
let at: Vec<usize> = (0..=bytes.len() - from.len())
.filter(|&i| &bytes[i..i + from.len()] == from)
.collect();
assert!(!at.is_empty());
for (k, &i) in at.iter().enumerate() {
if nth.is_none_or(|n| n == k) {
bytes[i..i + to.len()].copy_from_slice(to);
}
}
}
fn checked(bytes: &[u8]) -> Result<(), String> {
checked_with(bytes, MAX_ENTRIES, MAX_UNCOMPRESSED)
}
fn checked_with(bytes: &[u8], entries: usize, total: u64) -> Result<(), String> {
let dir = std::env::temp_dir().join(format!(
"registry-archive-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("upload.zip");
std::fs::write(&path, bytes).unwrap();
let result = check_with(&path, entries, total);
let _ = std::fs::remove_dir_all(&dir);
result
}
#[test]
fn plain_files_and_folders_pass() {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.add_directory("assets/", stored()).unwrap();
zip.start_file("assets/a.png", stored()).unwrap();
zip.write_all(b"png").unwrap();
zip.start_file("horizon-mod.toml", SimpleFileOptions::default())
.unwrap();
zip.write_all(b"id = \"a\"").unwrap();
assert_eq!(checked(&zip.finish().unwrap().into_inner()), Ok(()));
}
#[test]
fn a_symlink_is_refused() {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("a.txt", stored()).unwrap();
zip.write_all(b"x").unwrap();
zip.add_symlink("assets/link", "/etc/passwd", stored())
.unwrap();
let err = checked(&zip.finish().unwrap().into_inner()).unwrap_err();
assert!(err.contains("\"assets/link\" is a symlink"), "{err}");
}
#[test]
fn a_fifo_is_refused() {
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("pipe", stored().external_attributes(0o010644 << 16))
.unwrap();
let err = checked(&zip.finish().unwrap().into_inner()).unwrap_err();
assert!(err.contains("special file"), "{err}");
}
/// The central directory says `good.txt`, the local header `evil.txt`: a
/// streaming reader would see another file than the checks did.
#[test]
fn a_local_header_that_differs_is_refused() {
let mut bytes = zip_of(&[("good.txt", b"x")]);
patch(&mut bytes, b"good.txt", b"evil.txt", Some(0));
assert!(checked(&bytes).unwrap_err().contains("mismatch"));
}
#[test]
fn two_entries_for_one_path_are_refused() {
let mut bytes = zip_of(&[("a.txt", b"one"), ("b.txt", b"two")]);
patch(&mut bytes, b"b.txt", b"a.txt", None);
assert!(checked(&bytes).unwrap_err().contains("duplicate"));
// Two cases of one name are one file on macOS and Windows.
let bytes = zip_of(&[("Data/x.json", b"{}"), ("data/X.json", b"{}")]);
let err = checked(&bytes).unwrap_err();
assert!(err.contains("\"data/X.json\" is in the zip twice"), "{err}");
}
#[test]
fn an_encrypted_entry_is_refused() {
let mut bytes = zip_of(&[("a.txt", b"x")]);
// Bit 0 of the general purpose flags, in the local header (offset 6)
// and the central record (offset 8): the headers still agree.
bytes[6] |= 1;
let central = (0..bytes.len() - 4)
.find(|&i| &bytes[i..i + 4] == b"PK\x01\x02")
.unwrap();
bytes[central + 8] |= 1;
assert!(checked(&bytes).unwrap_err().contains("encrypted"));
}
#[test]
fn entries_and_declared_size_are_capped() {
let bytes = zip_of(&[("a", b""), ("b", b""), ("c", b"")]);
assert_eq!(checked_with(&bytes, 3, 1 << 20), Ok(()));
assert!(
checked_with(&bytes, 2, 1 << 20)
.unwrap_err()
.contains("over 2 entries")
);
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
zip.start_file("big", SimpleFileOptions::default()).unwrap();
zip.write_all(&vec![0; 3 << 20]).unwrap();
let bytes = zip.finish().unwrap().into_inner();
assert!(bytes.len() < 1 << 20);
assert_eq!(checked_with(&bytes, 10, 4 << 20), Ok(()));
let err = checked_with(&bytes, 10, 2 << 20).unwrap_err();
assert!(err.contains("expands beyond 2 MiB"), "{err}");
}
}
+8
View File
@@ -5,6 +5,7 @@
//! request with the entry. Reviewers decide; merging publishes. Signed-in
//! players also rate published projects (`ratings`, its only data: sessions
//! are signed cookies, and GitHub holds everything else).
mod archive;
pub mod github;
pub mod ratings;
@@ -861,6 +862,7 @@ pub fn publish(
return Err(bad("a project needs a name and a licence"));
}
// The file's own checks first: a wrong file never reaches GitHub.
archive::check(file).map_err(bad)?;
let facts = index::inspect(&kind, file).map_err(bad)?;
if let Some(id) = facts.id.as_ref().filter(|id| **id != form.id) {
return Err(bad(format!("the file's id is {id:?}, not {:?}", form.id)));
@@ -909,6 +911,9 @@ pub fn publish(
license: form.license.trim().to_owned(),
source: form.source.trim().to_owned(),
icon: form.icon.clone(),
categories: Vec::new(),
tags: Vec::new(),
screenshots: Vec::new(),
key: form.key.clone().filter(|k| !k.trim().is_empty()),
versions: vec![version.clone()],
},
@@ -1183,6 +1188,9 @@ pub fn list(config: &Config, login: &str, user_id: u64, form: &Form) -> Result<S
license: form.license.trim().to_owned(),
source: String::new(),
icon: form.icon.clone(),
categories: Vec::new(),
tags: Vec::new(),
screenshots: Vec::new(),
key: form.key.clone().filter(|k| !k.trim().is_empty()),
versions: Vec::new(),
};
@@ -685,6 +685,29 @@ async fn what_the_form_refuses() {
/// The origin gate in front of a submission, before the sign-in and the
/// form: how the answer to a request from `origin`.
/// The mod's file with a symlink beside its files: refused before any kind's
/// reader opens it (src/archive.rs).
#[tokio::test(flavor = "multi_thread")]
async fn a_zip_with_a_symlink_is_refused() {
let (mut client, _mock) = setup().await;
let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap();
let mut zip = zip::ZipWriter::new_append(std::io::Cursor::new(file)).unwrap();
zip.add_symlink(
"assets/link",
"/etc/passwd",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
let linked = zip.finish().unwrap().into_inner();
let csrf = client.sign_in().await;
let (status, json) = client.submit(&csrf, &form(), &linked).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
assert_eq!(
json["error"],
"\"assets/link\" is a symlink or special file"
);
}
async fn origin_gate(app: &Router, origin: Option<&str>) -> StatusCode {
let mut request = Request::post("/api/mods/submit");
if let Some(origin) = origin {
+15 -2
View File
@@ -376,16 +376,24 @@ pub struct MobEffect {
/// the vanilla entity types a mod's type may copy: thrown item
/// projectiles (`ThrowableItemProjectile`), then living mobs
/// ([`LIVING_TEMPLATES`]).
pub const ENTITY_TEMPLATES: [&str; 3] = [
pub const ENTITY_TEMPLATES: [&str; 4] = [
"minecraft:snowball",
"minecraft:villager",
"minecraft:wandering_trader",
"minecraft:zombie",
];
/// the [`ENTITY_TEMPLATES`] that are living mobs: a type copying one is a mob
/// of its template's class (NeoForge's `EntityType.Builder.of(Zombie::new,
/// ...)`), with its own default attributes (`EntityAttributeCreationEvent`).
pub const LIVING_TEMPLATES: [&str; 2] = ["minecraft:villager", "minecraft:zombie"];
/// The wandering trader is the neutral one whose behaviour is all goals,
/// which a native mod can replace (`register_goals`); a villager's is its
/// brain.
pub const LIVING_TEMPLATES: [&str; 3] = [
"minecraft:villager",
"minecraft:wandering_trader",
"minecraft:zombie",
];
/// an entity type the mod adds: a copy of a vanilla template's. A thrown one
/// is thrown from `item` and drawn as it; a living one is its template's mob
@@ -1677,6 +1685,10 @@ capabilities = ["registries"]
"data/ruby/horizon/entity_type/settler.json",
br#"{"template": "minecraft:villager"}"#,
),
(
"data/ruby/horizon/entity_type/merchant.json",
br#"{"template": "minecraft:wandering_trader"}"#,
),
];
let content = hmod("living", MANIFEST_TEXT, files).unwrap();
let types: Vec<_> = content
@@ -1705,6 +1717,7 @@ capabilities = ["registries"]
("minecraft:movement_speed", 0.3)
]
),
("ruby:merchant", "minecraft:wandering_trader", true, vec![]),
("ruby:settler", "minecraft:villager", true, vec![]),
]
);
+95 -2
View File
@@ -51,8 +51,13 @@ pub enum Kind {
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
ResourcePack,
/// A Horizon shader pack (`.hshader`, the native WGSL format of
/// docs/shaders.md; not the shaders/-folder packs, which stay on Modrinth).
/// docs/shaders.md).
Shader,
/// A `shaders/`-folder shader pack (`.zip`, the format `horizon-shaders` loads), listed by
/// its author under a licence that allows it.
ShaderPack,
/// A data pack (`.zip` with `pack.mcmeta` and `data/`), installed into a world.
DataPack,
Other(String),
}
@@ -64,6 +69,8 @@ impl Kind {
Kind::Modpack => "modpack",
Kind::ResourcePack => "resourcepack",
Kind::Shader => "shader",
Kind::ShaderPack => "shaderpack",
Kind::DataPack => "datapack",
Kind::Other(name) => name,
}
}
@@ -76,6 +83,7 @@ impl Kind {
Kind::Modpack => Some("hpack"),
Kind::ResourcePack => Some("zip"),
Kind::Shader => Some(horizon_shader_api::EXTENSION),
Kind::ShaderPack | Kind::DataPack => Some("zip"),
Kind::Other(_) => None,
}
}
@@ -89,6 +97,8 @@ impl From<String> for Kind {
"modpack" => Kind::Modpack,
"resourcepack" => Kind::ResourcePack,
"shader" => Kind::Shader,
"shaderpack" => Kind::ShaderPack,
"datapack" => Kind::DataPack,
_ => Kind::Other(name),
}
}
@@ -122,6 +132,15 @@ pub struct Project {
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// From the kind's list ([`page::categories`]), at most 3.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub categories: Vec<String>,
/// Free-form, lowercase, at most 10.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// `https://` addresses of PNG or JPEG images, in order, at most 8.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub screenshots: Vec<String>,
/// The author's minisign public key: every version must be signed with
/// it. None: unsigned (the SHA-256 still pins the reviewed file).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -258,6 +277,16 @@ impl Index {
if let Some(icon) = &p.icon {
https(icon).map_err(at)?;
}
if p.screenshots.len() > page::MAX_SCREENSHOTS {
return Err(at(format!(
"{} screenshots, at most {}",
p.screenshots.len(),
page::MAX_SCREENSHOTS
)));
}
for s in &p.screenshots {
https(s).map_err(at)?;
}
if let Some(key) = &p.key {
minisign_verify::PublicKey::from_base64(key.trim())
.map_err(|e| at(format!("key: {e}")))?;
@@ -625,6 +654,14 @@ pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
..Facts::default()
})
}
Kind::ShaderPack => Ok(Facts {
api: check_shader_folder_pack(path)?,
..Facts::default()
}),
Kind::DataPack => Ok(Facts {
api: check_data_pack(path)?,
..Facts::default()
}),
Kind::Other(name) => Err(format!("this Horizon can't read a {name} yet")),
}
}
@@ -633,8 +670,15 @@ pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
/// object, and only plain paths. Returns its pack format (`pack_format`, or
/// `min_format` in newer packs), empty when it names none.
fn check_resource_pack(path: &Path) -> Result<String, String> {
let (mut zip, _) = plain_zip(path)?;
pack_format(&mut zip)
}
/// A zip whose paths are all plain, and those paths.
fn plain_zip(path: &Path) -> Result<(zip::ZipArchive<std::fs::File>, Vec<String>), String> {
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
let mut names = Vec::with_capacity(zip.len());
for i in 0..zip.len() {
let name = zip
.by_index_raw(i)
@@ -646,7 +690,49 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
{
return Err(format!("{name:?} is not a plain path"));
}
names.push(name);
}
Ok((zip, names))
}
/// A data pack: a resource pack's rules, and a `data/` folder at the root.
fn check_data_pack(path: &Path) -> Result<String, String> {
let (mut zip, names) = plain_zip(path)?;
if !names.iter().any(|n| n.starts_with("data/")) {
return Err("no data/ folder at the zip's root: not a data pack".into());
}
pack_format(&mut zip)
}
/// A `shaders/`-folder shader pack: plain paths and a `shaders/` folder at the root. Its format
/// has no version of its own: the facts' `api` is empty.
fn check_shader_folder_pack(path: &Path) -> Result<String, String> {
let (_, names) = plain_zip(path)?;
if !names.iter().any(|n| n.starts_with("shaders/")) {
return Err("no shaders/ folder at the zip's root: not a shader pack".into());
}
Ok(String::new())
}
/// What a zip's file is, by its content: a `shaders/` folder makes a shader pack, a `data/` folder
/// without `assets/` a data pack, anything else a resource pack (checked as one).
pub fn zip_kind(path: &Path) -> Kind {
let Ok((_, names)) = plain_zip(path) else {
return Kind::ResourcePack;
};
let root = |dir: &str| names.iter().any(|n| n.starts_with(dir));
if root("shaders/") {
Kind::ShaderPack
} else if root("data/") && !root("assets/") {
Kind::DataPack
} else {
Kind::ResourcePack
}
}
/// `pack.mcmeta`'s pack format (`pack_format`, or `min_format` in newer packs), empty when it
/// names none; refused when it has no `pack` object.
fn pack_format(zip: &mut zip::ZipArchive<std::fs::File>) -> Result<String, String> {
let mut meta = String::new();
zip.by_name("pack.mcmeta")
.map_err(|_| "pack.mcmeta: missing".to_string())?
@@ -678,7 +764,13 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
/// formats.
pub fn open(kind: &Kind) -> Result<(), String> {
match kind {
Kind::Mod | Kind::NativeMod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
Kind::Mod
| Kind::NativeMod
| Kind::Modpack
| Kind::ResourcePack
| Kind::Shader
| Kind::ShaderPack
| Kind::DataPack => Ok(()),
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
}
}
@@ -689,6 +781,7 @@ fn names(caps: &[Capability]) -> String {
}
pub mod native;
pub mod page;
pub mod registry;
#[cfg(test)]
mod tests;
+406
View File
@@ -0,0 +1,406 @@
//! A project page's rules (docs/design/registry-v2.md): its categories, tags,
//! description and images. Shared by the registry's CI ([`super::registry`]),
//! the submission service and the index's readers.
use super::Kind;
/// At most this many categories, at least one once [`REQUIRED`].
pub const MAX_CATEGORIES: usize = 3;
pub const MAX_TAGS: usize = 10;
pub const MAX_TAG: usize = 24;
pub const MAX_SCREENSHOTS: usize = 8;
pub const MIN_DESCRIPTION: usize = 50;
pub const MAX_DESCRIPTION: usize = 10_000;
/// The biggest screenshot file.
pub const MAX_SCREENSHOT: u64 = 1 << 20;
/// A screenshot's smallest and biggest size, in pixels.
pub const SCREENSHOT_MIN: (u32, u32) = (320, 180);
pub const SCREENSHOT_MAX: (u32, u32) = (3840, 2160);
/// An icon is square, this many pixels a side.
pub const ICON_SIDE: std::ops::RangeInclusive<u32> = 64..=1024;
/// Whether a page must have a description, a category and an icon. Off until
/// every listed project has them (the rollout's step 4).
pub const REQUIRED: bool = false;
const MODS: &[&str] = &[
"adventure",
"decoration",
"economy",
"equipment",
"food",
"game-mechanics",
"library",
"magic",
"management",
"minigame",
"mobs",
"optimization",
"social",
"storage",
"technology",
"transportation",
"utility",
"worldgen",
];
const MODPACKS: &[&str] = &[
"adventure",
"challenging",
"combat",
"kitchen-sink",
"lightweight",
"magic",
"multiplayer",
"optimization",
"quests",
"technology",
];
const RESOURCE_PACKS: &[&str] = &[
"8x",
"16x",
"32x",
"48x",
"64x",
"128x",
"256x",
"512x",
"audio",
"blocks",
"combat",
"core-shaders",
"decoration",
"entities",
"environment",
"equipment",
"fonts",
"gui",
"items",
"locale",
"models",
"realistic",
"simplistic",
"themed",
"tweaks",
"utility",
"vanilla-like",
];
const SHADERS: &[&str] = &[
"cartoon",
"fantasy",
"realistic",
"semi-realistic",
"vanilla-like",
"atmosphere",
"bloom",
"colored-lighting",
"foliage",
"path-tracing",
"pbr",
"reflections",
"shadows",
"potato",
"low",
"medium",
"high",
"screenshot",
];
/// Words a tag may not hold: no listing is about gambling (Mojang's Usage
/// Guidelines name it for every use).
const REFUSED_TAG_WORDS: &[&str] = &[
"casino",
"gambl",
"slot-machine",
"lottery",
"betting",
"jackpot",
"roulette",
"poker",
];
/// The categories a project of `kind` picks from: Modrinth's lists, without
/// `cursed`. Empty for a kind this build doesn't know.
pub fn categories(kind: &Kind) -> &'static [&'static str] {
match kind {
Kind::Mod | Kind::NativeMod | Kind::DataPack => MODS,
Kind::Modpack => MODPACKS,
Kind::ResourcePack => RESOURCE_PACKS,
Kind::Shader | Kind::ShaderPack => SHADERS,
Kind::Other(_) => &[],
}
}
/// `[a-z0-9][a-z0-9-]*`, at most [`MAX_TAG`] long.
fn plain_tag(tag: &str) -> bool {
tag.len() <= MAX_TAG
&& tag
.bytes()
.next()
.is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
&& tag
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// Every problem with a page's description, categories and tags, each a
/// sentence a submitter can act on.
pub fn check(
kind: &Kind,
description: &str,
categories: &[String],
tags: &[String],
) -> Vec<String> {
let mut errors = Vec::new();
let length = description.trim().chars().count();
if length > MAX_DESCRIPTION {
errors.push(format!(
"the description is {length} characters: at most {MAX_DESCRIPTION}"
));
} else if REQUIRED && length < MIN_DESCRIPTION {
errors.push(format!(
"the description is {length} characters: write at least {MIN_DESCRIPTION} (what it does, how to use it)"
));
}
let known = self::categories(kind);
if REQUIRED && categories.is_empty() {
errors.push(format!("pick 1 to {MAX_CATEGORIES} categories"));
}
if categories.len() > MAX_CATEGORIES {
errors.push(format!(
"{} categories: pick at most {MAX_CATEGORIES}",
categories.len()
));
}
for (i, c) in categories.iter().enumerate() {
if !known.contains(&c.as_str()) {
errors.push(format!(
"{c:?} is not a category of a {}: {}",
kind.name(),
known.join(", ")
));
} else if categories[..i].contains(c) {
errors.push(format!("category {c:?} is listed twice"));
}
}
if tags.len() > MAX_TAGS {
errors.push(format!("{} tags: at most {MAX_TAGS}", tags.len()));
}
for (i, t) in tags.iter().enumerate() {
if !plain_tag(t) {
errors.push(format!(
"tag {t:?}: lowercase letters, digits and '-', at most {MAX_TAG} characters"
));
} else if REFUSED_TAG_WORDS.iter().any(|w| t.contains(w)) {
errors.push(format!(
"tag {t:?}: the registry lists nothing about gambling"
));
} else if tags[..i].contains(t) {
errors.push(format!("tag {t:?} is listed twice"));
}
}
errors
}
/// An image's format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Image {
Png,
Jpeg,
}
impl Image {
/// The extensions its files carry.
pub fn extensions(self) -> &'static [&'static str] {
match self {
Image::Png => &["png"],
Image::Jpeg => &["jpg", "jpeg"],
}
}
}
/// The format and size in pixels of a PNG or a JPEG, read from its header;
/// None for anything else.
pub fn image_size(bytes: &[u8]) -> Option<(Image, u32, u32)> {
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n";
if bytes.starts_with(PNG) && bytes.get(12..16) == Some(b"IHDR") {
let w = u32::from_be_bytes(bytes.get(16..20)?.try_into().ok()?);
let h = u32::from_be_bytes(bytes.get(20..24)?.try_into().ok()?);
return Some((Image::Png, w, h));
}
if !bytes.starts_with(&[0xff, 0xd8]) {
return None;
}
// JPEG: walk the segments to the first start-of-frame.
let mut at = 2;
loop {
while *bytes.get(at)? == 0xff && *bytes.get(at + 1)? == 0xff {
at += 1;
}
if *bytes.get(at)? != 0xff {
return None;
}
let marker = *bytes.get(at + 1)?;
let length = u16::from_be_bytes([*bytes.get(at + 2)?, *bytes.get(at + 3)?]) as usize;
let frame = matches!(marker, 0xc0..=0xcf) && !matches!(marker, 0xc4 | 0xc8 | 0xcc);
if frame {
let h = u16::from_be_bytes([*bytes.get(at + 5)?, *bytes.get(at + 6)?]);
let w = u16::from_be_bytes([*bytes.get(at + 7)?, *bytes.get(at + 8)?]);
return Some((Image::Jpeg, u32::from(w), u32::from(h)));
}
if length < 2 {
return None;
}
at += 2 + length;
}
}
/// Checks an icon's bytes: a square PNG of [`ICON_SIDE`] pixels.
pub fn check_icon(bytes: &[u8]) -> Result<(), String> {
match image_size(bytes) {
Some((Image::Png, w, h)) if w == h && ICON_SIDE.contains(&w) => Ok(()),
Some((Image::Png, w, h)) => Err(format!(
"the icon is {w}×{h}: make it square, {} to {} pixels a side",
ICON_SIDE.start(),
ICON_SIDE.end()
)),
_ => Err("the icon must be a PNG".into()),
}
}
/// Checks a screenshot's bytes and its file name's extension: a PNG or a
/// JPEG within [`SCREENSHOT_MIN`] and [`SCREENSHOT_MAX`], at most
/// [`MAX_SCREENSHOT`] bytes.
pub fn check_screenshot(name: &str, bytes: &[u8]) -> Result<(), String> {
if bytes.len() as u64 > MAX_SCREENSHOT {
return Err(format!(
"{name}: {} KiB, at most {} KiB",
bytes.len() >> 10,
MAX_SCREENSHOT >> 10
));
}
let Some((format, w, h)) = image_size(bytes) else {
return Err(format!("{name}: not a PNG or a JPEG"));
};
let extension = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
if !extension.is_some_and(|e| format.extensions().contains(&e.as_str())) {
return Err(format!(
"{name}: its extension isn't .{}",
format.extensions()[0]
));
}
let ((min_w, min_h), (max_w, max_h)) = (SCREENSHOT_MIN, SCREENSHOT_MAX);
if w < min_w || h < min_h || w > max_w || h > max_h {
return Err(format!(
"{name}: {w}×{h} pixels, from {min_w}×{min_h} to {max_w}×{max_h}"
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn categories_are_the_kinds_own() {
let ok = check(&Kind::Mod, "", &strings(&["magic", "mobs"]), &[]);
assert!(ok.is_empty(), "{ok:?}");
// A shader category on a mod, a repeat, and one too many.
let wrong = check(&Kind::Mod, "", &strings(&["bloom"]), &[]);
assert!(wrong[0].contains("not a category of a mod"), "{wrong:?}");
let twice = check(&Kind::Shader, "", &strings(&["pbr", "pbr"]), &[]);
assert_eq!(twice, ["category \"pbr\" is listed twice"]);
let many = check(
&Kind::Mod,
"",
&strings(&["magic", "mobs", "food", "storage"]),
&[],
);
assert_eq!(many, ["4 categories: pick at most 3"]);
assert!(categories(&Kind::Other("world".into())).is_empty());
}
#[test]
fn tags_are_plain_and_never_about_gambling() {
let ok = check(&Kind::Mod, "", &[], &strings(&["bosses", "1-21"]));
assert!(ok.is_empty(), "{ok:?}");
for bad in ["Bosses", "two words", "-x", "", &"a".repeat(25)] {
assert_eq!(
check(&Kind::Mod, "", &[], &[bad.to_owned()]).len(),
1,
"{bad:?}"
);
}
let casino = check(&Kind::Mod, "", &[], &strings(&["casino-games"]));
assert!(casino[0].contains("gambling"), "{casino:?}");
let eleven: Vec<String> = (0..11).map(|i| format!("t{i}")).collect();
assert_eq!(check(&Kind::Mod, "", &[], &eleven), ["11 tags: at most 10"]);
}
#[test]
fn a_description_has_a_ceiling() {
let long = "x".repeat(MAX_DESCRIPTION + 1);
assert_eq!(check(&Kind::Mod, &long, &[], &[]).len(), 1);
assert!(check(&Kind::Mod, &"x".repeat(MAX_DESCRIPTION), &[], &[]).is_empty());
}
fn png(w: u32, h: u32) -> Vec<u8> {
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
b.extend(w.to_be_bytes());
b.extend(h.to_be_bytes());
b.extend([8, 6, 0, 0, 0]);
b
}
fn jpeg(w: u16, h: u16) -> Vec<u8> {
// SOI, an APP0 segment of 16 bytes, then a baseline SOF0.
let mut b = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16];
b.extend([0; 14]);
b.extend([0xff, 0xc0, 0, 17, 8]);
b.extend(h.to_be_bytes());
b.extend(w.to_be_bytes());
b.extend([3; 12]);
b
}
#[test]
fn image_headers_give_their_size() {
assert_eq!(image_size(&png(256, 128)), Some((Image::Png, 256, 128)));
assert_eq!(
image_size(&jpeg(1920, 1080)),
Some((Image::Jpeg, 1920, 1080))
);
assert_eq!(image_size(b"GIF89a"), None);
assert_eq!(image_size(&[0xff, 0xd8, 0xff]), None);
}
#[test]
fn icons_are_square_pngs() {
assert!(check_icon(&png(256, 256)).is_ok());
assert!(check_icon(&png(256, 128)).unwrap_err().contains("square"));
assert!(check_icon(&png(32, 32)).is_err());
assert!(check_icon(&jpeg(256, 256)).unwrap_err().contains("PNG"));
}
#[test]
fn screenshots_are_bounded() {
assert!(check_screenshot("1.jpg", &jpeg(1920, 1080)).is_ok());
assert!(check_screenshot("1.png", &png(1280, 720)).is_ok());
assert!(
check_screenshot("1.png", &jpeg(1920, 1080))
.unwrap_err()
.contains(".jpg")
);
assert!(
check_screenshot("1.png", &png(100, 100))
.unwrap_err()
.contains("pixels")
);
assert!(check_screenshot("1.png", &png(7680, 4320)).is_err());
let mut big = png(1920, 1080);
big.resize(MAX_SCREENSHOT as usize + 1, 0);
assert!(check_screenshot("1.png", &big).unwrap_err().contains("KiB"));
}
}
+87 -28
View File
@@ -7,7 +7,9 @@
//! What an entry can't say for itself (a mod's API, side and capabilities,
//! a modpack's mods, a file's size) is read from the file, never typed.
pub use super::open;
use super::{File, Index, Kind, Project, Revoked, SCHEMA, Version, hex64, inspect, valid_id};
use super::{
File, Index, Kind, Project, Revoked, SCHEMA, Version, hex64, inspect, page as rules, valid_id,
};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
@@ -57,6 +59,16 @@ pub struct Entry {
/// `icons/<id>.png` in the registry repository, or an `https://` address.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
/// 1 to 3 of the kind's categories ([`rules::categories`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub categories: Vec<String>,
/// Free-form, lowercase, at most 10.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// At most 8, in order: `screenshots/<id>/<name>.png|.jpg` in the registry repository, or
/// `https://` addresses.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub screenshots: Vec<String>,
/// The author's minisign public key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
@@ -197,6 +209,19 @@ impl Entry {
if self.summary.chars().count() > 160 {
errors.push(format!("{id}: the summary is over 160 characters"));
}
for e in rules::check(&self.kind, &self.description, &self.categories, &self.tags) {
errors.push(format!("{id}: {e}"));
}
if rules::REQUIRED && self.icon.is_none() {
errors.push(format!("{id}: has no icon (icons/{id}.png, a square PNG)"));
}
if self.screenshots.len() > rules::MAX_SCREENSHOTS {
errors.push(format!(
"{id}: {} screenshots, at most {}",
self.screenshots.len(),
rules::MAX_SCREENSHOTS
));
}
errors
}
}
@@ -336,33 +361,15 @@ pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
if !valid_id(id) || RESERVED.contains(&id.as_str()) {
return Err(errors);
}
let icon = match entry.icon.as_deref() {
None => None,
Some(url) if url.starts_with("https://") => Some(url.to_owned()),
Some(path) => {
let ok = path
.strip_prefix("icons/")
.is_some_and(|name| super::plain_name(name) && name.ends_with(".png"));
let size = std::fs::metadata(src.registry.join(path)).map(|m| m.len());
match (ok, size) {
(true, Ok(n)) if n <= MAX_ICON => Some(format!("{}{path}", src.site)),
(true, Ok(_)) => {
errors.push(format!("{id}: {path} is over {} KiB", MAX_ICON >> 10));
None
}
(true, Err(e)) => {
errors.push(format!("{id}: {path}: {e}"));
None
}
(false, _) => {
errors.push(format!(
"{id}: icon {path:?} is neither icons/<name>.png nor https://"
));
None
}
}
}
};
let icon = entry
.icon
.as_deref()
.and_then(|icon| image(src, id, icon, "icons/", &mut errors));
let screenshots: Vec<String> = entry
.screenshots
.iter()
.filter_map(|s| image(src, id, s, &format!("screenshots/{id}/"), &mut errors))
.collect();
if entry.versions.is_empty() && cache.versions.is_empty() {
errors.push(format!("{id}: has no version"));
}
@@ -394,12 +401,64 @@ pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
license: entry.license.clone(),
source: entry.source.clone(),
icon,
categories: entry.categories.clone(),
tags: entry.tags.clone(),
screenshots,
key: entry.key.as_ref().map(|k| k.trim().to_owned()),
versions,
downloads: 0,
})
}
/// An icon (`folder` = `icons/`) or a screenshot (`screenshots/<id>/`) as the index lists it: an
/// `https://` address as it is, or a file in the registry repository checked and served from the
/// registry's site.
fn image(
src: &Sources,
id: &str,
path: &str,
folder: &str,
errors: &mut Vec<String>,
) -> Option<String> {
if path.starts_with("https://") {
return Some(path.to_owned());
}
let icon = folder == "icons/";
let name = path.strip_prefix(folder).filter(|n| super::plain_name(n));
let Some(name) = name else {
errors.push(format!(
"{id}: {path:?} is neither {folder}<name> nor https://"
));
return None;
};
let max = if icon {
MAX_ICON
} else {
rules::MAX_SCREENSHOT
};
let checked = std::fs::metadata(src.registry.join(path))
.map_err(|e| format!("{path}: {e}"))
.and_then(|m| {
if m.len() > max {
return Err(format!("{path} is over {} KiB", max >> 10));
}
let bytes =
std::fs::read(src.registry.join(path)).map_err(|e| format!("{path}: {e}"))?;
if icon {
rules::check_icon(&bytes).map_err(|e| format!("{path}: {e}"))
} else {
rules::check_screenshot(name, &bytes)
}
});
match checked {
Ok(()) => Some(format!("{}{path}", src.site)),
Err(e) => {
errors.push(format!("{id}: {e}"));
None
}
}
}
/// The kind of a version's file at `path`: the project's, but a `.hmod` without native code of a
/// mod that turned native (`review`) is from before it did.
fn file_kind(entry: &Entry, v: &EntryVersion, path: &Path) -> Kind {
@@ -86,6 +86,15 @@ reason = "Sent chat messages it didn't declare."
date = "2026-10-24"
"#;
/// The header of a PNG of `w`×`h` pixels: what the registry reads of an image.
fn png(w: u32, h: u32) -> Vec<u8> {
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
b.extend(w.to_be_bytes());
b.extend(h.to_be_bytes());
b.extend([8, 6, 0, 0, 0]);
b
}
/// A registry checkout and its downloaded files, in a scratch folder.
struct Scratch(PathBuf);
@@ -102,7 +111,7 @@ impl Scratch {
s.write("mods/cozy.toml", COZY);
s.write("mods/glow.toml", GLOW);
s.write("revoked.toml", REVOKED_TOML);
s.write("icons/coords_hud.png", "png");
s.write_bytes("icons/coords_hud.png", &png(128, 128));
for (name, hash) in [
("coords_hud-0.1.0.hmod", HMOD),
("cozy-1.0.0.hpack", HPACK),
@@ -117,6 +126,12 @@ impl Scratch {
std::fs::write(self.0.join("repo").join(path), text).unwrap();
}
fn write_bytes(&self, path: &str, bytes: &[u8]) {
let path = self.0.join("repo").join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn build(&self) -> Result<Index, Vec<String>> {
let (repo, files) = (self.0.join("repo"), self.0.join("files"));
build(&Sources {
@@ -335,10 +350,50 @@ fn a_mod_may_turn_native_once() {
);
}
#[test]
fn page_fields_and_screenshots_reach_the_index() {
let s = Scratch::new("page");
let page = "categories = [\"utility\"]\ntags = [\"hud\", \"coordinates\"]\n\
screenshots = [\"screenshots/coords_hud/1.png\", \"https://x.test/2.jpg\"]\n";
s.write("mods/coords_hud.toml", &format!("{page}{}", hud_entry()));
s.write_bytes("screenshots/coords_hud/1.png", &png(1280, 720));
let built = s.build().unwrap();
let p = built.project("coords_hud").unwrap();
assert_eq!(p.categories, ["utility"]);
assert_eq!(p.tags, ["hud", "coordinates"]);
assert_eq!(
p.screenshots,
[
"https://horizon-rust.github.io/horizon-mods/screenshots/coords_hud/1.png",
"https://x.test/2.jpg"
]
);
Index::parse(&serde_json::to_string(&built).unwrap()).unwrap();
// A category of another kind, a screenshot too small, one in another project's folder, and an
// icon that isn't square.
let bad = "categories = [\"bloom\"]\n\
screenshots = [\"screenshots/coords_hud/1.png\", \"screenshots/other/2.png\"]\n";
s.write("mods/coords_hud.toml", &format!("{bad}{}", hud_entry()));
s.write_bytes("screenshots/coords_hud/1.png", &png(100, 100));
s.write_bytes("icons/coords_hud.png", &png(128, 64));
let errors = s.build().unwrap_err();
let has = |part: &str| errors.iter().any(|e| e.contains(part));
assert!(has("\"bloom\" is not a category of a mod"), "{errors:?}");
assert!(has("1.png: 100×100 pixels"), "{errors:?}");
assert!(
has("\"screenshots/other/2.png\" is neither screenshots/coords_hud/"),
"{errors:?}"
);
assert!(has("the icon is 128×64"), "{errors:?}");
assert_eq!(errors.len(), 4, "{errors:?}");
}
#[test]
fn only_open_kinds_are_taken() {
assert!(open(&Kind::Mod).is_ok() && open(&Kind::ResourcePack).is_ok());
assert!(open(&Kind::Shader).is_ok());
assert!(open(&Kind::Shader).is_ok() && open(&Kind::ShaderPack).is_ok());
assert!(open(&Kind::DataPack).is_ok());
assert!(open(&Kind::Other("world".into())).is_err());
let mut submit: Entry = toml::from_str(COZY).unwrap();
submit.id = "submit".into();
+56
View File
@@ -182,6 +182,62 @@ fn resource_packs_need_a_pack_mcmeta() {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn data_packs_and_shader_folder_packs_are_read_by_their_content() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("horizon-index-dp-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut n = 0;
let mut pack = |files: &[&str]| {
n += 1;
let path = dir.join(format!("{n}.zip"));
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
for name in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
let text = if *name == "pack.mcmeta" {
r#"{"pack": {"description": "D", "pack_format": 94}}"#
} else {
""
};
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
path
};
let data = pack(&["pack.mcmeta", "data/x/function/a.mcfunction"]);
assert_eq!(inspect(&Kind::DataPack, &data).unwrap().api, "94");
assert_eq!(zip_kind(&data), Kind::DataPack);
let looks = pack(&["pack.mcmeta", "assets/x/textures/a.png"]);
let e = inspect(&Kind::DataPack, &looks).unwrap_err();
assert!(e.contains("data/"), "{e}");
assert_eq!(zip_kind(&looks), Kind::ResourcePack);
let no_meta = pack(&["data/x/function/a.mcfunction"]);
assert!(
inspect(&Kind::DataPack, &no_meta)
.unwrap_err()
.contains("missing")
);
let shaders = pack(&["shaders/composite.fsh", "shaders/lang/en_us.lang"]);
assert_eq!(inspect(&Kind::ShaderPack, &shaders).unwrap().api, "");
assert_eq!(zip_kind(&shaders), Kind::ShaderPack);
let e = inspect(&Kind::ShaderPack, &looks).unwrap_err();
assert!(e.contains("shaders/"), "{e}");
let sneaky = pack(&["shaders/a.fsh", "../../x"]);
assert!(
inspect(&Kind::ShaderPack, &sneaky)
.unwrap_err()
.contains("plain path")
);
// Both are zips, and a reader from before them keeps them as unknown kinds.
for kind in [Kind::DataPack, Kind::ShaderPack] {
assert_eq!(kind.extension(), Some("zip"));
assert!(open(&kind).is_ok());
assert_eq!(Kind::from(kind.name().to_owned()), kind);
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_newest_version_this_horizon_runs_is_installed() {
let mut index = sample();
+25 -10
View File
@@ -65,6 +65,27 @@ pub struct Row {
/// The same in plain words for a player. Empty in a `coverage.json` an older build wrote.
#[serde(default)]
pub plain: String,
/// Unported dependencies, kept apart so the launcher can translate the fixed sentence.
#[serde(default)]
pub waiting: usize,
}
impl Row {
/// The standalone coverage report stays English, including the dependency count.
pub fn plain_text(&self) -> String {
let mut plain = self.plain.clone();
match self.waiting {
0 => {}
1 => plain.push_str(" It also waits for 1 mod it depends on that is not ported yet."),
n => {
let _ = write!(
plain,
" It also waits for {n} mods it depends on that are not ported yet."
);
}
}
plain
}
}
/// The licence family of an SPDX id or a `mods.toml` licence text.
@@ -282,6 +303,7 @@ fn resource_row(path: &str, outcome: Option<&Outcome>, record: Option<&Record>)
} else {
Status::NotYet
},
waiting: 0,
plain: match (shader, outcome.map(|o| o.state)) {
(true, _) => "Shader packs are not converted yet.",
(false, Some(State::Available)) => "Copied as a resource pack; Horizon has not checked how it renders. Turn it on in the game's Resource Packs screen.",
@@ -337,6 +359,7 @@ pub fn build(records: &[Record], work: &Path) -> Vec<Row> {
license_kind: license_kind(&r.license).to_owned(),
status,
reason,
waiting: 0,
plain: match status {
Status::NotYet => plain_not_yet(r),
Status::NotNeeded => plain_not_needed(&r.class),
@@ -360,15 +383,7 @@ pub fn build(records: &[Record], work: &Path) -> Vec<Row> {
.collect();
if !waiting.is_empty() {
let _ = write!(row.reason, "; waits on {}", waiting.join(", "));
let _ = write!(
row.plain,
" It also waits for {} it depends on that {} not ported yet.",
match waiting.len() {
1 => "1 mod".to_owned(),
n => format!("{n} mods"),
},
if waiting.len() == 1 { "is" } else { "are" }
);
row.waiting = waiting.len();
}
}
for (path, outcome) in &resourcepacks.files {
@@ -508,7 +523,7 @@ pub fn markdown(pack: &str, rows: &[Row]) -> String {
r.license.replace('|', "/"),
r.status.label(),
r.reason.replace('|', "/"),
r.plain.replace('|', "/")
r.plain_text().replace('|', "/")
);
}
out
+27 -2
View File
@@ -28,6 +28,7 @@ fn row(class: &str, tier: &str, status: Status) -> Row {
status,
reason: String::new(),
plain: String::new(),
waiting: 0,
}
}
@@ -156,7 +157,9 @@ fn every_reason_has_a_plain_sentence_and_the_technical_reason_is_kept() {
assert!(said("machine").contains("block entities, menus"));
assert!(said("deep").contains("mobs, custom rendering"));
assert!(
said("deep").ends_with("It also waits for 2 mods it depends on that are not ported yet."),
by["deep"]
.plain_text()
.ends_with("It also waits for 2 mods it depends on that are not ported yet."),
"{}",
said("deep")
);
@@ -164,7 +167,9 @@ fn every_reason_has_a_plain_sentence_and_the_technical_reason_is_kept() {
assert!(said("odd").contains("has not worked out"));
assert!(said("addon").starts_with("It joins other mods together"));
assert!(
said("addon").ends_with("waits for 1 mod it depends on that is not ported yet."),
by["addon"]
.plain_text()
.ends_with("waits for 1 mod it depends on that is not ported yet."),
"{}",
said("addon")
);
@@ -239,5 +244,25 @@ fn a_coverage_json_from_before_the_plain_column_still_reads() {
"license_kind":"permissive","status":"partial","reason":"harness not run"}"#;
let row: Row = serde_json::from_str(old).unwrap();
assert_eq!(row.plain, "");
assert_eq!(row.waiting, 0);
assert_eq!(row.reason, "harness not run");
}
#[test]
fn dependency_counts_are_separate_from_the_translatable_sentence() {
let records = [
record("base", "content", "machines", &[]),
record("addon", "integration", "logic", &["base"]),
];
let rows = build(&records, &std::env::temp_dir());
let addon = &rows[1];
assert_eq!(
addon.plain,
"It joins other mods together, so it waits until those are ported."
);
assert_eq!(serde_json::to_value(addon).unwrap()["waiting"], 1);
assert!(
markdown("test", &rows)
.contains("It also waits for 1 mod it depends on that is not ported yet.")
);
}
+145 -11
View File
@@ -20,6 +20,11 @@ use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
pub const SUMS: &str = "SHA256SUMS";
/// Its signatures' asset name.
pub const SIGNATURE: &str = "SHA256SUMS.sig";
/// A Nightly's second signature file: the same checksum list signed for the Nightly's own version
/// (`horizon release nightly-<date>-<commit>`) rather than for the channel, so an older Nightly can't
/// be served as a newer one. Nightlies carry both while launchers that only read [`SIGNATURE`] are
/// in use (docs/security.md, "Nightly signatures"); current launchers and servers require this one.
pub const SIGNATURE_V2: &str = "SHA256SUMS.v2.sig";
const KEYS: &str = include_str!("../release-keys.txt");
const LEGACY: &str = include_str!("../legacy-releases.txt");
@@ -124,9 +129,10 @@ pub fn is_nightly(tag: &str) -> bool {
tag == NIGHTLY || tag.starts_with("nightly-")
}
/// The version a nightly installs as, from its release title ("Nightly 2026-10-28 · 0c02067"):
/// The version a nightly is listed as, from its release title ("Nightly 2026-10-28 · 0c02067"):
/// `nightly-2026-10-28-0c02067`. `published` (GitHub's `published_at`) dates a title without one.
/// The launcher and `horizon-server` name it the same way, and native mods read the commit back.
/// For listing only: what installs is checked against [`nightly_title_version`], which has no
/// fallback, since nothing signs `published`.
pub fn nightly_version(title: &str, published: &str) -> String {
let mut words = title
.strip_prefix("Nightly ")
@@ -182,15 +188,61 @@ pub fn signed_digest(
digest_in(tag, tag, asset, sums, signature, keys)
}
/// The signed SHA-256 of `asset` in the nightly build: its checksums must be signed for the
/// [`NIGHTLY`] channel, so a release's signed list (or one replayed from a release) is refused.
/// The version a Nightly's GitHub title names, strictly: `Nightly 2026-10-28 · 0c02067` (as
/// nightly-build.yml writes it) gives `nightly-2026-10-28-0c02067`. Anything else is None: a
/// Nightly is verified against the version it installs as, so a title that doesn't name its date
/// and commit is refused rather than dated by when GitHub says it was published.
pub fn nightly_title_version(title: &str) -> Option<String> {
let (date, commit) = title.strip_prefix("Nightly ")?.split_once(" · ")?;
let commit_ok = (7..=40).contains(&commit.len())
&& commit
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
(is_date(date) && commit_ok).then(|| format!("{NIGHTLY}-{date}-{commit}"))
}
/// Whether `version` is a Nightly build's full version, `nightly-<date>-<commit>`, the form its
/// [`SIGNATURE_V2`] is signed for.
pub fn is_nightly_build(version: &str) -> bool {
version
.strip_prefix("nightly-")
.and_then(|rest| rest.split_at_checked(10))
.is_some_and(|(date, commit)| {
is_date(date)
&& commit.strip_prefix('-').is_some_and(|c| {
(7..=40).contains(&c.len())
&& c.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
})
}
/// The signed SHA-256 of `asset` in the Nightly that installs as `version`
/// (`nightly-<date>-<commit>`, from [`nightly_title_version`]): `signature` is its
/// [`SIGNATURE_V2`], which must sign `sums` for exactly that version. A release's list, the channel
/// signature (`nightly`) and another Nightly's are all refused, so an older Nightly can't be passed off
/// as tonight's.
pub fn nightly_digest(
version: &str,
asset: &str,
sums: Option<&[u8]>,
signature: Option<&str>,
keys: &[PublicKey],
) -> Result<String, String> {
digest_in(NIGHTLY, "The Nightly build", asset, sums, signature, keys)
if !is_nightly_build(version) {
return Err(format!(
"{version:?} doesn't name a Nightly's date and commit, so it can't be checked: not installed."
));
}
let Some(signature) = signature else {
return Err(format!(
"{version} has no {SIGNATURE_V2} (its signature for this exact build): it was published \
before Nightlies were signed for their own version, so it can't be checked and was not \
installed. Pick a Nightly published since then; `horizon-server rollback` still goes back \
to a build a server already has."
));
};
digest_in(version, version, asset, sums, Some(signature), keys)
}
/// `asset`'s SHA-256 in `sums`, once one of `keys` is found to have signed them for `signed_for`;
@@ -404,30 +456,112 @@ mod tests {
/// round: one key signs both, and only the message's channel keeps them apart.
#[test]
fn nightly_and_release_signatures_never_pass_for_each_other() {
let (nightly, sums, signature) = signed(NIGHTLY);
const TONIGHT: &str = "nightly-2026-10-28-0c02067";
let (nightly, sums, signature) = signed(TONIGHT);
let keys = [nightly.public()];
let asset = "horizon-windows-x64.zip";
let digest = nightly_digest(asset, Some(sums.as_bytes()), Some(&signature), &keys);
let digest = nightly_digest(
TONIGHT,
asset,
Some(sums.as_bytes()),
Some(&signature),
&keys,
);
assert_eq!(digest, Ok(sha256_hex(b"windows")));
// Served under a release's tag: the message names the nightly channel, not v0.2.81.
// Served under a release's tag: the message names the Nightly, not v0.2.81.
for tag in ["v0.2.81", "v2026.10.28", "0.2.81"] {
let as_release =
signed_digest(tag, asset, Some(sums.as_bytes()), Some(&signature), &keys);
assert!(as_release.is_err(), "{tag} took a nightly's signature");
}
// Asked for by a nightly tag on the release channel: refused before any signature is read.
for tag in [NIGHTLY, "nightly-2026-10-27"] {
for tag in [NIGHTLY, "nightly-2026-10-27", TONIGHT] {
let err = signed_digest(tag, asset, Some(sums.as_bytes()), Some(&signature), &keys);
assert!(err.unwrap_err().contains("Nightly build"), "{tag}");
}
// A release's signed list replayed as tonight's nightly.
let (release, sums, signature) = signed("v0.2.81");
let keys = [release.public()];
assert!(nightly_digest(asset, Some(sums.as_bytes()), Some(&signature), &keys).is_err());
assert!(nightly_digest(asset, Some(sums.as_bytes()), None, &keys).is_err());
assert!(
nightly_digest(
TONIGHT,
asset,
Some(sums.as_bytes()),
Some(&signature),
&keys
)
.is_err()
);
assert!(nightly_digest(TONIGHT, asset, Some(sums.as_bytes()), None, &keys).is_err());
assert!(is_nightly("nightly-2026-10-27") && !is_nightly("v0.2.81"));
}
#[test]
fn an_older_nightly_is_not_tonights() {
let asset = "horizon-windows-x64.zip";
// Last week's build, re-served under tonight's title: its v2 signature names last week.
let (key, sums, old) = signed("nightly-2026-10-21-aaaaaaa");
let keys = [key.public()];
let tonight = nightly_title_version("Nightly 2026-10-28 · 0c02067").unwrap();
let err = nightly_digest(&tonight, asset, Some(sums.as_bytes()), Some(&old), &keys);
assert!(err.is_err(), "an older Nightly passed as {tonight}");
// The channel signature (SHA256SUMS.sig, for launchers from before v2) is no v2 signature.
let (key, sums, channel) = signed(NIGHTLY);
let keys = [key.public()];
assert!(
nightly_digest(
&tonight,
asset,
Some(sums.as_bytes()),
Some(&channel),
&keys
)
.is_err()
);
// And a version that isn't a full Nightly build is refused whatever signs it.
for version in [NIGHTLY, "nightly-2026-10-28", "v0.2.81"] {
let (key, sums, signature) = signed(version);
let err = nightly_digest(
version,
asset,
Some(sums.as_bytes()),
Some(&signature),
&[key.public()],
);
assert!(err.unwrap_err().contains("doesn't name"), "{version}");
}
}
#[test]
fn a_nightlys_title_is_parsed_strictly() {
assert_eq!(
nightly_title_version("Nightly 2026-10-28 · 0c02067").as_deref(),
Some("nightly-2026-10-28-0c02067")
);
assert_eq!(
nightly_title_version(&format!("Nightly 2026-10-28 · {}", "a".repeat(40))),
Some(format!("nightly-2026-10-28-{}", "a".repeat(40)))
);
for title in [
"Something else",
"Nightly 2026-10-28",
"Nightly 2026-10-28 · ",
"Nightly 2026-10-28 · 0c020", // too short
"Nightly 2026-10-28 · 0C02067", // GitHub's short SHAs are lowercase
"Nightly 2026-10-28 · 0c02067 x", // trailing words
"Nightly 2026-1-28 · 0c02067", // not a date
"nightly 2026-10-28 · 0c02067",
" Nightly 2026-10-28 · 0c02067",
"Nightly 2026-10-28 · 0c02067/../x",
] {
assert_eq!(nightly_title_version(title), None, "{title:?}");
}
assert!(is_nightly_build("nightly-2026-10-28-0c02067"));
assert!(!is_nightly_build("nightly-2026-10-28"));
assert!(!is_nightly_build("nightly-2026-10-28-"));
assert!(!is_nightly_build("nightly-2026-10-28-0c02067-x"));
}
#[test]
fn a_nightly_is_named_by_its_date_and_commit() {
let v = nightly_version("Nightly 2026-10-28 · 0c02067", "2026-10-28T20:40:00Z");
+17 -6
View File
@@ -3,10 +3,11 @@
//! job that holds the private key runs only the runner's sha256sum and OpenSSL, no code from the
//! repository.
//!
//! horizon-release-sign verify --tag v2026.10.19 [--key <base64>] [--publishing] <folder>
//! horizon-release-sign verify --tag v2026.10.19 [--key <base64>] [--signature <file>] [--publishing] <folder>
//! horizon-release-sign keygen --public-out key.pub | gh secret set RELEASE_SIGNING_KEY --env release
//!
//! `verify` checks SHA256SUMS.sig and every file SHA256SUMS lists with the keys built in from
//! `verify` checks SHA256SUMS.sig (or `--signature`, e.g. a Nightly's SHA256SUMS.v2.sig with
//! `--tag nightly-<date>-<commit>`) and every file SHA256SUMS lists with the keys built in from
//! release-keys.txt (or `--key`), as the launcher does. With `--publishing` (release.yml) a release
//! may go out unsigned, or signed with a key nobody can check yet, only while release-keys.txt
//! lists no key; once it lists one, anything launchers would refuse fails the release.
@@ -22,8 +23,8 @@ fn main() -> ExitCode {
Some("verify") => check_folder(&args[1..]),
Some("keygen") => keygen(&args[1..]),
_ => Err(
"usage: horizon-release-sign verify --tag <tag> [--key <base64>]... [--publishing] \
<folder> | keygen --public-out <file>"
"usage: horizon-release-sign verify --tag <tag> [--key <base64>]... [--signature <file>] \
[--publishing] <folder> | keygen --public-out <file>"
.into(),
),
};
@@ -76,6 +77,16 @@ fn file_hash(path: &Path) -> Result<String, String> {
fn check_folder(args: &[String]) -> Result<(), String> {
let (tag, rest) = tag_of(args)?;
let (keys, rest) = options(&rest, "--key");
let (signature_file, rest) = options(&rest, "--signature");
let signature_file = signature_file
.into_iter()
.next()
.unwrap_or_else(|| SIGNATURE.to_owned());
if signature_file.contains(['/', '\\']) {
return Err(format!(
"--signature names a file of the folder, not {signature_file:?}"
));
}
let publishing = rest.iter().any(|a| a == "--publishing");
let folder = PathBuf::from(
rest.iter()
@@ -88,7 +99,7 @@ fn check_folder(args: &[String]) -> Result<(), String> {
parse_keys(&keys.join("\n"))?
};
let sums = std::fs::read(folder.join(SUMS)).map_err(|e| format!("{SUMS}: {e}"))?;
let listed = match std::fs::read_to_string(folder.join(SIGNATURE)) {
let listed = match std::fs::read_to_string(folder.join(&signature_file)) {
Ok(signature) if !keys.is_empty() => verify(&tag, &sums, &signature, &keys)?,
Ok(_) if publishing => {
println!(
@@ -105,7 +116,7 @@ fn check_folder(args: &[String]) -> Result<(), String> {
Ok(_) => return Err("no key to check the signature with".into()),
Err(_) => {
return Err(format!(
"{tag} has no {SIGNATURE}: launchers would refuse it (is the release environment's \
"{tag} has no {signature_file}: launchers would refuse it (is the release environment's \
RELEASE_SIGNING_KEY set?)"
));
}
+73 -2
View File
@@ -1,5 +1,5 @@
//! The release steps as release.yml runs them: `sums`, tools/ci/sign_release.sh (OpenSSL), `verify`.
use horizon_release_sign::{SIGNATURE, SUMS, SigningKey, trusted_keys};
use horizon_release_sign::{SIGNATURE, SIGNATURE_V2, SUMS, SigningKey, trusted_keys};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
@@ -60,10 +60,14 @@ fn openssl() -> Option<String> {
}
fn sign_script(dir: &Path, env: &[(&str, &str)]) -> Output {
sign_script_for(TAG, dir, env)
}
fn sign_script_for(tag: &str, dir: &Path, env: &[(&str, &str)]) -> Output {
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tools/ci/sign_release.sh");
let mut cmd = Command::new("bash");
cmd.arg(script)
.arg(TAG)
.arg(tag)
.arg(dir.join("horizon-linux-x86_64.tar.gz"))
.arg(dir.join("horizon-windows-x64.zip"))
.env_remove("RELEASE_SIGNING_KEY")
@@ -182,3 +186,70 @@ fn keygen_writes_the_secret_to_a_pipe_and_the_public_key_to_a_file() {
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_nightly_is_signed_for_its_channel_and_for_its_version() {
if cfg!(windows) || openssl().is_none() {
eprintln!("skipped: tools/ci/sign_release.sh is tested on Linux (bash, OpenSSL 3)");
return;
}
const VERSION: &str = "nightly-2026-10-28-0c02067";
let dir = folder("nightly");
std::fs::remove_file(dir.join(SUMS)).unwrap();
let (key, secret) = SigningKey::generate().unwrap();
let out = sign_script_for(
"nightly",
&dir,
&[("RELEASE_SIGNING_KEY", &secret), ("ALSO_FOR", VERSION)],
);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let public = key.public_base64();
let check = |tag: &str, signature: &str| {
tool(
&[
"verify",
"--tag",
tag,
"--key",
&public,
"--signature",
signature,
],
&[&dir],
)
.status
.success()
};
// Launchers from before v2 check the channel's; current ones, the version's.
assert!(check("nightly", SIGNATURE));
assert!(check(VERSION, SIGNATURE_V2));
assert!(!check(VERSION, SIGNATURE));
assert!(!check("nightly", SIGNATURE_V2));
assert!(!check("nightly-2026-10-27-0c02067", SIGNATURE_V2));
// What a current launcher does with it.
let sums = std::fs::read(dir.join(SUMS)).unwrap();
let v2 = std::fs::read_to_string(dir.join(SIGNATURE_V2)).unwrap();
let digest = horizon_release_sign::nightly_digest(
VERSION,
"horizon-windows-x64.zip",
Some(&sums),
Some(&v2),
&[key.public()],
);
assert_eq!(
digest,
Ok(horizon_release_sign::sha256_hex(b"windows build"))
);
// A bad ALSO_FOR is refused before anything is signed.
let out = sign_script_for(
"nightly",
&dir,
&[("RELEASE_SIGNING_KEY", &secret), ("ALSO_FOR", "v0.2.81")],
);
assert!(!out.status.success());
std::fs::remove_dir_all(dir).unwrap();
}
+6 -1
View File
@@ -247,7 +247,7 @@ pub fn players(dir: &Path, as_json: bool) -> Result<(), String> {
}
/// `kick NAME [REASON]`, `ban NAME [REASON]`, `pardon NAME`, `ban-ip ADDRESS|NAME [REASON]`, `pardon-ip
/// ADDRESS`: the vanilla command on the running server, through the control API.
/// ADDRESS`: the vanilla command on a running server, or a file-backed edit while it is stopped.
pub fn moderate(dir: &Path, command: &str, words: &[String]) -> Result<(), String> {
let Some(target) = words.first() else {
return Err(format!(
@@ -259,6 +259,11 @@ pub fn moderate(dir: &Path, command: &str, words: &[String]) -> Result<(), Strin
}
));
};
// File-backed moderation uses the same stopped-server path as `op` and `whitelist`; while a
// supervisor is running, lists::run falls through to the game's vanilla console command.
if command != "kick" {
return crate::lists::run(dir, command, words);
}
let mut body = json!({});
let key = if target.parse::<std::net::IpAddr>().is_ok() {
"ip"
+15
View File
@@ -159,4 +159,19 @@ mod tests {
"Horizon server 0.1.0 · restarting"
);
}
#[test]
fn console_requires_a_running_server() {
let dir = std::env::temp_dir().join(format!(
"horizon-server-console-stopped-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(
run(&dir).unwrap_err(),
format!("no server is running in {}", dir.display())
);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+137 -5
View File
@@ -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();
}
}
+1 -1
View File
@@ -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)
+47 -16
View File
@@ -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(&current, &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]
+11
View File
@@ -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"]}
}
+61 -1
View File
@@ -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<()> {
+16 -3
View File
@@ -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]
);
}
+11 -1
View File
@@ -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
View File
@@ -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`).
+1 -1
View File
@@ -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
View File
@@ -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`
+105
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+6
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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