Merge remote-tracking branch 'origin/main' into fix/wool-vibration-drops

This commit is contained in:
Horizon agent committed 2026-11-07 13:19:20 +01:00
commit 2f88719668
175 files changed
+5140 -823

No files matched your search

+1
View File
@@ -65,6 +65,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
| `horizon-polytone` | Polytone-format pack files (`assets/*/polytone/`), read clean-room from the public wiki: targets, biome and dimension modifiers and their environment attribute layers (#11486) |
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
Generated
+28 -7
View File
@@ -1411,6 +1411,7 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"crc32fast",
"dunce",
"flate2",
"fs4",
@@ -1493,6 +1494,7 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"crc32fast",
"flate2",
"fs4",
"getrandom 0.3.4",
@@ -1649,7 +1651,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"socket2",
"socket2 0.6.5",
"spake2",
]
@@ -1754,6 +1756,14 @@ dependencies = [
"regex",
]
[[package]]
name = "horizon-polytone"
version = "0.1.0"
dependencies = [
"regex",
"serde_json",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1793,6 +1803,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"socket2 0.5.10",
]
[[package]]
@@ -2127,7 +2138,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"socket2 0.6.5",
"widestring",
"windows-registry",
"windows-result 0.4.1",
@@ -2500,7 +2511,7 @@ dependencies = [
"percent-encoding",
"rustls",
"rustls-pemfile",
"socket2",
"socket2 0.6.5",
"twox-hash",
"url",
"webpki",
@@ -3436,7 +3447,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.3",
"rustls",
"socket2",
"socket2 0.6.5",
"thiserror 2.0.21",
"tokio",
"tracing",
@@ -3474,7 +3485,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
@@ -3592,7 +3603,7 @@ dependencies = [
"itoa",
"percent-encoding",
"ryu",
"socket2",
"socket2 0.6.5",
"url",
"xxhash-rust",
]
@@ -4044,6 +4055,16 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -4364,7 +4385,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"socket2 0.6.5",
"windows-sys 0.61.2",
]
+1
View File
@@ -37,6 +37,7 @@ members = [
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-deps",
@@ -275,6 +275,25 @@ impl Registries {
.clone_from(&e.advancement_criteria);
}
}
/// Add the damage catalog's types ([`Self::damage_types`]: content mods'
/// and the world's packs' with vanilla's) to the commands'
/// `minecraft:damage_type` ids, which the export lists for vanilla alone.
/// `ResourceArgument` looks a type up in the server's registries, so
/// `/damage` takes every type the world has. The export's tags stay. The
/// ids are replaced, not extended: `by_registry` outlives a world, and a
/// pack's type must not stay accepted in the next world opened.
pub fn sync_damage_type_ids(&mut self) {
if self.damage_types.is_empty() {
return;
}
let mut ids = self.damage_types.clone();
ids.sort();
ids.dedup();
self.by_registry
.entry("minecraft:damage_type".into())
.or_default()
.ids = ids;
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
@@ -312,3 +331,49 @@ impl Registries {
}
}
}
#[cfg(test)]
mod tests {
use super::Registries;
/// A content mod's damage type joins vanilla's in the commands' ids, the
/// export's tags stay, and a second world open lists each type once.
#[test]
fn damage_type_ids_take_the_catalogs_types() {
let export = Registries::from_export(&serde_json::json!({
"by_registry": {"minecraft:damage_type": {
"ids": ["minecraft:fall", "minecraft:magic"],
"tags": ["minecraft:is_fall"],
}},
}));
let mut reg = Registries {
damage_types: vec![
"minecraft:magic".into(),
"example:test".into(),
"minecraft:fall".into(),
],
..Registries::default()
};
reg.fill_from_exported(&export);
assert_eq!(
reg.ids("minecraft:damage_type"),
["minecraft:fall", "minecraft:magic"],
"the export alone lists vanilla's types"
);
for _ in 0..2 {
reg.sync_damage_type_ids();
assert_eq!(
reg.ids("minecraft:damage_type"),
["example:test", "minecraft:fall", "minecraft:magic"]
);
assert_eq!(reg.tags("minecraft:damage_type"), ["minecraft:is_fall"]);
}
// The next world, without the pack: its type is no longer accepted.
reg.damage_types = vec!["minecraft:fall".into(), "minecraft:magic".into()];
reg.sync_damage_type_ids();
assert_eq!(
reg.ids("minecraft:damage_type"),
["minecraft:fall", "minecraft:magic"]
);
}
}
+1
View File
@@ -76,6 +76,7 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
@@ -350,6 +350,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
+31
View File
@@ -1188,6 +1188,18 @@ impl SuggestLayout {
};
}
/// A suggestion accepted by Tab or a click (`SuggestionsList.useSuggestion`).
#[derive(Debug, PartialEq, Eq)]
pub struct SuggestionCompletion {
/// The suggestion list's original input, also when cycling with Tab.
pub original: String,
/// The replaced range in `original`, as Java UTF-16 start/end offsets.
pub range: (usize, usize),
pub text: String,
/// The resulting input, after the text field's length limit.
pub input: String,
}
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
/// the suggestion list, Tab completion, history).
@@ -1211,6 +1223,7 @@ pub struct ChatScreen {
/// the command up to the cursor.
request: Option<(i32, String)>,
suggestions: Option<SuggestionList>,
completion: Option<SuggestionCompletion>,
usage: Vec<Text>,
usage_position: i32,
usage_width: i32,
@@ -1333,6 +1346,7 @@ impl ChatScreen {
awaiting: None,
request: None,
suggestions: None,
completion: None,
usage: Vec::new(),
usage_position: 0,
usage_width: 0,
@@ -1370,6 +1384,7 @@ impl ChatScreen {
awaiting: None,
request: None,
suggestions: None,
completion: None,
usage: Vec::new(),
usage_position: 0,
usage_width: 0,
@@ -1385,6 +1400,11 @@ impl ChatScreen {
pub fn value(&self) -> &str {
&self.input.value
}
/// Take the most recently accepted suggestion, at most once. Call after
/// each key or click to observe every acceptance, including Tab cycling.
pub fn take_suggestion_completion(&mut self) -> Option<SuggestionCompletion> {
self.completion.take()
}
/// Replace the input for a suggest-command link and refresh completions.
pub fn suggest_command(&mut self, m: &Measure, c: &Commands, command: &str) {
self.input.set_value(m, command);
@@ -1751,6 +1771,17 @@ impl ChatScreen {
self.select(l, l.current as isize);
self.keep_suggestions = false;
l.tab_cycles = true;
let units = |byte: usize| {
l.original[..byte.min(l.original.len())]
.encode_utf16()
.count()
};
self.completion = Some(SuggestionCompletion {
original: l.original.clone(),
range: (units(s.range.0), units(s.range.1)),
text: s.text,
input: self.input.value.clone(),
});
}
/// `ChatScreen.keyPressed`.
pub fn key(
@@ -7,6 +7,85 @@ fn full() -> ChatAbilities {
ChatAbilities::new(ChatVisibility::Full)
}
#[test]
fn accepted_suggestions_report_utf16_ranges_and_tab_cycles() {
use winit::keyboard::KeyCode as K;
let font = font();
let m = Measure(&font);
let registries = Registries {
players: vec!["Steve".into(), "Steven".into()],
..Registries::default()
};
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "😀 hi st");
assert!(screen.take_suggestion_completion().is_none());
screen.key(&m, &c, &[], K::Tab, false, false);
assert!(
screen.take_suggestion_completion().is_none(),
"opening is not accepting"
);
for (shift, text) in [(false, "Steve"), (false, "Steven"), (true, "Steve")] {
screen.key(&m, &c, &[], K::Tab, shift, false);
assert_eq!(screen.value(), format!("😀 hi {text}"));
assert_eq!(
screen.take_suggestion_completion(),
Some(SuggestionCompletion {
original: "😀 hi st".into(),
range: (6, 8),
text: text.into(),
input: format!("😀 hi {text}"),
})
);
assert!(
screen.take_suggestion_completion().is_none(),
"reported once"
);
}
screen.key(&m, &c, &[], K::ArrowDown, false, false);
assert!(
screen.take_suggestion_completion().is_none(),
"selection is not accepting"
);
screen.key(&m, &c, &[], K::Escape, false, false);
assert!(screen.take_suggestion_completion().is_none());
}
#[test]
fn clicked_suggestion_reports_the_actual_length_limited_input() {
use winit::keyboard::KeyCode as K;
let font = font();
let m = Measure(&font);
let registries = Registries {
players: vec!["Steve".into()],
..Registries::default()
};
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.input.max_length = 6;
screen.type_text(&m, &c, "hi st");
screen.key(&m, &c, &[], K::Tab, false, false);
let [x, y, _, _] = screen.suggestions.as_ref().unwrap().rect;
assert!(screen.click(&m, &c, f64::from(x + 1), f64::from(y + 1)));
assert_eq!(screen.value(), "hi Ste");
assert_eq!(
screen.take_suggestion_completion(),
Some(SuggestionCompletion {
original: "hi st".into(),
range: (3, 5),
text: "Steve".into(),
input: "hi Ste".into(),
})
);
assert!(screen.take_suggestion_completion().is_none());
}
fn font() -> Font {
// The missing-glyph metrics let these tests run without game assets.
Font::load(
@@ -483,8 +562,18 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
tab(&mut screen);
assert_eq!(screen.value(), "/bossbar get pt");
screen.server_suggestions(&m, &c, request, (13, 2), reply("pt6:check"));
assert!(screen.take_suggestion_completion().is_none());
tab(&mut screen);
assert_eq!(screen.value(), "/bossbar get pt6:check");
assert_eq!(
screen.take_suggestion_completion(),
Some(SuggestionCompletion {
original: "/bossbar get pt".into(),
range: (13, 15),
text: "pt6:check".into(),
input: "/bossbar get pt6:check".into(),
})
);
// Without the server's provider the local list is done at once.
let local = crate::command::vanilla::game_dispatcher();
let c = Commands {
+88
View File
@@ -231,6 +231,13 @@ fn sky_visible(frame: &Frame) -> bool {
!frame.title && frame.skybox && sky_open(frame)
}
/// The End during the dragon fight, whose world fog leaves out vanilla's sky: a shader pack's
/// `gbuffers_skybasic` still draws a fan around the horizon (`pack_sky`), as our probe packs read
/// it in Iris 1.11.7.
fn end_fog_sky(frame: &Frame) -> bool {
!frame.title && frame.end_sky && frame.boss_world_fog
}
/// The world pass's part that draws a shader pack's solid terrain: the world's colour and
/// depth loaded, and its `surface` target, cleared on the first of these passes.
fn surface_pass<'e>(
@@ -887,6 +894,8 @@ pub struct Frame<'a> {
pub end_sky: bool,
/// End sky atlas bounds (u0, v0, u1, v1); absent when the sprite is missing.
pub end_sky_sprite: Option<[f32; 4]>,
/// A boss bar makes world fog (`BossHealthOverlay.shouldCreateWorldFog`: the Ender Dragon's).
pub boss_world_fog: bool,
/// Environmental fog start/end (Nether 10..96), if any.
pub environment_fog: Option<(f32, f32)>,
pub water_fog: [f32; 3],
@@ -7223,6 +7232,24 @@ impl Renderer {
planes: frustum(views.cull().view_proj(bob)),
}
}
/// A conservative view for geometry built before TAA chooses this frame's
/// jitter. Cover a whole rendered pixel on each side, at the configured
/// resolution scale/upscale, rather than borrowing the previous frame's size.
pub fn camera_frustum_with_jitter(
&self,
fov_degrees: f32,
yaw: f32,
pitch: f32,
bob: Mat4,
depth_far: f32,
) -> Frustum {
let views = ViewSet::single(self.screen_view(fov_degrees, yaw, pitch, depth_far));
taa_frustum(
views.cull().view_proj(bob),
self.world_post
.taa_render_size([self.config.width, self.config.height]),
)
}
fn screen_view(&self, fov_degrees: f32, yaw: f32, pitch: f32, far: f32) -> View {
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
View::screen(yaw, pitch, fov_degrees, aspect, far)
@@ -12252,6 +12279,16 @@ fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
});
[a, b]
}
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
// Widen x/y before extracting the planes: this includes every such shifted
// view, including a posed camera, without changing the near/far planes.
let [x, y] = rendered_size.map(|size| 1.0 / (1.0 + 2.0 / size.max(1) as f32));
Frustum {
planes: frustum(Mat4::from_scale(Vec3::new(x, y, 1.0)) * view_proj),
}
}
fn frustum(m: Mat4) -> [Vec4; 6] {
let r = |i| m.row(i);
let planes = [
@@ -12267,6 +12304,57 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn taa_culling_covers_jittered_view_edges_but_still_rejects_behind_the_camera() {
use super::*;
let mut outside_unjittered = 0;
for size in [[1, 1], [320, 180], [1280, 720], [3840, 2160]] {
for (yaw, pitch) in [(0.0, 0.0), (73.0, -35.0), (-142.0, 79.0)] {
let pose = Mat4::from_translation(Vec3::new(0.03, -0.02, 0.01))
* Mat4::from_scale(Vec3::new(1.1, 0.9, 1.0))
* crate::camera::view_matrix(yaw, pitch);
let matrix = world_projection(70.0, 16.0 / 9.0, 512.0) * pose;
let guarded = taa_frustum(matrix, size);
let ordinary = Frustum {
planes: frustum(matrix),
};
for x in [-1.0, 1.0] {
for y in [-1.0, 1.0] {
let jitter = Mat4::from_translation(Vec3::new(
x / size[0] as f32,
y / size[1] as f32,
0.0,
));
let inverse = (jitter * matrix).inverse();
for clip_x in [-1.0, 0.0, 1.0] {
for clip_y in [-1.0, 0.0, 1.0] {
for depth in [0.001, 0.5, 0.999] {
// Independent oracle: a point on an actual jittered
// viewport edge, transformed back into world space.
let point = inverse * Vec4::new(clip_x, clip_y, depth, 1.0);
let point = point.truncate() / point.w;
assert!(guarded.intersects_sphere(point, 1.0e-4));
let slack = Vec3::splat(1.0e-4);
assert!(guarded.intersects(point - slack, point + slack));
if !ordinary.intersects_sphere(point, 0.0) {
outside_unjittered += 1;
}
}
}
}
}
}
let behind = pose.inverse().transform_point3(Vec3::new(0.0, 0.0, 10.0));
assert!(!guarded.intersects_sphere(behind, 0.3));
}
}
assert!(
outside_unjittered > 0,
"the test must need the jitter guard"
);
}
#[test]
fn depth_culling_keeps_jittered_edges_and_depth_crossing_models() {
use super::*;
@@ -199,6 +199,8 @@ pub struct FrameInputs {
pub skybox: bool,
pub end_sky: bool,
pub end_sky_sprite: Option<[f32; 4]>,
#[serde(default)]
pub boss_world_fog: bool,
pub environment_fog: Option<(f32, f32)>,
pub water_fog: [f32; 3],
pub fog: Option<FogData>,
@@ -414,6 +416,7 @@ impl Renderer {
skybox: frame.skybox,
end_sky: frame.end_sky,
end_sky_sprite: frame.end_sky_sprite,
boss_world_fog: frame.boss_world_fog,
environment_fog: frame.environment_fog,
water_fog: frame.water_fog,
fog: frame.fog,
@@ -510,6 +513,7 @@ impl Scene {
skybox: f.skybox,
end_sky: f.end_sky,
end_sky_sprite: f.end_sky_sprite,
boss_world_fog: f.boss_world_fog,
environment_fog: f.environment_fog,
water_fog: f.water_fog,
fog: f.fog,
@@ -76,6 +76,9 @@ mod custom_type_tests;
#[cfg(test)]
mod sky_depth_tests;
#[cfg(test)]
mod end_fan_tests;
#[cfg(test)]
mod runtime_length_tests;
@@ -4009,7 +4012,7 @@ impl PackPost {
frame: &super::Frame,
terrain: &mut TerrainDraw<'_>,
) {
if super::sky_visible(frame) {
if super::sky_visible(frame) || super::end_fog_sky(frame) {
self.draw_sky(device, queue, encoder, size, frame);
}
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
@@ -593,6 +593,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -431,6 +431,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -0,0 +1,151 @@
//! In the End, while the dragon fight's world fog leaves out vanilla's sky, a pack's
//! `gbuffers_skybasic` draws a fan in the fog colour, as our probe packs read it in Iris 1.11.7.
//! 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;
/// `gbuffers_skybasic` writes its vertex colour into colortex0 and `gl_Vertex` into colortex1
/// (32-bit floats). final shows, on the picture's top half, `gl_Vertex.y` of the view's lower
/// half as a 24-bit integer ((y + 64) * 65536), and on its bottom half colortex0 of the view's
/// upper half.
fn files() -> Vec<(String, String)> {
vec![
("shaders.properties".into(), "blend.gbuffers_skybasic=off\n".into()),
(
"gbuffers_skybasic.vsh".into(),
"#version 120\nvarying vec4 color;\nvarying vec3 at;\nvoid main() {\n\
\x20 gl_Position = ftransform();\n color = gl_Color;\n at = gl_Vertex.xyz;\n}\n"
.into(),
),
(
"gbuffers_skybasic.fsh".into(),
"#version 120\nvarying vec4 color;\nvarying vec3 at;\n/* DRAWBUFFERS:01 */\n\
void main() {\n gl_FragData[0] = color;\n gl_FragData[1] = vec4(at, 1.0);\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 float y = texelFetch(colortex1, ivec2(gl_FragCoord.xy) - ivec2(0, 8), 0).y;\n\
\x20 uint n = min(uint((y + 64.0) * 65536.0 + 0.5), 16777215u);\n\
\x20 vec4 at = vec4(uvec4(n >> 16u, (n >> 8u) & 255u, n & 255u, 255u)) / 255.0;\n\
\x20 gl_FragColor = uv.y > 0.5 ? at : texelFetch(colortex0, ivec2(gl_FragCoord.xy) + ivec2(0, 8), 0);\n}\n"
.into(),
),
]
}
/// The pack's picture of the fixture's frame (16 pixels square, 90° field of view, looking level
/// toward +Z) in the End, the world having left the far plane everywhere.
fn picture(gpu: &Gpu, boss_world_fog: bool) -> Vec<[u8; 4]> {
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
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(super::super::pack_sky::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.end_sky = true;
frame.boss_world_fog = boss_world_fog;
frame.detail_distance = 192.0;
frame.fog = Some(crate::fog::FogData {
color: [0.2, 0.4, 0.6, 1.0],
environmental_start: 0.0,
environmental_end: 96.0,
render_distance_start: 172.8,
render_distance_end: 192.0,
sky_end: 96.0,
cloud_end: 96.0,
});
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
}
/// Where the view ray through pixel (`column`, `row`) meets the fan: from (0, -16, 0) to eight
/// points 16 above the eye at radius 192, every 45° from +Z. None when it passes above the rim.
fn fan_height(column: usize, row: usize) -> Option<f64> {
let side = |i: usize| (i as f64 + 0.5) / 8.0 - 1.0;
// The fan is mirrored across x = 0, so either sign of the view's x does.
let ray = glam::DVec3::new(side(column), -side(row), 1.0);
let azimuth = ray.x.atan2(ray.z).to_degrees();
let edge = (azimuth / 45.0).floor();
let rim = |k: f64| {
let a = (k * 45.0).to_radians();
glam::DVec3::new(192.0 * a.sin(), 16.0, 192.0 * a.cos())
};
let apex = glam::DVec3::new(0.0, -16.0, 0.0);
let normal = (rim(edge) - apex).cross(rim(edge + 1.0) - apex);
let along = apex.dot(normal) / ray.dot(normal);
let hit = ray * along;
(along > 0.0 && hit.y <= 16.0).then_some(hit.y)
}
#[test]
fn the_end_under_the_dragons_fog_has_the_packs_fan() {
let Some(gpu) = Gpu::new() else { return };
let pixels = picture(&gpu, true);
let width = SIZE[0] as usize;
// Below the horizon: every pixel sees the fan, at the height the planes give (to 0.02:
// the GPU interpolates `gl_Vertex` across triangles 192 blocks wide; it came within 0.008).
for row in 8..16 {
for column in 0..width {
let p = pixels[(row - 8) * width + column];
let got = f64::from(u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
/ 65536.0
- 64.0;
let expected = fan_height(column, row).expect("below the horizon");
assert!(
(got - expected).abs() < 0.02,
"row {row}, column {column}: gl_Vertex.y {got}, expected {expected}"
);
}
}
// Above it: only the row just over the horizon reaches the fan (the rim stands 4.8° up),
// drawn in the fog colour; above that, the world's grey stays.
for row in 0..8 {
for column in 0..width {
let p = pixels[(row + 8) * width + column];
let expected = match fan_height(column, row) {
Some(_) => [51, 102, 153, 255],
None => [16, 16, 16, 255],
};
assert_eq!(p, expected, "row {row}, column {column}");
}
assert_eq!(fan_height(0, row).is_some(), row == 7, "row {row}");
}
}
/// The End without the fog: vanilla's End sky, which no `gbuffers_skybasic` draws.
#[test]
fn the_end_without_the_fog_has_no_fan() {
let Some(gpu) = Gpu::new() else { return };
let pixels = picture(&gpu, false);
let width = SIZE[0] as usize;
for (i, p) in pixels[8 * width..].iter().enumerate() {
assert_eq!(*p, [16, 16, 16, 255], "pixel {i}");
}
}
@@ -400,6 +400,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -22,6 +22,8 @@ const SUNSET_APEX: u32 = 1;
const SUNSET_RIM: u32 = 2;
const STAR: u32 = 3;
const VOID: u32 = 4;
/// The End's fan (`fan`): `pos.xz` of a rim vertex is its direction, scaled by the radius.
const FAN: u32 = 5;
/// The frame's sky (std140).
#[repr(C)]
@@ -33,7 +35,7 @@ struct Uniform {
color: [f32; 4],
/// Sunset colour and alpha.
sunrise: [f32; 4],
/// cos and sin of the sunset fan's turn, star brightness.
/// cos and sin of the sunset fan's turn, star brightness, the End fan's radius.
sun: [f32; 4],
}
@@ -77,6 +79,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}} else if (hz_v_kind == {STAR}u) {{\n \
hz_p = (hz_sky_stars * vec4(hz_v_pos, 1.0)).xyz;\n \
hz_Color = vec4(hz_sky_sun.z);\n \
}} else if (hz_v_kind == {FAN}u) {{\n \
hz_p = vec3(hz_v_pos.x * hz_sky_sun.w, hz_v_pos.y, hz_v_pos.z * hz_sky_sun.w);\n \
}} else if (hz_v_kind == {VOID}u) {{\n \
hz_Color = vec4(0.0, 0.0, 0.0, 1.0);\n \
}}\n \
@@ -188,14 +192,37 @@ fn horizon(out: &mut Vec<Vertex>) {
}
}
/// The fan a pack's `gbuffers_skybasic` draws in the End while the dragon fight's fog leaves out
/// vanilla's sky (`super::end_fog_sky`): from (0, -16, 0) under the eye to eight rim vertices 16
/// above it, every 45° from +Z, at the render distance (the `far` uniform; scaled in the vertex
/// stage). Fitted to the `gl_Vertex` our probe packs' fragments read in Iris 1.11.7 at render
/// distances 12 and 8: 289 of 291 samples lie within 0.05 block of these eight planes (one of
/// the other two, checked, sat on a terrain edge), and nothing drew above the rim.
fn fan(out: &mut Vec<Vertex>) {
let rim = |i: i32| {
let a = (i as f32 * 45.0).to_radians();
Vertex {
pos: [a.sin(), 16.0, a.cos()],
kind: FAN,
}
};
let apex = Vertex {
pos: [0.0, -16.0, 0.0],
kind: FAN,
};
for i in 0..8 {
out.extend([apex, rim(i), rim(i + 1)]);
}
}
pub(super) struct PackSky {
vertices: wgpu::Buffer,
celestial: wgpu::Buffer,
uniform: wgpu::Buffer,
pub layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
/// Vertex ranges of the sky disc, the sunset fan, the stars and the void.
ranges: [Range<u32>; 4],
/// Vertex ranges of the sky disc, the sunset fan, the stars, the void and the End's fan.
ranges: [Range<u32>; 5],
}
impl PackSky {
@@ -227,6 +254,8 @@ impl PackSky {
let stars = sunset.end..v.len() as u32;
disc(-4.0, VOID, &mut v);
let void = stars.end..v.len() as u32;
fan(&mut v);
let end_fan = void.end..v.len() as u32;
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack sky"),
contents: bytemuck::cast_slice(&v),
@@ -271,7 +300,7 @@ impl PackSky {
uniform,
layout,
group,
ranges: [sky, sunset, stars, void],
ranges: [sky, sunset, stars, void, end_fan],
}
}
@@ -294,7 +323,8 @@ impl PackSky {
/// Writes the frame's sky and returns its draws in vanilla's order: the disc, the sunset
/// fan while the sun is near the horizon, the sun and the moon, the stars and, with the
/// camera low, the void. `tilt` is the pack's `sunPathRotation` (degrees): the sun, moon
/// and stars turn with it (7.1).
/// and stars turn with it (7.1). In the End under the dragon fight's fog, only the End's fan,
/// in the fog colour (`fogColor`), as stage SKY.
pub fn prepare(&self, queue: &wgpu::Queue, frame: &super::Frame, tilt: f32) -> Vec<Part> {
let turn = super::pack_uniforms::tilt_matrix(tilt);
let sun_angle = frame.sky.sun_angle.to_radians();
@@ -302,12 +332,22 @@ impl PackSky {
let flip = if sun_angle.sin() < 0.0 { 180f32 } else { 0.0 };
let theta = (flip + 90.0).to_radians();
let (rotation, brightness) = frame.stars.unwrap_or_default();
let [r, g, b] = frame.sky.sky_color;
let end_fan = super::end_fog_sky(frame);
let [r, g, b] = match &frame.fog {
Some(f) if end_fan => [f.color[0], f.color[1], f.color[2]],
None if end_fan => frame.sky.fog_color,
_ => frame.sky.sky_color,
};
let uniform = Uniform {
stars: (turn * rotation).to_cols_array_2d(),
color: [r, g, b, 1.0],
sunrise: frame.sky.sunrise_color,
sun: [theta.cos(), theta.sin(), brightness.clamp(0.0, 1.0), 0.0],
sun: [
theta.cos(),
theta.sin(),
brightness.clamp(0.0, 1.0),
frame.detail_distance,
],
};
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
// The sun's quad, then the moon's (Game::celestial_geometry), as triangles.
@@ -327,12 +367,15 @@ impl PackSky {
})
.collect();
queue.write_buffer(&self.celestial, 0, bytemuck::cast_slice(&triangles));
let [sky, sunset, stars, void] = self.ranges.clone();
let [sky, sunset, stars, void, fan] = self.ranges.clone();
let part = |stage, textured, range| Part {
stage,
textured,
range,
};
if end_fan {
return vec![part("SKY", false, fan)];
}
let mut parts = vec![part("SKY", false, sky)];
if frame.sky.sunrise_color[3] > 0.001 {
parts.push(part("SUNSET", false, sunset));
@@ -2476,6 +2476,7 @@ pub(super) mod initial_history_tests {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -257,14 +257,23 @@ impl Post {
/// The world's rendered size and the size it is shown at (TAA's history).
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
let upscale = if self.taa_on { self.upscale } else { 1.0 };
self.sizes_with_upscale(window, upscale)
}
fn sizes_with_upscale(&self, window: [u32; 2], upscale: f32) -> ([u32; 2], [u32; 2]) {
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
(
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
output,
)
}
/// The next TAA frame's input size, even before its first jitter or resize.
pub(super) fn taa_render_size(&self, window: [u32; 2]) -> [u32; 2] {
self.sizes_with_upscale(window, self.upscale).0
}
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
@@ -604,7 +613,7 @@ mod tests {
#[test]
fn jitter_stays_inside_the_pixel() {
for i in 1..=8 {
for i in 1..=128 {
let (x, y) = (halton(i, 2), halton(i, 3));
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
}
@@ -614,6 +623,30 @@ mod tests {
);
}
#[test]
fn culling_uses_the_next_taa_size_before_the_first_jitter_or_resize() {
let Some((device, _)) = test_device() else {
return;
};
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
assert!(!post.taa_on);
assert!(post.size().is_none());
for (window, scale, upscale, expected) in [
([1280, 720], 1.0, 1.0, [1280, 720]),
([1281, 721], 0.5, 2.0, [321, 181]),
([3840, 2160], 0.25, 4.0, [240, 135]),
([10000, 10000], 2.0, 4.0, [2048, 2048]),
([0, 0], 0.5, 4.0, [1, 1]),
] {
post.scale = scale;
post.upscale = upscale;
post.taa_on = false;
assert_eq!(post.taa_render_size(window), expected);
post.taa_on = true;
assert_eq!(post.sizes(window).0, expected);
}
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
@@ -1515,6 +1515,8 @@ pub enum Event {
PlayMusicTrack(String, String),
/// Open the recordings folder in the system file browser.
OpenStudioFolder,
/// Start playback from the selected Studio recording.
OpenStudioReplay(std::path::PathBuf),
}
/// A row of the Music page: the track, and the pool it is played from.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -3978,6 +3980,9 @@ impl OptionsUi {
return match self.studio.click((x, y), size) {
StudioAction::None => Vec::new(),
StudioAction::Refresh => vec![Event::ButtonClick],
StudioAction::OpenReplay(path) => {
vec![Event::ButtonClick, Event::OpenStudioReplay(path)]
}
StudioAction::OpenFolder => vec![Event::ButtonClick, Event::OpenStudioFolder],
StudioAction::Back => {
let mut events = vec![Event::ButtonClick];
@@ -4459,6 +4464,10 @@ impl OptionsUi {
self.graphics.scroll(s, size, amount);
return;
}
if self.page() == Page::Studio {
self.studio.wheel(amount);
return;
}
if Self::controller_page(self.page()).is_some() {
self.controller.scroll(s, size, amount);
return;
+65 -31
View File
@@ -88,6 +88,7 @@ impl SavedRecordingItem {
#[derive(Clone, Debug, PartialEq)]
pub enum StudioAction {
None,
OpenReplay(PathBuf),
OpenFolder,
Refresh,
Back,
@@ -103,6 +104,12 @@ pub struct StudioUi {
pub record_dir: PathBuf,
}
/// A recording's row and the gap under it, in GUI pixels.
const ROW_STEP: f32 = 40.0;
const ROW_GAP: f32 = 4.0;
/// The panel's margins above and below its rows.
const LIST_MARGINS: f32 = 10.0;
impl Default for StudioUi {
fn default() -> Self {
let dir = PathBuf::from("recordings");
@@ -270,18 +277,19 @@ impl StudioUi {
);
} else {
// Render scrollable cards
let card_h = 36.0;
let card_gap = 4.0;
let row_step = card_h + card_gap;
let visible_cards = (panel_h / row_step).floor() as usize;
let card_h = ROW_STEP - ROW_GAP;
let row_step = ROW_STEP;
// Rows that fit whole between the panel's margins, as the loop below draws them.
let visible_cards = ((panel_h - LIST_MARGINS + ROW_GAP) / row_step).floor() as usize;
let max_scroll =
(self.recordings.len().saturating_sub(visible_cards) as f32 * row_step).max(0.0);
self.scroll = self.scroll.clamp(0.0, max_scroll);
let card_w = panel_w - 12.0;
let card_x = panel_x + 6.0;
let mut y = panel_y + 6.0 - (self.scroll % row_step);
let start_idx = (self.scroll / row_step) as usize;
// Whole rows: nothing here clips one that's partly scrolled off.
let mut y = panel_y + 6.0;
let start_idx = (self.scroll / row_step).round() as usize;
for (idx, item) in self.recordings.iter().enumerate().skip(start_idx) {
if y + card_h > panel_y + panel_h - 4.0 {
@@ -289,6 +297,8 @@ impl StudioUi {
}
let rect = [card_x, y, card_w, card_h];
crate::widgets::record_as(&item.title, rect, true, crate::widgets::Kind::Row);
crate::widgets::target_as(rect, crate::widgets::Kind::Row);
let is_hovered = ui::hovered(cursor, card_x, y, card_w, card_h);
let is_selected = self.selected == Some(idx);
@@ -334,6 +344,12 @@ impl StudioUi {
y += row_step;
}
crate::widgets::scroll_area(
[panel_x, panel_y, panel_w, panel_h],
self.scroll,
max_scroll,
);
// Scrollbar track & thumb if scrollable
if max_scroll > 0.0 {
let track_x = panel_x + panel_w - 4.0;
@@ -357,35 +373,35 @@ impl StudioUi {
// Button 1: Open Folder (Left)
let f_btn = [panel_x, btn_y, 130.0, btn_h];
let f_hover = ui::hovered(cursor, f_btn[0], f_btn[1], f_btn[2], f_btn[3]);
style::button(
batch,
atlas,
f_btn,
&Text::tr("horizon.studio.open_folder", &[]),
(f_hover, true, false),
);
let label = Text::tr("horizon.studio.open_folder", &[]);
crate::widgets::record(&label.plain(), f_btn, true);
style::button(batch, atlas, f_btn, &label, (f_hover, true, false));
// Button 2: Refresh (Center-Left)
let r_btn = [panel_x + 136.0, btn_y, 65.0, btn_h];
let r_hover = ui::hovered(cursor, r_btn[0], r_btn[1], r_btn[2], r_btn[3]);
let label = Text::tr("horizon.studio.refresh", &[]);
crate::widgets::record(&label.plain(), r_btn, true);
style::button(batch, atlas, r_btn, &label, (r_hover, true, false));
// Button 3: Open the selected recording for playback.
let e_btn = [panel_x + panel_w - 180.0, btn_y, 110.0, btn_h];
let e_hover = ui::hovered(cursor, e_btn[0], e_btn[1], e_btn[2], e_btn[3]);
style::button(
batch,
atlas,
r_btn,
&Text::tr("horizon.studio.refresh", &[]),
(r_hover, true, false),
e_btn,
&Text::tr("horizon.studio.open_replay", &[]),
(e_hover, true, false),
);
// Button 3: Done / Back (Right)
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
let d_hover = ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]);
style::button(
batch,
atlas,
d_btn,
&Text::tr("gui.done", &[]),
(d_hover, true, false),
);
let label = Text::tr("gui.done", &[]);
crate::widgets::record(&label.plain(), d_btn, true);
style::button(batch, atlas, d_btn, &label, (d_hover, true, false));
crate::widgets::declare_action(crate::widgets::Action::Done, d_btn, true);
}
/// Process mouse click on the Studio Browser screen.
@@ -411,20 +427,32 @@ impl StudioUi {
return StudioAction::Refresh;
}
// Button 3: Done
// Button 3: Open Replay
let e_btn = [panel_x + panel_w - 180.0, btn_y, 110.0, btn_h];
if ui::hovered(cursor, e_btn[0], e_btn[1], e_btn[2], e_btn[3]) {
if let Some(idx) = self.selected
&& let Some(item) = self.recordings.get(idx)
{
return StudioAction::OpenReplay(item.path.clone());
} else if let Some(item) = self.recordings.first() {
self.selected = Some(0);
return StudioAction::OpenReplay(item.path.clone());
}
}
// Button 4: Done
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
if ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]) {
return StudioAction::Back;
}
// Card clicks
let card_h = 36.0;
let card_gap = 4.0;
let row_step = card_h + card_gap;
let card_h = ROW_STEP - ROW_GAP;
let row_step = ROW_STEP;
let card_w = panel_w - 12.0;
let card_x = panel_x + 6.0;
let start_idx = (self.scroll / row_step) as usize;
let mut y = panel_y + 6.0 - (self.scroll % row_step);
let start_idx = (self.scroll / row_step).round() as usize;
let mut y = panel_y + 6.0;
for (idx, _) in self.recordings.iter().enumerate().skip(start_idx) {
if y + card_h > panel_y + panel_h - 4.0 {
@@ -444,7 +472,8 @@ impl StudioUi {
/// Scroll list by mouse wheel delta.
pub fn wheel(&mut self, amount: f32) {
self.scroll = (self.scroll - amount * 24.0).max(0.0);
// A notch is a row, as a focus step needs (`gamepad::list_step`).
self.scroll = (self.scroll - amount * ROW_STEP).max(0.0);
}
}
@@ -516,6 +545,11 @@ mod tests {
assert_eq!(action, StudioAction::None);
assert_eq!(ui.selected, Some(0));
// Click the bottom-bar Open Replay button.
// Panel width = 387, panel_x = 20, button x = 227..337, y = 692..712.
let open_replay_action = ui.click((280.0, 700.0), (427.0, 720.0));
assert!(matches!(open_replay_action, StudioAction::OpenReplay(_)));
// Clean up
let _ = fs::remove_dir_all(&temp_dir);
}
@@ -527,6 +561,6 @@ mod tests {
assert_eq!(ui.scroll, 0.0); // clamped at 0
ui.scroll = 100.0;
ui.wheel(1.0);
assert_eq!(ui.scroll, 76.0);
assert_eq!(ui.scroll, 100.0 - ROW_STEP);
}
}
+5
View File
@@ -69,6 +69,7 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
@@ -146,6 +147,10 @@ path = "tests/mod_api_hooks.rs"
name = "native_gui_overlay_probe"
path = "examples/native_gui_overlay_probe.rs"
[[example]]
name = "wind_burst_hook_probe"
path = "examples/wind_burst_hook_probe.rs"
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
# The build script runs on the host, but embeds resources based on the target OS.
@@ -30,6 +30,30 @@
"horizon.studio.record.error": "Studio-Aufzeichnungsfehler: %s",
"horizon.studio.record.status.active": "Studio-Aufzeichnung aktiv — %s s, %s Pakete, %s KB (%s)",
"horizon.studio.record.status.inactive": "Studio-Aufzeichnung inaktiv",
"horizon.studio.replay.usage": "Verwendung: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Verwendung: /studio replay open <PATH>",
"horizon.studio.replay.opened": "Studio-Wiedergabe geöffnet: %s (%s s, %s Pakete)",
"horizon.studio.replay.open_error": "Wiedergabe konnte nicht geöffnet werden: %s",
"horizon.studio.replay.paused": "Studio-Wiedergabe pausiert",
"horizon.studio.replay.resumed": "Studio-Wiedergabe fortgesetzt",
"horizon.studio.replay.inactive": "Keine aktive Studio-Wiedergabe",
"horizon.studio.replay.speed.usage": "Verwendung: /studio replay speed <MULTIPLIER>",
"horizon.studio.replay.speed.set": "Studio-Wiedergabegeschwindigkeit auf %s× gesetzt",
"horizon.studio.replay.speed.invalid": "Ungültige Wiedergabegeschwindigkeit",
"horizon.studio.replay.seek.usage": "Verwendung: /studio replay seek <MILLISEKUNDEN>",
"horizon.studio.replay.seeked": "Studio-Wiedergabe auf %s ms (%s s) gesetzt",
"horizon.studio.replay.seek.error": "Wiedergabe konnte nicht verschoben werden: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Zurückspulen ist nur bei einer eigenständigen Wiedergabe verfügbar.",
"horizon.studio.replay.seek.invalid": "Ungültige Wiedergabeposition",
"horizon.studio.replay.status": "Studio-Wiedergabe: %s / %s ms (%s%%), Geschwindigkeit %s×, pausiert %s, beendet %s",
"horizon.studio.replay.closed": "Studio-Wiedergabe geschlossen",
"horizon.studio.saved_recordings": "Gespeicherte Aufzeichnungen",
"horizon.studio.empty": "Keine Aufzeichnungen im Aufnahmeordner gefunden.",
"horizon.studio.empty_hint": "Nutze /studio record start <name> in einer Welt oder lege .mcpr-Dateien dort ab.",
"horizon.studio.open_folder": "Aufnahmen öffnen",
"horizon.studio.open_replay": "Wiedergabe starten",
"horizon.studio.refresh": "Aktualisieren",
"horizon.studio.unknown_server": "Unbekannt",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader-Pakete",
"horizon.pages.shader_options": "Shader-Optionen",
@@ -34,6 +34,23 @@
"horizon.studio.record.error": "Studio recording error: %s",
"horizon.studio.record.status.active": "Studio recording active — %s s, %s packets, %s KB (%s)",
"horizon.studio.record.status.inactive": "Studio recording inactive",
"horizon.studio.replay.usage": "Usage: /studio replay <open PATH|pause|resume|speed MULTIPLIER|seek MS|status|close>",
"horizon.studio.replay.open.usage": "Usage: /studio replay open <PATH>",
"horizon.studio.replay.opened": "Studio replay opened: %s (%s s, %s packets)",
"horizon.studio.replay.open_error": "Could not open replay: %s",
"horizon.studio.replay.paused": "Studio replay paused",
"horizon.studio.replay.resumed": "Studio replay resumed",
"horizon.studio.replay.inactive": "No active Studio replay",
"horizon.studio.replay.speed.usage": "Usage: /studio replay speed <MULTIPLIER>",
"horizon.studio.replay.speed.set": "Studio replay speed set to %s×",
"horizon.studio.replay.speed.invalid": "Invalid replay speed",
"horizon.studio.replay.seek.usage": "Usage: /studio replay seek <MILLISECONDS>",
"horizon.studio.replay.seeked": "Studio replay seeked to %s ms (%s s)",
"horizon.studio.replay.seek.error": "Could not seek replay: %s",
"horizon.studio.replay.seek.backward_requires_standalone": "Rewind is only available for a standalone replay.",
"horizon.studio.replay.seek.invalid": "Invalid replay position",
"horizon.studio.replay.status": "Studio replay: %s / %s ms (%s%%), speed %s×, paused %s, finished %s",
"horizon.studio.replay.closed": "Studio replay closed",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.shader_options": "Shader Options",
@@ -88,6 +105,7 @@
"horizon.studio.empty": "No recordings found in the recordings folder.",
"horizon.studio.empty_hint": "Use /studio record start <name> in a world, or drop .mcpr files there.",
"horizon.studio.open_folder": "Open Recordings",
"horizon.studio.open_replay": "Play Replay",
"horizon.studio.refresh": "Refresh",
"horizon.studio.unknown_server": "Unknown",
"horizon.pages.open.music": "Music...",
@@ -0,0 +1,36 @@
//! Headless native wind-burst hook regression. Uses the dispenser fixture's
//! synthetic server Game; never starts a server or opens a world.
use horizon_client::mod_api::{Hooks, Host, WindBurstEvent};
use std::sync::Arc;
struct Protect;
impl Hooks for Protect {
fn wind_burst(&self, _host: &mut dyn Host, event: &mut WindBurstEvent<'_>) {
assert_eq!(event.dimension, "minecraft:overworld");
assert_eq!(event.center, [0.5, 100.5, 0.5]);
// The protected lever and candle are present in the real ray result.
assert!(event.blocks.contains(&[0, 100, 0]));
assert!(event.blocks.contains(&[1, 100, 0]));
if std::env::var_os("HORIZON_WIND_PROTECT").is_some() {
event
.blocks
.retain(|p| *p != [0, 100, 0] && *p != [1, 100, 0]);
}
}
}
fn main() {
let mods: Arc<[Box<dyn Hooks>]> = if std::env::var_os("HORIZON_WIND_NO_MOD").is_some() {
Arc::from([])
} else {
Arc::from([Box::new(Protect) as Box<dyn Hooks>])
};
// Debug Game construction exceeds the Windows main thread's default stack.
// This worker only loads the isolated component fixture; it has no window.
std::thread::Builder::new()
.name("wind-burst-fixture".into())
.stack_size(64 * 1024 * 1024)
.spawn(move || horizon_client::entry::main_with(mods))
.expect("start wind fixture")
.join()
.expect("wind fixture panicked");
}
+21 -6
View File
@@ -1122,9 +1122,17 @@ pub fn mob_to_nbt(
map.insert("anger".into(), Tag::compound(Vec::<(&str, Tag)>::new()));
}
}
// Armadillo.addAdditionalSaveData: the time left to the next scute.
// Armadillo.addAdditionalSaveData: the time left to the next scute, and
// the state it is in, which is always written (`idle` when it is not
// rolled up).
"minecraft:armadillo" => {
map.insert("scute_time".into(), Tag::Int(m.extra.egg_time));
map.insert(
"state".into(),
string(crate::mobs::armadillo::state_name(
crate::mobs::armadillo::state(m),
)),
);
}
"minecraft:ravager" => {
map.insert("AttackTick".into(), Tag::Int(m.extra.counter));
@@ -2217,11 +2225,18 @@ pub fn mob_from_nbt(
st.vibration = crate::sculk::Data::from_nbt(listener);
}
}
// Armadillo.readAdditionalSaveData: keeps the constructor's time when absent.
if m.species() == crate::mobs::Species::Armadillo
&& let Some(t) = number(tag.get("scute_time"))
{
m.extra.egg_time = t as i32;
// Armadillo.readAdditionalSaveData: the saved state through `switchToState`
// (`onSyncedDataUpdated` then restarts the time in that state), idle for a
// save that names none; then the time to the next scute, the constructor's
// when absent.
if m.species() == crate::mobs::Species::Armadillo {
crate::mobs::armadillo::set_state(
&mut m,
crate::mobs::armadillo::state_by_name(text(tag.get("state")).as_deref()),
);
if let Some(t) = number(tag.get("scute_time")) {
m.extra.egg_time = t as i32;
}
}
if m.species() == crate::mobs::Species::Ravager {
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
+65 -15
View File
@@ -3247,6 +3247,14 @@ struct Game<'a> {
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option<remote::Remote>,
/// Active packet replay session (game/studio.rs).
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
/// Camera perspective to restore after leaving replay playback.
studio_replay_previous_camera_type: Option<perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
studio_replay_standalone: bool,
last_replay_tick: Option<Instant>,
pub(super) studio_virtual_tick_ms: f32,
/// This world is open to the network (game/lan.rs).
lan: Option<lan::LanHost>,
/// Single player's server thread, which `remote` is connected to.
@@ -9208,7 +9216,8 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
let show_hud = !self.ui.hide_gui;
let in_studio = self.studio_replay.is_some();
let show_hud = !self.ui.hide_gui && !in_studio;
if show_hud {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
@@ -9446,14 +9455,13 @@ impl Game<'_> {
batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true);
}
// The chat screen draws the focused chat itself; F1 hides the rest.
if show_hud && self.screen != Screen::Chat {
if self.screen == Screen::Chat {
self.draw_chat(&mut batch);
} else if show_hud {
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
self.gui_layer("minecraft:tab_list", &mut batch, |g, batch| {
g.draw_tab_list(batch);
});
} else {
self.draw_chat(&mut batch);
self.draw_tab_list(&mut batch);
}
// Screen.extractBlurredBackground blurs the world and HUD, while the
// screen's background, widgets, toasts and debug overlay stay sharp.
@@ -11563,6 +11571,7 @@ impl Game<'_> {
camera: glam::DVec3,
alpha: f64,
fov_degrees: f32,
billboard_frustum: Option<horizon_client::gfx::Frustum>,
mut out: Vec<Vertex>,
) -> (
Vec<Vertex>,
@@ -11593,13 +11602,11 @@ impl Game<'_> {
} else {
self.yaw
};
// Packs may move vertices or draw from another camera; TAA jitters the
// projection beyond this unjittered frustum. Keep their original geometry path.
let fire_view = self.view_frustum.as_ref().filter(|_| {
self.pack_ids.is_none()
&& self.native_shaders.picked.is_empty()
&& !self.settings.horizon.taa
});
// Packs may move vertices or draw from another camera. The guarded
// billboard frustum covers TAA's jitter without changing model culling.
let fire_view = billboard_frustum
.as_ref()
.filter(|_| self.pack_ids.is_none() && self.native_shaders.picked.is_empty());
let fire_visible = |position, width, height| {
fire_view.is_none_or(|view| {
horizon_client::fire_render::in_view(view, camera, position, width, height)
@@ -11734,7 +11741,12 @@ impl Game<'_> {
// In first person only a shader pack's shadow pass draws the player.
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
self.player_model_geometry(camera, self.clock.camera_fraction(), &mut out);
let player_alpha = if self.studio_replay.is_some() {
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
} else {
self.clock.camera_fraction()
};
self.player_model_geometry(camera, player_alpha, &mut out);
// A native pack has no per-entity draws to hide it with: its shadow pass alone gets
// the first-person model.
if self.pack_ids.is_none() && !self.own_body_in_view() {
@@ -13537,6 +13549,13 @@ impl Game<'_> {
Ok(())
}
fn close_world(&mut self) -> Result<()> {
// Pause-menu and death-screen exits use the normal world-close action.
// A standalone replay owns a synthetic remote session, so it must go
// through replay cleanup instead of being treated like a server.
if self.studio_replay.is_some() {
self.close_studio_replay();
return Ok(());
}
// A recording cannot cross into another world, and a replay feeding the
// title screen is nonsense.
self.repro_world_closed();
@@ -15008,6 +15027,7 @@ impl Game<'_> {
|| self.ui.hide_gui
|| self.sleeping.is_some()
|| !self.camera_type.first_person()
|| self.studio_replay.is_some()
|| self
.player
.as_ref()
@@ -17051,6 +17071,13 @@ impl Game<'_> {
.renderer
.as_ref()
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob * spin, depth_far));
let billboard_frustum = if self.settings.horizon.taa {
self.renderer.as_ref().map(|r| {
r.camera_frustum_with_jitter(fov_degrees, yaw, pitch, bob * spin, depth_far)
})
} else {
self.view_frustum
};
// Keep the buffer local while builders need mutable access to the game.
let mut entities = std::mem::take(&mut self.entity_vertices);
entities.clear();
@@ -17064,7 +17091,7 @@ impl Game<'_> {
if !title {
let (built, static_sections, visible_static_sections, static_stats) =
spans::time("entity_geometry", || {
self.entity_geometry(camera, alpha, fov_degrees, entities)
self.entity_geometry(camera, alpha, fov_degrees, billboard_frustum, entities)
});
entities = built;
static_block_entities = static_sections;
@@ -17223,6 +17250,7 @@ impl Game<'_> {
.sprites
.get("environment/end_sky")
.map(|r| [r.u0, r.v0, r.u1, r.v1]),
boss_world_fog: self.boss_overlay.should_create_world_fog(),
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
cave_culling,
water_fog,
@@ -17546,6 +17574,10 @@ impl Game<'_> {
KeyCode::Escape => match self.screen {
Screen::Options => self.options_key(KeyCode::Escape, None)?,
Screen::Playing => {
if self.studio_replay.is_some() && self.captured {
self.capture(false);
return Ok(());
}
self.pause_focus = None;
self.screen = Screen::Paused;
self.pause_menu = true;
@@ -17675,7 +17707,9 @@ impl Game<'_> {
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
KeyCode::F11 => self.toggle_fullscreen(),
KeyCode::F5 if self.screen == Screen::Playing => self.cycle_camera(),
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
self.cycle_camera()
}
KeyCode::KeyE
if self.screen == Screen::Creative && self.creative_screen.tab().search => {}
KeyCode::KeyE => match self.screen {
@@ -18141,6 +18175,9 @@ impl Game<'_> {
if let Err(e) = horizon_client::spans::time("poll_remote", || self.poll_remote()) {
log::warn!("Horizon: {e}");
}
if let Err(e) = self.advance_studio_replay() {
log::warn!("Horizon Studio replay: {e}");
}
// The renderer laid translucent quads out again (a shader pack's flat terrain came
// or went) and dropped every section and far tile: meshed again, as for a new one.
if self
@@ -20633,6 +20670,11 @@ impl<'a> Game<'a> {
persist: Default::default(),
entity_attachments: Default::default(),
remote: None,
studio_replay: None,
studio_replay_previous_camera_type: None,
studio_replay_standalone: false,
last_replay_tick: None,
studio_virtual_tick_ms: 0.0,
lan: None,
integrated: None,
level_shapes: Default::default(),
@@ -20887,6 +20929,7 @@ enum PendingWorldEntry {
/// A transfer's carried-over server cookies (`TransferState`).
cookies: Option<horizon_client::net::Cookies>,
},
StudioReplay(std::path::PathBuf),
}
struct WorldRestLoader {
@@ -20970,6 +21013,12 @@ impl Game<'_> {
}) => {
self.connect_server_with_options(&address, &name, cookies, lan)?;
}
Some(PendingWorldEntry::StudioReplay(path)) => {
let path_str = path.to_string_lossy().to_string();
if let Err(e) = self.open_studio_replay(&path_str) {
log::warn!("Horizon Studio: could not open {path_str}: {e}");
}
}
None => {}
}
Ok(())
@@ -22301,6 +22350,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -856,6 +856,8 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
// ArmorStand.kill(level, player).
self.entity_killed_event(at, true);
return true;
}
if time - self.armor_stands[i].last_hit > 5 {
@@ -888,6 +890,8 @@ impl Game<'_> {
];
self.spawn_item(stack, center, v, 10);
}
// ArmorStand.kill(level, player), after the drops.
self.entity_killed_event(at, true);
self.world
.modified
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
@@ -1087,6 +1091,7 @@ impl Game<'_> {
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.entity_killed_event(at, false);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
+6 -2
View File
@@ -1519,8 +1519,12 @@ impl Game<'_> {
for stack in b.items.iter().flatten() {
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
}
// `VehicleEntity.destroy(level, dropItem)` drops the boat item on
// the death path only, and only with `entity_drops` on.
// `VehicleEntity.destroy(level, dropItem)`: `kill` (the boat's own
// ENTITY_DIE), then the boat item on the death path only, and only
// with `entity_drops` on.
if !creative {
self.entity_killed_event(at, false);
}
if !creative && self.ui.rules.bool("entity_drops") {
let item = b.entity_type();
self.spawn_item(
+123 -6
View File
@@ -1,5 +1,8 @@
//! Chat, the command registries and the per-world state behind commands
//! (game rules, the world clock, difficulty), bound to the game.
#[cfg(test)]
mod suggestion_completion_tests;
use super::{Game, Screen};
use horizon_client::{
chat::{
@@ -286,7 +289,8 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
entity_types.sort();
let (timelines, markers) = overworld_timelines(root);
let ids = Arc::new(ResourceIds {
recipes: ids_under(&data.join("recipe"), "json"),
// `/recipe <id>` must suggest a content mod's recipes too.
recipes: json_ids_of_every_namespace(root, "recipe"),
// `/loot ... loot <id>` must find a content mod's tables too.
loot_tables: json_ids_of_every_namespace(root, "loot_table"),
templates: templates_under(&root.join("worldgen/structure")),
@@ -360,11 +364,6 @@ fn command_export(dir: &Path, keep: bool) -> Option<Arc<Registries>> {
export
}
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
ids_in(root, extension, "minecraft")
}
/// The ids of the `.json` files in `data/<namespace>/<kind>` of every
/// namespace of `root` (vanilla's and the content mods'), sorted.
fn json_ids_of_every_namespace(root: &Path, kind: &str) -> Vec<String> {
@@ -685,6 +684,8 @@ impl Game<'_> {
.map(|(k, _)| k.clone())
.collect();
self.sync_enchantment_registries();
// After the export, which keys the registry with vanilla's tags.
self.ui.registries.sync_damage_type_ids();
}
/// The block the player is looking at, for coordinate suggestions, and
/// the entity, for entity arguments (ClientSuggestionProvider).
@@ -1063,6 +1064,23 @@ impl Game<'_> {
self.ui.chat_screen = Some(screen);
}
}
self.notify_chat_suggestion_completion();
}
fn notify_chat_suggestion_completion(&mut self) {
if let Some(completion) = self
.ui
.chat_screen
.as_mut()
.and_then(ChatScreen::take_suggestion_completion)
{
self.native_chat_suggestion_applied(
&completion.original,
completion.range.0,
completion.range.1,
&completion.text,
&completion.input,
);
}
}
/// The server's completions for an `ask_server` request
/// (ClientPacketListener.handleCommandSuggestions).
@@ -1174,6 +1192,7 @@ impl Game<'_> {
}
if let Some(screen) = self.ui.chat_screen.as_mut() {
if screen.click(&m, &c, cursor.0 as f64, cursor.1 as f64) {
self.notify_chat_suggestion_completion();
return;
}
} else {
@@ -1836,6 +1855,23 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// A content mod's recipes are suggested with vanilla's, so `/recipe`
/// offers the ids the recipe index knows.
#[test]
fn recipes_of_every_namespace_are_listed() {
let root = std::env::temp_dir().join(format!("horizon-recipe-ids-{}", std::process::id()));
for dir in ["data/minecraft/recipe", "data/mymod/recipe/press"] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
std::fs::write(root.join("data/minecraft/recipe/stick.json"), "{}").unwrap();
std::fs::write(root.join("data/mymod/recipe/press/iron.json"), "{}").unwrap();
assert_eq!(
resource_ids(&root).recipes,
["minecraft:stick", "mymod:press/iron"]
);
let _ = std::fs::remove_dir_all(&root);
}
/// Only the overworld clock's timelines count; a marker is a bare tick
/// count (hidden from commands) or an object that may show itself.
#[test]
@@ -2274,3 +2310,84 @@ mod enchantment_snapshot_tests {
);
}
}
#[cfg(test)]
mod damage_type_registry_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
/// A world pack's damage type is a `/damage` type once the world opens,
/// beside vanilla's, and the registry keeps vanilla's tags.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_world_packs_damage_type_parses_in_damage() {
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();
let dir = std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join("damage-type-ids");
let pack = dir.join("datapacks/model");
let types = pack.join("data/example/damage_type");
std::fs::create_dir_all(&types).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(
types.join("test.json"),
r#"{"message_id":"example.test","scaling":"never","exhaustion":0.0}"#,
)
.unwrap();
std::fs::write(
dir.join("datapacks.json"),
r#"{"enabled":["file/model"],"disabled":[]}"#,
)
.unwrap();
game.world_root = dir;
(game.world_tables, game.health_resources) = game.build_world_tables(false, &[]).unwrap();
game.level.allow_commands = Some(true);
game.build_registries();
let reg = &game.ui.registries;
assert!(
reg.damage_types.contains(&"example:test".to_owned()),
"the damage catalog holds the pack's type"
);
let parse = |input: &str| {
horizon_client::command::vanilla::dispatcher()
.parse(input, reg)
.executable()
.map(|_| ())
.map_err(|e| format!("{e:?}"))
};
assert_eq!(parse("damage @s 1 example:test"), Ok(()));
assert_eq!(parse("damage @s 1 minecraft:magic"), Ok(()));
assert!(parse("damage @s 1 example:missing").is_err());
assert!(
reg.tags("minecraft:damage_type")
.contains(&"minecraft:is_fire".to_owned()),
"vanilla's damage type tags stay"
);
}
}
@@ -0,0 +1,124 @@
use super::*;
use crate::{
game::{Audio, Options, Progress, Settings, load_resources},
mod_api::{self, ChatSuggestionAppliedEvent, Hooks, Host},
};
use std::sync::{Arc, Mutex};
/// Exercise the actual Game keyboard/mouse handlers and generated mod dispatch.
#[test]
#[ignore = "needs prepared resources and a fresh HORIZON_HOME; run in isolation"]
fn chat_completion_reaches_mods_from_keyboard_and_mouse() {
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let scratch = PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("suggestion-game");
std::fs::create_dir_all(&scratch).unwrap();
struct RestoreDir(PathBuf);
impl Drop for RestoreDir {
fn drop(&mut self) {
std::env::set_current_dir(&self.0).unwrap();
}
}
let _restore = RestoreDir(std::env::current_dir().unwrap());
std::env::set_current_dir(&scratch).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;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&scratch),
None,
)
.unwrap();
struct Listener(Arc<Mutex<Vec<String>>>);
impl Hooks for Listener {
fn chat_suggestion_applied(
&self,
_host: &dyn Host,
e: &mut ChatSuggestionAppliedEvent<'_>,
) {
assert_eq!(e.original, "😀 hi st");
assert_eq!((e.start, e.end), (6, 8));
assert_eq!(e.input, format!("😀 hi {}", e.suggestion));
self.0.lock().unwrap().push(e.input.to_owned());
}
}
let calls = Arc::new(Mutex::new(Vec::new()));
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Listener(calls.clone()))];
assert!(
mod_api::MODS.set(mods.into()).is_ok(),
"run this test alone"
);
game.screen = Screen::Chat;
game.ui.registries.players = vec!["Steve".into(), "Steven".into()];
game.ui.chat_screen = Some(ChatScreen::new(
&Measure(&game.gui.atlas.font),
&Commands {
dispatcher: chat_tree(&game.remote),
registries: &game.ui.registries,
},
"",
false,
0,
640,
360,
ChatAbilities::new(ChatVisibility::Full),
));
game.chat_type("😀 hi st");
game.chat_key_code(KeyCode::Tab, None);
assert!(calls.lock().unwrap().is_empty(), "only opened the list");
game.chat_key_code(KeyCode::Tab, None);
game.chat_key_code(KeyCode::Tab, None);
assert_eq!(*calls.lock().unwrap(), ["😀 hi Steve", "😀 hi Steven"]);
// Click the suggestion's rendered text rather than assuming its coordinates.
let draws =
game.ui
.chat_screen
.as_mut()
.unwrap()
.draw(&Measure(&game.gui.atlas.font), (-1, -1), 0, 0);
let at = draws
.into_iter()
.find_map(|d| match d {
horizon_client::screen_draw::Draw::Text { text, x, y, .. }
if text.plain() == "Steve" =>
{
Some(((x + 1) as f32, (y + 1) as f32))
}
_ => None,
})
.expect("Steve is a visible suggestion");
game.cursor = at;
game.chat_click();
assert_eq!(
*calls.lock().unwrap(),
["😀 hi Steve", "😀 hi Steven", "😀 hi Steve"]
);
assert!(
game.ui
.chat_screen
.as_mut()
.unwrap()
.take_suggestion_completion()
.is_none()
);
game.chat_key_code(KeyCode::ArrowDown, None);
assert_eq!(calls.lock().unwrap().len(), 3, "navigation does not apply");
}
@@ -2281,6 +2281,30 @@ impl Game<'_> {
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
}
fn kill_target_in_level(&mut self, t: Target) {
// Entity.kill (BlockAttachedEntity.kill; ArmorStand's generic_kill hit,
// which bypasses its invulnerability; a vehicle is no LivingEntity):
// removed, then ENTITY_DIE by the entity itself. Mobs post theirs as
// they die, the crystal and the dragon on their own paths, and an item
// entity can dampen it (ItemEntity.dampensVibrations), so it is not here.
let died = matches!(
t,
Target::Stand(_)
| Target::Frame(_)
| Target::Painting(_)
| Target::Boat(_)
| Target::Cart(_)
| Target::Orb(_)
| Target::Projectile(_)
| Target::Fangs(_)
| Target::Tnt(_)
| Target::Falling(_)
| Target::Rocket(_)
| Target::Trident(_)
| Target::Spawner(_)
| Target::Cloud(_)
)
.then(|| self.target_position(t))
.flatten();
match t {
Target::Guest(_) => {
self.for_player(t, |g| g.kill_target(Target::Player));
@@ -2356,6 +2380,9 @@ impl Game<'_> {
}
// The orbs marked for removal are gone (Entity.kill removes them).
self.orbs.retain(|o| o.age < u32::MAX / 2);
if let Some(at) = died {
self.entity_killed_event(at, false);
}
self.native_leaves_flush();
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
@@ -387,6 +387,8 @@ impl Game<'_> {
if self.riding == Some(c.id) {
self.dismount_removed_vehicle(p.y);
}
// BlockAttachedEntity.kill, before dropItem.
self.entity_killed_event([p.x, p.y, p.z], by_player);
self.audio.play(
"minecraft:entity.cushion.break",
Some([p.x, p.y, p.z]),
@@ -312,7 +312,21 @@ impl Game<'_> {
);
let state = self.world.block(origin.x, origin.y, origin.z);
let mut used = Vec::new();
if let Some(op) = case.get("remove") {
if let Some(op) = case.get("wind_burst") {
let center = [
op["center"][0].as_f64().ok_or("wind center x")?,
op["center"][1].as_f64().ok_or("wind center y")?,
op["center"][2].as_f64().ok_or("wind center z")?,
];
let breeze = op["breeze"].as_bool().unwrap_or(false);
self.wind_burst(
center,
op["radius"].as_f64().unwrap_or(3.0) as f32,
1.0,
breeze,
!breeze,
)?;
} else if let Some(op) = case.get("remove") {
used = self.fixture_remove(origin, op)?;
} else if let Some(op) = case.get("use") {
used = self.fixture_use(origin, op)?;
+6
View File
@@ -363,6 +363,10 @@ impl Game<'_> {
);
if !with_frame {
self.item_frames[i].item = None;
} else {
// BlockAttachedEntity.kill, before dropItem. A frame holding an
// item only gives the item up (ItemFrame.hurtServer).
self.entity_killed_event(at, true);
}
self.drop_frame_items(&f, with_frame, true);
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
@@ -470,6 +474,7 @@ impl Game<'_> {
continue;
}
let f = self.item_frames.remove(i);
self.entity_killed_event(center(&f), false);
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
@@ -497,6 +502,7 @@ impl Game<'_> {
if !self.frame_survives(&f) && !self.hanging_break_cancelled(f.kind(), f.pos, false)
{
self.item_frames.remove(i);
self.entity_killed_event(center(&f), false);
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
+31
View File
@@ -194,6 +194,19 @@ impl Game<'_> {
let duration = swing_duration(animation, dig_speed, fatigue);
if self.swing.start_if_able(hand, animation, duration) {
self.swings_started += 1;
if let Some(r) = self.remote.as_ref()
&& r.session.is_recording()
{
let pkt = horizon_protocol::packets::play::Clientbound::SwingAnimation(
horizon_protocol::packets::play::SwingAnimation {
id: r.entity_id,
hand: if hand == Hand::Off { 1 } else { 0 },
kind: animation.kind as i32,
duration,
},
);
r.session.record_clientbound(&pkt);
}
}
}
/// `swing(hand)` for the hand that acts, with the default animation.
@@ -536,6 +549,24 @@ impl Game<'_> {
}
}
}
/// A replay owns its held-item and equip animation state. Reset it when a
/// standalone replay closes so a later world cannot inherit lowered hands
/// or the replay player's visible stacks.
pub(super) fn reset_studio_hand_state(&mut self) {
let main = self.inventory.get(self.selected).cloned().flatten();
self.visible_hands = [(self.selected, main), (OFFHAND, self.offhand.clone())];
self.hands_replaced = [false; 2];
self.heights = horizon_client::first_person::HandHeights {
main: 1.0,
old_main: 1.0,
off: 1.0,
old_off: 1.0,
};
self.swing = Default::default();
self.sway = Default::default();
}
/// `Minecraft.crosshairPickEntity` when it is a `LivingEntity`: whether
/// it is alive (the nearest entity under the crosshair, before any block).
pub(super) fn crosshair_living(&self) -> Option<bool> {
+10 -4
View File
@@ -3,13 +3,14 @@
//! dropped items (`ItemEntityRenderer`) and items posed in the world (hands,
//! frames, armour stands). This binds the resolver's host services
//! (`ItemEnv`) and the special renderers' registries to the game.
use super::{Dim, Game};
use super::{Dim, Game, entity_culling::EntityCullDecision};
use horizon_assets::item_model::{
Components, DisplayContext, GlobalPos, ItemEnv, LevelState, OwnerState, StackView,
};
use horizon_client::{
block_entity_render::{diffuse_lights, vertex_light},
cit::{Hand as CitHand, Kind as CitKind},
graphics::EntityCullingMode,
hud::{HudBatch, Rect},
item_render::{self, ItemRenderState, SpecialHost, WorldSink},
items::ItemStack,
@@ -615,7 +616,7 @@ impl Game<'_> {
continue;
}
let age = item.age as f32 + partial;
let (lo, _) = state.bounding_box();
let (lo, hi) = state.bounding_box();
let min_offset_y = -lo.y + 0.0625;
let bob = (age / 10.0 + item.bob_offset).sin() * 0.1 + 0.1;
let spin = item_render::item_spin(age, item.bob_offset);
@@ -642,16 +643,21 @@ impl Game<'_> {
};
let seed = i64::from(self.pack.item_renderer.item_id(&item.stack.item))
+ i64::from(item.stack.damage);
let copies = item_render::cluster_poses(
&state,
let copies = item_render::cluster_poses_with_depth(
pose,
item_render::rendered_amount(item.stack.count),
seed,
hi.z - lo.z,
);
// Use the same animated, spun, clustered transforms as the draw.
// ItemEntity's collision box is only 0.25 blocks and cannot prove
// that this larger rendered model stays behind an occluder.
let culling = entity.then(|| {
// The posed envelope walks every model point in every copy;
// with culling off the decision is `Bypass` whatever it holds.
if self.settings.horizon.entity_culling == EntityCullingMode::Off {
return EntityCullDecision::Bypass;
}
state
.posed_bounds(&copies)
.map(|(lo, hi)| {
@@ -5316,6 +5316,11 @@ impl Game<'_> {
if g.fall_flying != gliding {
g.fall_flying = gliding;
changed = Some((g.uuid, g.name.clone(), gliding));
} else if !gliding {
// A refused start changes nothing on the server, so no flags
// update would follow; force one, so the client that predicted
// the glide takes the cleared bit and ends it.
g.sent_shared ^= -128;
}
}
if let Some((uuid, name, gliding)) = changed {
@@ -2394,6 +2394,11 @@ impl Game<'_> {
self.carts.insert(i, c);
} else {
let at = [c.position.x, c.position.y, c.position.z];
// VehicleEntity.destroy: `kill` (the cart's own ENTITY_DIE),
// then the drops; a creative hit only discards it.
if !creative {
self.entity_killed_event(at, false);
}
if self.ui.rules.bool("entity_drops") {
for s in c.items.iter().flatten() {
self.spawn_item(s.clone(), at, [0.0, 0.1, 0.0], 10);
@@ -6480,6 +6480,32 @@ impl Game<'_> {
);
}
/// `CommandSuggestions.SuggestionsList.useSuggestion`: a suggestion was
/// applied in the chat by Tab or a click, after updating the input and caret.
/// `start..end` is a Java UTF-16 range in `original`, the list's original
/// input even when cycling. `suggestion` is the selected replacement;
/// `input` is the resulting field value after its length limit. Client-only,
/// read-only; previews, navigation and command-block editors do not fire it.
/// API @API:chat_suggestion_applied@.
pub(super) fn native_chat_suggestion_applied(
&self,
original: &str,
start: usize,
end: usize,
suggestion: &str,
input: &str,
) {
mod_api::dispatch_chat_suggestion_applied(
self,
mod_api::mods(),
original,
start,
end,
suggestion,
input,
);
}
/// NeoForge's `ToastAddEvent`: a new toast is about to be queued. `kind`
/// is `advancement`, `recipe`, `tutorial`, `system` or `now_playing`;
/// `id` is the advancement id, the first recipe id, the tutorial hint
@@ -8250,6 +8276,18 @@ impl Game<'_> {
) {
mod_api::dispatch_right_click_block(self, mod_api::mods(), pos, face, canceled);
}
/// A wind burst is about to interact with its shuffled block positions.
/// Remove positions to protect their blocks. Knockback and the vanilla
/// shuffle have already run; this does not change either. Server-side.
/// Pending API @API:wind_burst@.
pub(super) fn native_wind_burst(
&mut self,
dimension: &str,
center: [f64; 3],
blocks: &mut Vec<[i32; 3]>,
) {
mod_api::dispatch_wind_burst(self, mod_api::mods(), dimension, center, blocks);
}
/// An explosion at `center` in `dimension` (`minecraft:overworld`) is
/// about to break `blocks`: `ServerExplosion.explode` with its `toBlow`,
/// before any block is hit. Take out the positions to spare; they keep
@@ -478,6 +478,12 @@ impl Game<'_> {
self.confirm_trusted_link(ACCESSIBILITY_HELP_LINK, Screen::Options)
}
Event::Narrator => self.narrator_status_changed(),
Event::OpenStudioReplay(path) => {
let path = path.to_string_lossy().to_string();
if let Err(e) = self.open_studio_replay(&path) {
log::warn!("Horizon Studio: could not open {path}: {e}");
}
}
Event::OpenStudioFolder => {
let dir = std::path::PathBuf::from("recordings");
let _ = std::fs::create_dir_all(&dir);
@@ -350,6 +350,8 @@ impl Game<'_> {
(lo[2] + hi[2]) / 2.0,
];
self.world.modified.insert((p.pos[0] >> 4, p.pos[2] >> 4));
// BlockAttachedEntity.kill, before dropItem.
self.entity_killed_event(at, by_player);
if !self.ui.rules.bool("entity_drops") {
return true;
}
+310 -14
View File
@@ -574,6 +574,9 @@ pub(super) struct Remote {
/// The slot the server last heard (`MultiPlayerGameMode.carriedIndex`): a cell, so `send` can
/// send a new one first (`ensureHasSentCarriedItem`).
carried: std::cell::Cell<Option<usize>>,
was_shared_flags: u8,
was_living_flags: u8,
last_recorded_pose: Option<crate::pose::Pose>,
/// The last ClientboundPlayerAbilitiesPacket. Vanilla's LocalPlayer
/// holds its abilities from the login on; here the player is made at
/// the first teleport, which takes them then.
@@ -1144,6 +1147,9 @@ impl Game<'_> {
last_input: 0,
was_sprinting: false,
carried: std::cell::Cell::new(None),
was_shared_flags: 0,
was_living_flags: 0,
last_recorded_pose: None,
abilities: None,
loaded_sent: false,
heard_level: None,
@@ -1240,6 +1246,227 @@ impl Game<'_> {
Ok(())
}
/// Prepare a client session and remote state to receive packets from a studio replay.
pub(super) fn init_remote_for_replay(&mut self) -> Result<()> {
self.reset_tutorial();
self.perf.world_opening();
self.world_biomes = None;
self.world_biomes_base = None;
let root = self.options.resources.clone();
let builtin = builtin_registries(&root).ok_or("Resources are incomplete")?;
let lang = Arc::new(horizon_client::net::load_language(&root));
let info = self
.settings
.client_information(client_view_distance(self.settings.render_distance));
let auth = self.auth();
let name = auth.as_ref().map_or("ReplayPlayer", |a| a.name.as_str());
let user = auth.as_ref().map_or_else(
|| {
(
name.to_owned(),
horizon_protocol::crypto::offline_uuid(name),
)
},
|a| (a.name.clone(), a.uuid),
);
self.server_packs_connect(None, user);
let session = Session::replay(name);
let player_type = super::perspective::player_type(&root);
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
self.worker_context(),
self.res.threads(),
);
streaming.remote = true;
streaming.radius = self.settings.render_distance;
self.streaming = streaming;
self.world = World::new(self.res.facts().air, 0);
self.save = Arc::new(SaveDir::new(
std::env::temp_dir().join("horizon-replay-unused"),
horizon_client::save::LevelShape::OVERWORLD,
));
self.inventory = vec![None; 36];
self.armor = [None, None, None, None];
self.craft_grid = vec![None; 9];
self.craft_size = 2;
self.player = None;
self.spawn = None;
self.mobs.clear();
self.clear_remote_objects();
self.clear_remote_projectiles();
self.creatures.projectiles.clear();
self.dropped.clear();
self.particles.clear();
self.pickups.clear();
self.orbs.clear();
self.falling.clear();
self.mining = None;
self.container = None;
self.carried = None;
self.using = None;
self.end_mod_use();
self.set_mode_id(0);
self.experience = Experience {
level: 0,
progress: 0.0,
total: 0,
};
self.ui.registries = Default::default();
self.build_registries();
self.ui.registries.permission = 0;
self.ui.chat.clear();
self.screen = Screen::Playing;
self.title = None;
self.dim = Dim::Overworld;
self.clear_lod();
self.pending_uploads.clear();
if let Some(r) = self.renderer.as_mut() {
r.clear_sections(self.world.layout());
}
self.apply_settings();
self.clear_waypoints();
self.boss_overlay.reset();
let chat_tree = self.chat_tree_for(None);
let services_keys = Arc::new(std::sync::Mutex::new(ServicesKeys::Ready(Vec::new())));
self.remote = Some(Remote {
session,
save_folder: None,
brand: "horizon-studio".into(),
recipe_transfer: false,
features: vec!["minecraft:vanilla".into()],
quiet_commands: false,
mod_net: false,
attachments: HashMap::new(),
far_terrain: None,
builtin,
status: "Playing Studio Replay".into(),
lang,
registries: Arc::new(Dynamic::default()),
enchantments: None,
joined: false,
reconfiguring: None,
logged_in: true,
local_entity_silent: false,
entity_id: 0,
camera: None,
use_item: None,
dimension: DimensionType::vanilla("minecraft:overworld"),
level: "minecraft:overworld".into(),
hashed_seed: 0,
chunk_format: Arc::new(ChunkFormat::new(
&DimensionType::vanilla("minecraft:overworld"),
Vec::new(),
7,
)),
decoder: ChunkDecoder::new(),
view_distance: self.settings.render_distance,
simulation_distance: self.settings.simulation_distance,
sequence: 0,
last_teleport_sequence: -1,
position_sender: Default::default(),
last_input: 0,
was_sprinting: false,
was_shared_flags: 0,
was_living_flags: 0,
last_recorded_pose: None,
carried: std::cell::Cell::new(None),
abilities: None,
loaded_sent: false,
heard_level: None,
native_epoch: None,
level_load: horizon_client::level_loading::LevelLoadTracker::new(0),
sent_info: info,
predictions: HashMap::new(),
menu: None,
craft_result: None,
menu_title: None,
state_id: 0,
use_target: None,
menu_extra: None,
entities: HashMap::new(),
lerps: HashMap::new(),
cart_lerps: HashMap::new(),
stepped: HashMap::new(),
head_lerps: HashMap::new(),
ghast_dismount: None,
first_passenger: false,
passenger_index: 0,
pending_items: HashMap::new(),
health: (20.0, 20, 5.0),
flash_on_set_health: false,
game_mode: 0,
chat: Vec::new(),
chat_received: 0,
chat_queue: VecDeque::new(),
signatures: Default::default(),
chat_previous: None,
next_chat_index: 0,
enforces_secure_chat: false,
services_keys,
chat_sessions: HashMap::new(),
chat_validators: HashMap::new(),
joining_narrated: None,
chat_tree,
chat_tree_received: false,
secure_chat: Default::default(),
custom_completions: Default::default(),
players: HashMap::new(),
tab_players: HashMap::new(),
skin_textures: HashMap::new(),
own_profile: OwnProfile::Idle,
skins: HashMap::new(),
skins_revision: 0,
published_skins: None,
listed: Default::default(),
online_mode: false,
level_players: HashMap::new(),
level_player_names: HashMap::new(),
level_player_textures: HashMap::new(),
glowing: Default::default(),
own_invisible: false,
own_on_fire: false,
own_arrows: 0,
own_stingers: 0,
own_shoulders: [None; 2],
profile: 0,
profile_name: String::new(),
destroying: None,
destroying_item: String::new(),
is_destroying: false,
destroy_delay: 0,
input: Default::default(),
pending_keys: Vec::new(),
swap_answer: [false; 2],
stop_mining: false,
placing: None,
flat: false,
death_message: None,
show_death_screen: true,
reduced_debug_info: false,
do_limited_crafting: false,
player_type,
synced: vec![None; CREATIVE_SYNCED],
decode_errors: 0,
clock: None,
clock_rate: 1.0,
clock_partial: 0.0,
clock_game_time: 0,
chunk_center: (0, 0),
backlog: VecDeque::new(),
backlog_since: None,
server_links: Vec::new(),
debug_query: Default::default(),
transfer: None,
});
self.clock = horizon_client::clock::FixedStep::client();
self.adv = Default::default();
self.stats = Default::default();
Ok(())
}
/// Disconnect and return to the multiplayer screen (DisconnectedScreen).
pub(super) fn leave_server(&mut self, reason: Option<String>) -> Result<()> {
if reason.is_none() && self.integrated.as_ref().is_some_and(|s| !s.stopped()) {
@@ -2422,6 +2649,9 @@ impl Game<'_> {
/// view centre, `calculateStorageRange` (the server's radius, at least
/// two, plus three) chunks each way.
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
if self.studio_replay.is_some() {
return true;
}
self.remote.as_ref().is_none_or(|r| {
let range = r.view_distance.max(2) + 3;
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
@@ -6103,7 +6333,9 @@ impl Game<'_> {
self.apply_remote_abilities();
}
}
self.previous_position = position;
if self.studio_replay.is_none() {
self.previous_position = position;
}
self.yaw = yaw;
self.pitch = pitch;
// setValuesFromPositionPacket: setOldPosAndRot gives xRotO the same
@@ -6430,7 +6662,7 @@ impl Game<'_> {
return Ok(());
};
player.fluids.boat = boat;
if !riding {
if !riding && self.studio_replay.is_none() {
self.previous_position = player.position;
}
let before = (
@@ -6629,7 +6861,9 @@ impl Game<'_> {
self.snap_player_model_pitch();
}
// The player's own model (third person, the inventory's preview).
self.tick_player_model();
if self.studio_replay.is_none() {
self.tick_player_model();
}
self.tick_emotes();
if !dead {
self.remote_tick_using();
@@ -7473,6 +7707,7 @@ impl Game<'_> {
}));
}
};
let mut moved_or_rotated = false;
if riding {
// LocalPlayer.sendChanges: a passenger sends its look, then the
// vehicle it controls and, only then, its sprint.
@@ -7485,18 +7720,74 @@ impl Game<'_> {
out.extend(vehicle);
sprint(r, &mut out);
}
moved_or_rotated = true;
} else {
sprint(r, &mut out);
}
if !riding
&& controls_camera
// LocalPlayer.sendPosition: no move while the view is another
// entity's (the server keeps the player on it).
&& let Some(packet) =
r.position_sender
.update(pos, (yaw, pitch), on_ground, collision)
{
out.push(packet);
if controls_camera
&& let Some(packet) =
r.position_sender
.update(pos, (yaw, pitch), on_ground, collision)
{
out.push(packet);
moved_or_rotated = true;
}
}
if moved_or_rotated && r.session.is_recording() {
let pkt = horizon_protocol::packets::play::Clientbound::PlayerPosition(
horizon_protocol::packets::play::PlayerPosition {
teleport: 0,
position: pos,
delta: [0.0, 0.0, 0.0],
yaw,
pitch,
relatives: 0,
},
);
r.session.record_clientbound(&pkt);
}
if r.session.is_recording() {
let is_using = self.using.is_some();
let living_flags: u8 = if is_using {
1 | if self.hand == Hand::Off { 2 } else { 0 }
} else {
0
};
let crouching = p.local_crouching || matches!(p.pose, crate::pose::Pose::Crouching);
let mut shared: u8 = 0;
if crouching {
shared |= 0x02;
}
if sprinting {
shared |= 0x08;
}
if p.swimming {
shared |= 0x10;
}
if p.fall_flying {
shared |= 0x80;
}
if shared != r.was_shared_flags
|| living_flags != r.was_living_flags
|| r.last_recorded_pose != Some(p.pose)
{
r.was_shared_flags = shared;
r.was_living_flags = living_flags;
r.last_recorded_pose = Some(p.pose);
use horizon_protocol::entity_data::{Value, serializer};
let pkt = horizon_protocol::packets::play::Clientbound::SetEntityData(
horizon_protocol::packets::play::SetEntityData {
id: r.entity_id,
values: vec![
(0, serializer::BYTE, Value::Byte(shared as i8)),
(6, serializer::POSE, Value::Id(p.pose.id())),
(8, serializer::BYTE, Value::Byte(living_flags as i8)),
],
},
);
r.session.record_clientbound(&pkt);
}
}
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
let mut creative_slots = Vec::new();
@@ -10178,9 +10469,11 @@ impl Game<'_> {
/// A connection's screen is over the world: connecting, reconfiguring, or
/// the level loading screen, a portal's included (`remote_overlay`).
pub(super) fn remote_screen_up(&self) -> bool {
self.remote
.as_ref()
.is_some_and(|r| self.player.is_none() || r.level_load.is_active())
self.studio_replay.is_none()
&& self
.remote
.as_ref()
.is_some_and(|r| self.player.is_none() || r.level_load.is_active())
}
/// Connecting / loading screens; true when it covered the frame.
pub(super) fn remote_overlay(&mut self, batch: &mut HudBatch) -> bool {
@@ -10260,6 +10553,9 @@ impl Game<'_> {
/// Connected or connecting with no player yet: `ConnectScreen`, then
/// `LevelLoadingScreen`, over the panorama with its blur.
pub(super) fn remote_loading(&self) -> bool {
if self.studio_replay.is_some() {
return false;
}
use horizon_client::level_loading::Reason;
self.remote.as_ref().is_some_and(|r| {
if r.level_load.is_active() {
+11
View File
@@ -2282,6 +2282,17 @@ impl Game<'_> {
ctx,
);
}
/// `Entity.kill` for an entity that is no mob (`BlockAttachedEntity.kill`,
/// `ArmorStand.kill`, `VehicleEntity.destroy`): removed, then
/// `gameEvent(ENTITY_DIE, attributedTo != null ? attributedTo : this)` at
/// the entity's position. `by_player`: the player's hit killed it, so the
/// player is the source. Otherwise the source is the entity itself (or a
/// bolt), which no listener tells apart from none: it is not living,
/// sneaking, a spectator or a warden.
pub(super) fn entity_killed_event(&mut self, at: [f64; 3], by_player: bool) {
let ctx = self.who_context(by_player.then_some(Who::Player), None);
self.post_game_event("minecraft:entity_die", at, ctx);
}
}
/// `MobEffects.DARKNESS`'s `setBlendDuration(22)`: its fade in, its fade out,
@@ -662,6 +662,11 @@ impl Loading {
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::OpenStudioReplay(path) => {
self.menu.options_open = false;
self.menu.dragging = false;
self.menu.pending = Some(PendingWorldEntry::StudioReplay(path));
}
}
}
}
+743 -105
View File
@@ -1,36 +1,36 @@
//! Studio recording commands.
//! In-game Video Studio: Recording & Replay Subsystem
//!
//! Provides in-game and control-socket commands to record network packet streams
//! into standard `.mcpr` containers and drive timeline-based replay playback.
use super::Game;
use super::{Game, PendingWorldEntry, Screen};
use horizon_client::{
Result,
studio::{Metadata, generate_recording_filename},
studio::{Metadata, ReplayDriver},
text::Text,
};
use serde_json::{Value, json};
use std::path::PathBuf;
use std::{
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
pub const HELP: &[&str] = &[
"studio record start [NAME] start recording clientbound packets into an .mcpr archive",
"studio record stop stop recording and finalize the archive",
"studio record status show recording elapsed time, packets, and size",
"studio record start [NAME] start recording clientbound packets into .mcpr archive",
"studio record stop stop recording and finalize .mcpr archive",
"studio record status query recording status (active, elapsed, packets, bytes)",
"studio replay open PATH open .mcpr recording archive for playback",
"studio replay pause pause replay playback",
"studio replay resume resume replay playback",
"studio replay speed MULTIPLIER set replay playback speed (0.01..20.0)",
"studio replay seek MS seek replay playback to timestamp in milliseconds",
"studio replay status query replay playback status",
"studio replay close close active replay session",
];
fn studio_recording_metadata(is_singleplayer: bool) -> Metadata {
let server_name = if is_singleplayer {
"Singleplayer World"
} else {
"Multiplayer Server"
};
Metadata::new(
server_name.into(),
is_singleplayer,
horizon_core::release::VERSION.into(),
)
}
impl Game<'_> {
/// Handles Horizon's `/studio record` command without intercepting server
/// commands that merely share its prefix.
/// In-game chat command handler: `/studio <record|replay> ...`.
/// Returns true if the command was recognized and handled.
pub(super) fn studio_command(&mut self, command: &str) -> bool {
let Some(rest) = command.strip_prefix("studio") else {
return false;
@@ -39,113 +39,550 @@ impl Game<'_> {
return false;
}
let words: Vec<&str> = rest.split_whitespace().collect();
if words.first().copied() != Some("record") {
self.system_message(Text::translate("horizon.studio.record.usage", &[]));
if words.is_empty() {
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
return true;
}
match words.get(1).copied() {
Some("start") => match self.start_studio_recording(words.get(2).copied()) {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let path = v["path"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.started", &[&path]));
match words[0] {
"record" => match words.get(1).copied() {
Some("start") => match self.start_studio_recording(words.get(2).copied()) {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let path = v["path"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.started", &[&path]));
}
Ok(v) => {
let error = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
Err(error) => {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
},
Some("stop") => match self.stop_studio_recording() {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let path = v["path"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.saved", &[&path]));
}
Ok(v) => {
let error = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
Err(error) => {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
},
Some("status") => {
let status = self.studio_recording_status();
if status["active"].as_bool() == Some(true) {
let elapsed =
(status["elapsed_ms"].as_u64().unwrap_or(0) / 1000).to_string();
let packets = status["packet_count"].as_u64().unwrap_or(0).to_string();
let size =
(status["bytes_written"].as_u64().unwrap_or(0) / 1024).to_string();
let path = status["path"].as_str().unwrap_or_default();
self.system_message(Text::tr(
"horizon.studio.record.status.active",
&[&elapsed, &packets, &size, &path],
));
} else if let Some(error) = status["error"].as_str() {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
} else {
self.system_message(Text::translate(
"horizon.studio.record.status.inactive",
&[],
));
}
}
Ok(v) => {
let error = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
_ => self.system_message(Text::translate("horizon.studio.record.usage", &[])),
},
"replay" => match words.get(1).copied() {
Some("open") => {
let Some(path) = words.get(2) else {
self.system_message(Text::translate(
"horizon.studio.replay.open.usage",
&[],
));
return true;
};
match self.open_studio_replay(path) {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let duration_s = v["duration_ms"].as_f64().unwrap_or(0.0) / 1000.0;
let packets = v["packet_count"].as_u64().unwrap_or(0);
let duration = format!("{duration_s:.1}");
let packets = packets.to_string();
self.system_message(Text::tr(
"horizon.studio.replay.opened",
&[path, &duration, &packets],
));
}
Ok(v) => {
let err = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr(
"horizon.studio.replay.open_error",
&[&err],
));
}
Err(e) => {
let error = e.to_string();
self.system_message(Text::tr(
"horizon.studio.replay.open_error",
&[&error],
));
}
}
}
Err(error) => {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
Some("pause") => {
if let Some(r) = self.studio_replay.as_mut() {
r.set_paused(true);
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
Some("resume") => {
if let Some(r) = self.studio_replay.as_mut() {
r.set_paused(false);
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
Some("speed") => {
let Some(spd_str) = words.get(2) else {
self.system_message(Text::translate(
"horizon.studio.replay.speed.usage",
&[],
));
return true;
};
if let Ok(spd) = spd_str.parse::<f64>() {
let new_speed = if let Some(r) = self.studio_replay.as_mut() {
r.set_speed(spd);
Some(r.speed())
} else {
None
};
if let Some(speed) = new_speed {
let speed = format!("{speed:.2}");
self.system_message(Text::tr(
"horizon.studio.replay.speed.set",
&[&speed],
));
} else {
self.system_message(Text::translate(
"horizon.studio.replay.inactive",
&[],
));
}
} else {
self.system_message(Text::translate(
"horizon.studio.replay.speed.invalid",
&[],
));
}
}
Some("seek") => {
let Some(ms_str) = words.get(2) else {
self.system_message(Text::translate(
"horizon.studio.replay.seek.usage",
&[],
));
return true;
};
if let Ok(ms) = ms_str.parse::<u32>() {
if self.studio_replay.is_some() {
match self.seek_studio_replay(ms) {
Ok(()) => {
let actual_ms = self
.studio_replay
.as_ref()
.map_or(ms, |replay| ms.min(replay.duration_ms()));
let ms_text = actual_ms.to_string();
let seconds = format!("{:.1}", actual_ms as f32 / 1000.0);
self.system_message(Text::tr(
"horizon.studio.replay.seeked",
&[&ms_text, &seconds],
));
}
Err(e) => {
if e.kind() == std::io::ErrorKind::Unsupported {
self.system_message(Text::translate(
"horizon.studio.replay.seek.backward_requires_standalone",
&[],
));
} else {
let error = e.to_string();
self.system_message(Text::tr(
"horizon.studio.replay.seek.error",
&[&error],
));
}
}
}
} else {
self.system_message(Text::translate(
"horizon.studio.replay.inactive",
&[],
));
}
} else {
self.system_message(Text::translate(
"horizon.studio.replay.seek.invalid",
&[],
));
}
}
Some("status") => {
if let Some(r) = self.studio_replay.as_ref() {
let position = r.position_ms().to_string();
let duration = r.duration_ms().to_string();
let progress = format!("{:.1}", r.progress() * 100.0);
let speed = format!("{:.2}", r.speed());
let paused = r.is_paused().to_string();
let finished = r.is_finished().to_string();
self.system_message(Text::tr(
"horizon.studio.replay.status",
&[&position, &duration, &progress, &speed, &paused, &finished],
));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
Some("close") => {
if self.close_studio_replay() {
self.system_message(Text::translate("horizon.studio.replay.closed", &[]));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
_ => {
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
}
},
Some("stop") => match self.stop_studio_recording() {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let path = v["path"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.saved", &[&path]));
}
Ok(v) => {
let error = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
Err(error) => {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
},
Some("status") => {
let status = self.studio_recording_status();
if status["active"].as_bool() == Some(true) {
let elapsed = (status["elapsed_ms"].as_u64().unwrap_or(0) / 1000).to_string();
let packets = status["packet_count"].as_u64().unwrap_or(0).to_string();
let size = (status["bytes_written"].as_u64().unwrap_or(0) / 1024).to_string();
let path = status["path"].as_str().unwrap_or_default();
self.system_message(Text::tr(
"horizon.studio.record.status.active",
&[&elapsed, &packets, &size, &path],
));
} else if let Some(error) = status["error"].as_str() {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
} else {
self.system_message(Text::translate(
"horizon.studio.record.status.inactive",
&[],
));
}
_ => {
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
}
_ => self.system_message(Text::translate("horizon.studio.record.usage", &[])),
}
true
}
/// Control socket command handler for `studio record`.
/// Reset playback tick accumulator and align positions on seek.
pub(super) fn reset_studio_playback_ticks(&mut self) {
self.studio_virtual_tick_ms = 0.0;
if let Some(player) = &self.player {
self.previous_position = player.position;
if let Some(m) = self.player_model.as_mut() {
m.previous = player.position;
m.position = player.position;
m.limb_speed = 0.0;
m.previous_limb_speed = 0.0;
}
}
}
/// Reconstruct replay state at a target timestamp. Standalone replays can
/// safely reset their synthetic remote; an in-world replay can only seek
/// forward because its pre-replay world must remain intact.
fn seek_studio_replay(&mut self, target_ms: u32) -> std::io::Result<()> {
let current_ms = self
.studio_replay
.as_ref()
.map_or(0, ReplayDriver::position_ms);
let packets = if self.studio_replay_standalone {
let packets = self
.studio_replay
.as_mut()
.expect("replay checked by caller")
.seek_and_collect(target_ms)?;
self.init_remote_for_replay()
.map_err(|error| std::io::Error::other(error.to_string()))?;
packets
} else {
if target_ms < current_ms {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"backward seek requires a standalone replay",
));
}
self.studio_replay
.as_mut()
.expect("replay checked by caller")
.seek_forward(target_ms)?
};
self.dispatch_studio_replay_packets(packets);
self.reset_studio_playback_ticks();
self.last_replay_tick = Some(std::time::Instant::now());
Ok(())
}
fn dispatch_studio_replay_packets(
&mut self,
packets: Vec<horizon_client::studio::mcpr::TimestampedPacket>,
) {
for pkt in packets {
match horizon_protocol::packets::play::Clientbound::decode(pkt.id, &pkt.payload) {
Ok(packet) => {
let _ = self.handle_packet(packet);
}
Err(e) => {
let name = horizon_protocol::ids::name(
horizon_protocol::State::Play,
horizon_protocol::Direction::Clientbound,
pkt.id,
)
.unwrap_or("?");
log::debug!(
"Studio replay skipped packet {name} (id {:#x}): {e}",
pkt.id
);
}
}
}
}
/// Advance active replay playback by elapsed frame time and dispatch ready packets.
pub(super) fn advance_studio_replay(&mut self) -> Result<()> {
let Some(driver) = self.studio_replay.as_mut() else {
self.last_replay_tick = None;
return Ok(());
};
let now = std::time::Instant::now();
let delta = match self.last_replay_tick {
Some(last) => now
.saturating_duration_since(last)
.min(Duration::from_millis(250)),
None => Duration::from_millis(16),
};
self.last_replay_tick = Some(now);
let ready_packets = match driver.advance(delta) {
Ok(pkts) => pkts,
Err(e) => {
log::warn!("Studio replay advance error: {e}");
return Ok(());
}
};
self.dispatch_studio_replay_packets(ready_packets);
let paused = self
.studio_replay
.as_ref()
.is_some_and(|replay| replay.is_paused());
if self.player_model.is_none() {
self.tick_player_model();
}
if !paused {
let speed = self
.studio_replay
.as_ref()
.map_or(1.0, |r| r.speed() as f32)
.max(0.1);
self.studio_virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
while self.studio_virtual_tick_ms >= 50.0 {
self.studio_virtual_tick_ms -= 50.0;
if let Some(player) = &self.player {
let current_pos = player.position;
let (dx, dz) = (
current_pos.x - self.previous_position.x,
current_pos.z - self.previous_position.z,
);
let walked = (dx * dx + dz * dz).sqrt() as f32;
if let Some(m) = self.player_model.as_mut() {
m.previous = self.previous_position;
m.position = current_pos;
m.previous_limb_speed = m.limb_speed;
let target_speed = (walked * 4.0).min(1.0);
m.limb_speed += (target_speed - m.limb_speed) * 0.4;
if m.limb_speed < 0.001 {
m.limb_speed = 0.0;
}
m.limb_position += m.limb_speed;
m.previous_yaw = m.yaw;
m.previous_head_yaw = m.head_yaw;
m.previous_head_pitch = m.head_pitch;
m.previous_body_yaw = m.body_yaw;
m.yaw = self.yaw;
m.head_yaw = self.yaw;
m.head_pitch = self.pitch;
let mut target = m.body_yaw;
if dx * dx + dz * dz > 0.0025 {
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
let facing =
((self.yaw - walk + 180.0).rem_euclid(360.0) - 180.0).abs();
target = if 95.0 < facing && facing < 265.0 {
walk - 180.0
} else {
walk
};
}
let diff = (target - m.body_yaw + 180.0).rem_euclid(360.0) - 180.0;
m.body_yaw += diff * 0.3;
let body_diff = (self.yaw - m.body_yaw + 180.0).rem_euclid(360.0) - 180.0;
if body_diff.abs() > 50.0 {
m.body_yaw += body_diff - body_diff.signum() * 50.0;
}
m.age = m.age.wrapping_add(1);
}
self.previous_position = current_pos;
}
}
} else if let Some(m) = self.player_model.as_mut() {
m.previous_limb_speed = m.limb_speed;
}
Ok(())
}
/// Control socket command handler for `studio`.
pub(super) fn studio_control(&mut self, words: &[&str]) -> Result<Option<Value>> {
if words.first().copied() != Some("studio") {
return Ok(None);
}
let value = match words.get(1).copied() {
match words.get(1).copied() {
Some("record") => match words.get(2).copied() {
Some("start") => self.start_studio_recording(words.get(3).copied())?,
Some("stop") => self.stop_studio_recording()?,
Some("status") => self.studio_recording_status(),
_ => json!({ "error": "unknown studio record subcommand" }),
Some("start") => {
let name = words.get(3).copied();
let res = self.start_studio_recording(name)?;
Ok(Some(res))
}
Some("stop") => {
let res = self.stop_studio_recording()?;
Ok(Some(res))
}
Some("status") => {
let res = self.studio_recording_status();
Ok(Some(res))
}
_ => Ok(Some(json!({ "error": "unknown studio record subcommand" }))),
},
_ => json!({ "error": "unknown studio command" }),
};
Ok(Some(value))
Some("replay") => match words.get(2).copied() {
Some("open") => {
let path = words.get(3).ok_or("missing path for studio replay open")?;
let res = self.open_studio_replay(path)?;
Ok(Some(res))
}
Some("pause") => {
if let Some(r) = self.studio_replay.as_mut() {
r.set_paused(true);
Ok(Some(json!({ "ok": true, "paused": true })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
Some("resume") => {
if let Some(r) = self.studio_replay.as_mut() {
r.set_paused(false);
Ok(Some(json!({ "ok": true, "paused": false })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
Some("speed") => {
let spd_str = words.get(3).ok_or("missing speed value")?;
let spd: f64 = spd_str.parse().map_err(|_| "invalid speed")?;
if let Some(r) = self.studio_replay.as_mut() {
r.set_speed(spd);
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
Some("seek") => {
let ms_str = words.get(3).ok_or("missing seek timestamp")?;
let ms: u32 = ms_str.parse().map_err(|_| "invalid timestamp")?;
let pos = if let Some(r) = self.studio_replay.as_mut() {
r.seek(ms).map_err(|e| format!("{e}"))?;
Some(r.position_ms())
} else {
None
};
if let Some(pos) = pos {
self.reset_studio_playback_ticks();
Ok(Some(json!({ "ok": true, "position_ms": pos })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
Some("status") => {
if let Some(r) = self.studio_replay.as_ref() {
Ok(Some(json!({
"ok": true,
"active": true,
"position_ms": r.position_ms(),
"duration_ms": r.duration_ms(),
"progress": r.progress(),
"speed": r.speed(),
"paused": r.is_paused(),
"finished": r.is_finished(),
})))
} else {
Ok(Some(json!({ "ok": true, "active": false })))
}
}
Some("close") => {
let closed = self.close_studio_replay();
Ok(Some(json!({ "ok": true, "closed": closed })))
}
_ => Ok(Some(json!({ "error": "unknown studio replay subcommand" }))),
},
_ => Ok(Some(json!({ "error": "unknown studio command" }))),
}
}
pub(super) fn start_studio_recording(&mut self, name: Option<&str>) -> Result<Value> {
let Some(remote) = self.remote.as_ref() else {
let Some(r) = self.remote.as_ref() else {
return Ok(json!({ "ok": false, "error": "no active world session" }));
};
if remote.session.is_recording() {
if r.session.is_recording() {
return Ok(json!({ "ok": false, "error": "recording already active" }));
}
let recordings = PathBuf::from("recordings");
std::fs::create_dir_all(&recordings)?;
let path = recordings.join(generate_recording_filename(name));
let is_singleplayer =
remote.session.address.is_empty() || remote.session.address == "local";
let metadata = studio_recording_metadata(is_singleplayer);
match remote.session.start_recording(&path, metadata) {
let _ = std::fs::create_dir_all("recordings");
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = match name {
Some(n) if !n.is_empty() => format!("{}_{}.mcpr", n, ts),
_ => format!("recording_{}.mcpr", ts),
};
let path = PathBuf::from("recordings").join(filename);
let server_address = if r.session.address.is_empty() {
"local".to_owned()
} else {
r.session.address.clone()
};
let is_singleplayer = server_address == "local";
let meta = Metadata::new(
server_address,
is_singleplayer,
horizon_core::release::VERSION.into(),
);
match r
.session
.start_recording_with_init(&path, meta, |recorder| {
self.dump_world_snapshot_to_recorder(recorder);
}) {
Ok(()) => Ok(json!({ "ok": true, "path": path.display().to_string() })),
Err(error) => Ok(json!({ "ok": false, "error": error.to_string() })),
Err(e) => Ok(json!({ "ok": false, "error": e.to_string() })),
}
}
pub(super) fn stop_studio_recording(&mut self) -> Result<Value> {
let Some(remote) = self.remote.as_ref() else {
let Some(r) = self.remote.as_ref() else {
return Ok(json!({ "ok": false, "error": "no active world session" }));
};
match remote.session.stop_recording() {
match r.session.stop_recording() {
Some(Ok(path)) => Ok(json!({ "ok": true, "path": path.display().to_string() })),
Some(Err(error)) => Ok(json!({ "ok": false, "error": error.to_string() })),
Some(Err(e)) => Ok(json!({ "ok": false, "error": e.to_string() })),
None => Ok(json!({ "ok": false, "error": "no active recording" })),
}
}
pub(super) fn studio_recording_status(&self) -> Value {
if let Some(remote) = self.remote.as_ref()
&& let Some(status) = remote.session.recorder_status()
if let Some(r) = self.remote.as_ref()
&& let Some(status) = r.session.recorder_status()
{
json!({
"ok": true,
@@ -154,26 +591,227 @@ impl Game<'_> {
"elapsed_ms": status.elapsed.as_millis(),
"packet_count": status.packet_count,
"bytes_written": status.bytes_written,
"error": status.error,
})
} else {
json!({ "ok": true, "active": false, "error": null })
json!({ "ok": true, "active": false })
}
}
}
#[cfg(test)]
mod tests {
use super::studio_recording_metadata;
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
// The recordings browser can be opened while the title screen is
// available but the slower world tables are still loading. Replay
// setup builds the normal client registries, which require `rest`.
if self.remote.is_none() && self.res.rest.get().is_none() {
let pending = PendingWorldEntry::StudioReplay(PathBuf::from(path));
if self.defer_world_entry(pending)? {
return Ok(json!({ "ok": true, "pending": true }));
}
self.join_world_rest()?;
}
#[test]
fn recording_metadata_does_not_store_server_addresses_or_player_names() {
let multiplayer = studio_recording_metadata(false);
assert_eq!(multiplayer.server_name, "Multiplayer Server");
assert!(multiplayer.players.is_empty());
// Validate and index the archive before replacing the title menu or active world. A
// temp-file, metadata, or protocol error must leave the current screen usable.
let driver = match ReplayDriver::open(Path::new(path)) {
Ok(driver) => driver,
Err(error) => {
log::error!("Failed to open studio replay '{path}': {error}");
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
};
let singleplayer = studio_recording_metadata(true);
assert_eq!(singleplayer.server_name, "Singleplayer World");
assert!(singleplayer.players.is_empty());
let standalone = self.studio_replay.is_none() && self.remote.is_none();
if self.remote.is_none()
&& let Err(error) = self.init_remote_for_replay()
{
log::error!("Failed to initialize studio replay session: {error}");
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
let duration_ms = driver.duration_ms();
let packets = driver.packet_count();
if self.studio_replay.is_none() {
self.studio_replay_previous_camera_type = Some(self.camera_type);
self.studio_replay_standalone = standalone;
}
self.studio_replay = Some(driver);
self.camera_type = super::perspective::CameraType::FirstPerson;
self.title = None;
self.screen = Screen::Playing;
self.capture(false);
self.dragging = false;
// Apply timestamp-zero login, player pose, and chunk packets before the first rendered
// frame so playback opens at the recorded player's viewpoint.
let _ = self.advance_studio_replay();
self.reset_studio_playback_ticks();
log::info!("Studio: Opened replay '{path}' ({duration_ms} ms, {packets} packets)");
Ok(json!({
"ok": true,
"duration_ms": duration_ms,
"packet_count": packets,
}))
}
pub(super) fn close_studio_replay(&mut self) -> bool {
let closed = self.studio_replay.take().is_some();
if !closed {
return false;
}
// A standalone replay owns a synthetic Remote connection. Treat that
// connection as replay state even if the saved origin flag was lost;
// sending it through leave_server() opens the Multiplayer screen.
let standalone = self.studio_replay_standalone
|| self
.remote
.as_ref()
.is_some_and(|remote| remote.brand == "horizon-studio");
self.studio_replay_standalone = false;
if let Some(camera_type) = self.studio_replay_previous_camera_type.take() {
self.camera_type = camera_type;
}
self.last_replay_tick = None;
self.studio_virtual_tick_ms = 0.0;
self.capture(false);
if standalone {
if let Some(mut remote) = self.remote.take() {
remote.session.disconnect();
}
self.using = None;
self.end_mod_use();
if let Err(error) = self.finish_close_world() {
log::error!("Failed to close standalone Studio replay cleanly: {error}");
}
self.reset_studio_hand_state();
// A title-screen replay has no previous world perspective to
// restore. Start the next world in first person with visible hands.
self.camera_type = super::perspective::CameraType::FirstPerson;
if let Some(title) = self.title.as_mut() {
title.mode = horizon_client::title::Mode::Main;
}
} else if self.integrated.is_none() && self.lan.is_none() {
let _ = self.close_world();
if let Some(title) = self.title.as_mut() {
title.mode = horizon_client::title::Mode::Main;
}
}
closed
}
/// Dump current player pose, dimension login, center chunk, and resident loaded chunks
/// into the recording stream at timestamp 0ms so replays accurately reconstruct the world.
pub(super) fn dump_world_snapshot_to_recorder(
&self,
recorder: &horizon_client::studio::StudioRecorder,
) {
let Some(r) = self.remote.as_ref() else {
return;
};
let record_cb = |pkt: horizon_protocol::packets::play::Clientbound| {
let encoded = pkt.encode();
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
if let Ok(id) = reader.varint() {
let payload = reader.rest();
recorder.record_packet_at(0, id, payload);
}
};
// 1. C::Login packet baseline
let login = horizon_protocol::packets::play::Clientbound::Login(
horizon_protocol::packets::play::Login {
player_id: r.entity_id,
hardcore: self.level.hardcore,
levels: vec!["minecraft:overworld".into()],
max_players: 20,
chunk_radius: self.settings.render_distance,
simulation_distance: self.settings.simulation_distance,
reduced_debug_info: false,
show_death_screen: true,
limited_crafting: false,
spawn: horizon_protocol::packets::play::SpawnInfo {
dimension_type: 0,
dimension: "minecraft:overworld".into(),
seed: 0,
game_type: 0,
previous_game_type: None,
debug: false,
flat: false,
last_death: None,
portal_cooldown: 0,
sea_level: 63,
},
online_mode: false,
enforces_secure_chat: false,
},
);
record_cb(login);
// 2. Center chunk and player position
let (pos, yaw, pitch) = if let Some(p) = &self.player {
(
glam::DVec3::new(p.position.x, p.position.y, p.position.z),
self.yaw,
self.pitch,
)
} else {
(glam::DVec3::ZERO, self.yaw, self.pitch)
};
let chunk_x = (pos.x.floor() as i32) >> 4;
let chunk_z = (pos.z.floor() as i32) >> 4;
let center_chunk = horizon_protocol::packets::play::Clientbound::SetChunkCacheCenter(
horizon_protocol::packets::play::SetChunkCacheCenter {
x: chunk_x,
z: chunk_z,
},
);
record_cb(center_chunk);
let player_pos = horizon_protocol::packets::play::Clientbound::PlayerPosition(
horizon_protocol::packets::play::PlayerPosition {
teleport: 0,
position: [pos.x, pos.y, pos.z],
delta: [0.0, 0.0, 0.0],
yaw,
pitch,
relatives: 0,
},
);
record_cb(player_pos);
// 3. Encode resident loaded chunks into LevelChunkWithLight packets
let biome_names: Vec<String> = world_biomes!(self)
.list
.iter()
.map(|b| b.name.clone())
.collect();
let registry: Vec<String> = r
.registries
.registries
.get("minecraft:worldgen/biome")
.map(|v| v.iter().map(|(n, _)| n.clone()).collect())
.unwrap_or_else(|| biome_names.clone());
let encoder = crate::lan::chunks::ChunkEncoder::with_signs(
self.res.facts().states.iter().map(|s| {
(
s.name.clone(),
s.fluid_level > 0 || s.waterlogged,
s.sign_form(),
)
}),
&r.builtin,
&biome_names,
&registry,
);
let mut count = 0;
for chunk in self.world.chunks.values() {
let level_chunk = encoder.encode(chunk, &self.world.block_entities);
record_cb(
horizon_protocol::packets::play::Clientbound::LevelChunkWithLight(level_chunk),
);
count += 1;
}
log::info!(
"Studio Recorder: Dumped initial world snapshot ({count} chunks) at timestamp 0ms"
);
}
}
+2
View File
@@ -475,6 +475,8 @@ impl Game<'_> {
let swap = self.world.random.next_int_bound(i as i32).unwrap_or(0) as usize;
targets.swap(i - 1, swap);
}
let dimension = self.dim.id();
self.native_wind_burst(dimension, center, &mut targets);
for pos in targets {
self.wind_trigger_block(pos)?;
}
+47 -2
View File
@@ -1179,14 +1179,22 @@ pub fn rendered_amount(count: u32) -> u32 {
/// `ItemEntityRenderer.submitMultipleFromCount`: the poses of each copy of
/// a stack (`seed` is `ItemClusterRenderState.getSeedForItemStack`).
pub fn cluster_poses(state: &ItemRenderState, pose: Mat4, amount: u32, seed: i64) -> Vec<Mat4> {
if amount == 0 {
return Vec::new();
}
let (lo, hi) = state.bounding_box();
cluster_poses_with_depth(pose, amount, seed, hi.z - lo.z)
}
/// [`cluster_poses`] with the depth of the resolved model's bounding box
/// already computed for this draw (also used to place the stack's bob).
pub fn cluster_poses_with_depth(pose: Mat4, amount: u32, seed: i64, depth: f32) -> Vec<Mat4> {
use horizon_core::random::{LegacyRandom, RandomSource};
let mut out = Vec::new();
if amount == 0 {
return out;
}
let mut random = LegacyRandom::new(seed);
let (lo, hi) = state.bounding_box();
let depth = hi.z - lo.z;
let mut next = || random.next_float() * 2.0 - 1.0;
if depth > 0.0625 {
out.push(pose);
@@ -1308,6 +1316,43 @@ mod tests {
assert_eq!(hi, Vec3::new(2.5, 3.0, 0.25));
}
#[test]
fn stack_copy_positions_keep_the_flat_and_solid_random_sequences() {
// Captured from the original cluster renderer with seed 7, three
// copies and an identity pose. Flat items draw two random values
// per extra copy; solid items draw three.
for (depth, expected) in [
(
0.03125,
[
[0, 0, 3_175_088_128],
[1_024_310_737, 1_017_789_485, 0],
[1_025_054_461, 3_180_766_298, 1_027_604_480],
],
),
(
0.25,
[
[0, 0, 0],
[1_032_699_345, 1_026_178_093, 1_033_443_069],
[3_189_154_906, 3_174_720_933, 3_141_056_154],
],
),
] {
let copies = cluster_poses_with_depth(Mat4::IDENTITY, 3, 7, depth);
assert_eq!(copies.len(), expected.len());
for (pose, position) in copies.iter().zip(expected) {
let expected =
Mat4::from_translation(Vec3::from_array(position.map(f32::from_bits)));
assert_eq!(
pose.to_cols_array().map(f32::to_bits),
expected.to_cols_array().map(f32::to_bits)
);
}
}
assert!(cluster_poses_with_depth(Mat4::IDENTITY, 0, 7, 0.25).is_empty());
}
/// One frame's glint is read once and shared: every item drawn from the
/// same `GlintFrame` scrolls by the same phase, and without the atlas
/// sprite no item gets a glint (`TextureTransform.setupGlintTexturing`
+119 -3
View File
@@ -2839,7 +2839,10 @@ impl Hub {
/// PlayerChunkSender.sendNextChunks' choice: ready chunks, closest first,
/// as many as the batch quota the client's acknowledgements pace allows,
/// with their places in the queue. What the view leaves of the quota goes
/// to the far ring, as far as its brake lets it reach.
/// to the far ring, as far as its brake lets it reach. A memory connection
/// that has not loaded yet sends every ready view column in that one
/// batch; the quota recorded for it covers them, so the tick after
/// `PlayerLoaded` is capped again.
fn next_batch<'w>(
&mut self,
i: usize,
@@ -2855,6 +2858,10 @@ impl Hub {
return None;
}
let budget = g.batch_quota.floor() as usize;
// The integrated server's own player, still on the loading screen.
// LAN guests and a client that has loaded keep the quota above.
let burst = g.address == super::conn::MEMORY_ADDRESS && !g.loaded;
let near_take = if burst { g.chunk_queue.len() } else { budget };
// ChunkStatus.LIGHT needs its neighbours at INITIALIZE_LIGHT: a
// column whose light layers would leave one out waits for it.
let ready = |c: &Chunk| {
@@ -2875,11 +2882,12 @@ impl Hub {
.enumerate()
.filter_map(|(idx, key)| Some((idx, &**world.chunks.get(key)?)))
.filter(|(_, c)| ready(c))
.take(budget)
.take(near_take)
.collect();
let mut far = Vec::new();
if let (Some(ring), Some((cx, cz))) = (self.far.as_mut(), g.center) {
let mut left = budget - near.len();
// What the view left of the vanilla quota. A first-view burst can take more than that.
let mut left = budget.saturating_sub(near.len());
for (idx, &(x, z)) in g.far_queue.iter().enumerate().take(super::far::LOOKS) {
if left == 0 {
break;
@@ -2900,6 +2908,11 @@ impl Hub {
far.push((idx, column));
}
}
// `update_chunks` subtracts the batch. Covering the near columns keeps that from going
// negative, so the next tick's `.min(chunks_per_tick)` cap holds again.
if burst {
g.batch_quota = g.batch_quota.max(near.len() as f32);
}
(!near.is_empty() || !far.is_empty()).then_some(Batch { near, far })
}
@@ -6327,4 +6340,107 @@ mod tests {
}
}
}
/// A single-player memory connection sends every ready view column before
/// `PlayerLoaded`. After that, on a respawn, and for a socket guest, the
/// batch is `chunks_per_tick` again (64). An unlit column stays out, and
/// the gates that send nothing still do.
#[test]
fn memory_connection_first_view_sends_every_ready_column() {
let (mut hub, _rx) = hub_with_a_guest();
hub.ticks = 3;
{
let g = &mut hub.guests[0];
g.chunks_per_tick = 64.0;
g.batch_quota = 0.0;
g.address = crate::lan::conn::MEMORY_ADDRESS;
g.loaded = false;
}
// The unlit column first, then 80 lit ones (`floor_at`'s empty column, lit).
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
let mut dark = crate::chunk::Chunk::empty(100, 100);
dark.lit = false;
world.chunks.insert((100, 100), std::sync::Arc::new(dark));
hub.guests[0].chunk_queue.push((100, 100));
for i in 0..80 {
let (x, z) = (i % 16, i / 16);
let mut chunk = crate::chunk::Chunk::empty(x, z);
chunk.lit = true;
world.chunks.insert((x, z), std::sync::Arc::new(chunk));
hub.guests[0].chunk_queue.push((x, z));
}
let batch = hub
.next_batch(0, &world, None)
.expect("the first view sends");
assert_eq!(
batch.near.len(),
80,
"every ready column leaves in one batch"
);
assert!(
batch.near.iter().all(|(_, c)| c.lit),
"an unlit column is not ready"
);
assert!(batch.far.is_empty());
assert!(hub.guests[0].batch_quota >= 80.0);
hub.guests[0].loaded = true;
let loaded = hub
.next_batch(0, &world, None)
.expect("a loaded client still receives chunks");
assert_eq!(loaded.near.len(), 64, "PlayerLoaded puts the cap back");
hub.guests[0].address = "127.0.0.1:25565".parse().unwrap();
hub.guests[0].loaded = false;
hub.guests[0].batch_quota = 0.0;
let socket = hub
.next_batch(0, &world, None)
.expect("a socket guest still receives chunks");
assert_eq!(socket.near.len(), 64, "a socket guest keeps the quota");
hub.guests[0].address = crate::lan::conn::MEMORY_ADDRESS;
hub.guests[0].loaded = true;
hub.guests[0].loading = true;
hub.guests[0].batch_quota = 0.0;
let respawn = hub
.next_batch(0, &world, None)
.expect("a respawned player still receives chunks");
assert_eq!(
respawn.near.len(),
64,
"a respawn stays on the vanilla pace"
);
// Seventy lit columns, the unlit one still queued: all seventy, in one batch.
hub.guests[0].loaded = false;
hub.guests[0].batch_quota = 0.0;
hub.guests[0].chunk_queue.truncate(71);
let seventy = hub
.next_batch(0, &world, None)
.expect("seventy ready columns send");
assert_eq!(seventy.near.len(), 70);
assert!(seventy.near.iter().all(|(_, c)| c.lit));
hub.guests[0].unacknowledged = hub.guests[0].max_unacknowledged;
assert!(
hub.next_batch(0, &world, None).is_none(),
"too many batches still waiting sends nothing"
);
hub.guests[0].unacknowledged = 0;
hub.ticks = hub.guests[0].placed_at + 1;
assert!(
hub.next_batch(0, &world, None).is_none(),
"the tick after placement still waits"
);
hub.ticks = 3;
hub.guests[0].chunk_queue.clear();
hub.guests[0].far_queue.clear();
assert!(
hub.next_batch(0, &world, None).is_none(),
"an empty queue sends nothing"
);
}
}
@@ -188,6 +188,26 @@ pub fn set_state(m: &mut Mob, state: u8) {
a.in_state_ticks = 0;
}
}
/// `ArmadilloState`'s name (`StringRepresentable`, which its `CODEC` writes):
/// what `Armadillo.addAdditionalSaveData` stores under `state`.
pub fn state_name(state: u8) -> &'static str {
match state {
ROLLING => "rolling",
SCARED => "scared",
UNROLLING => "unrolling",
_ => "idle",
}
}
/// `ArmadilloState.CODEC` read back: a name that is no state, and no name at
/// all, are `IDLE` (`read("state", CODEC).orElse(IDLE)`).
pub fn state_by_name(name: Option<&str>) -> u8 {
match name {
Some("rolling") => ROLLING,
Some("scared") => SCARED,
Some("unrolling") => UNROLLING,
_ => IDLE,
}
}
/// `Armadillo.canStayRolledUp`.
fn can_stay_rolled_up(m: &Mob) -> bool {
!m.ai.panicking
@@ -807,6 +827,65 @@ mod tests {
assert!(!source.causes_panic());
assert!(!environmental(DamageKind::Campfire));
}
/// `Armadillo.addAdditionalSaveData` writes `state` (the `ArmadilloState`
/// name, always) and `readAdditionalSaveData` switches to it, `idle` when
/// the save names none. Horizon models the state and wrote only
/// `scute_time`, so a rolled-up armadillo unloaded with its chunk came back
/// idle: hits it took for half the damage past the first point cost it the
/// whole amount, and its shell went away.
#[test]
fn a_rolled_up_armadillo_keeps_its_state_through_the_chunk_save() {
use crate::entity_nbt::{mob_from_nbt, mob_to_nbt};
use horizon_world::nbt::Tag;
fn armadillo_tag(state: Option<&str>) -> Tag {
let mut fields = vec![
("id", Tag::String("minecraft:armadillo".into())),
(
"Pos",
Tag::List(vec![
Tag::Double(0.5_f64.to_bits()),
Tag::Double(64.0_f64.to_bits()),
Tag::Double(0.5_f64.to_bits()),
]),
),
];
if let Some(state) = state {
fields.push(("state", Tag::String(state.into())));
}
Tag::compound(fields)
}
let types = [("minecraft:armadillo".into(), armadillo().kind.clone())].into();
// A ball takes half of the damage past the first point, and hides in
// its shell once the roll is five ticks in.
let mut ball = mob_from_nbt(&armadillo_tag(Some("rolling")), &types, 1, None).unwrap();
assert_eq!(state(&ball), ROLLING, "the loaded state");
assert_eq!(reduce_damage(&ball, 10.0), 4.5, "a rolling armadillo");
ball.extra.armadillo.in_state_ticks = 6;
assert!(hiding_in_shell(&ball), "six ticks into the roll");
// The save names the state the armadillo is in now, not the one its
// compound came in with (`store`, not `storeNullable`).
let saved = mob_to_nbt(
&ball,
[0; 4],
Some(&armadillo_tag(Some("idle"))),
None,
|_| None,
);
assert_eq!(saved.get("state"), Some(&Tag::String("rolling".into())));
let back = mob_from_nbt(&saved, &types, 2, None).unwrap();
assert_eq!(state(&back), ROLLING, "the reloaded state");
assert_eq!(reduce_damage(&back, 10.0), 4.5);
// `switchToState` restarts the time in the state
// (`onSyncedDataUpdated`), so the shell goes in again six ticks later.
assert_eq!(back.extra.armadillo.in_state_ticks, 0);
assert!(!hiding_in_shell(&back), "a reload restarts the roll");
// No state, or a name that is no state: `orElse(IDLE)`, full damage.
for absent in [None, Some("sneaking")] {
let idle = mob_from_nbt(&armadillo_tag(absent), &types, 3, None).unwrap();
assert_eq!(state(&idle), IDLE, "{absent:?}");
assert_eq!(reduce_damage(&idle, 10.0), 10.0, "{absent:?}");
}
}
}
#[cfg(test)]
+155 -1
View File
@@ -189,6 +189,23 @@ impl Session {
false,
)
}
/// A lightweight session for studio replay playback without background network IO.
pub fn replay(name: &str) -> Self {
let (_send, events) = channel();
Self {
events,
writer: Arc::new(Mutex::new(None)),
configuring: Arc::new(AtomicBool::new(false)),
thread: None,
address: "studio-replay".to_owned(),
name: name.to_owned(),
counters: Arc::new(Mutex::new(None)),
cookies: Default::default(),
debug: false,
far: Arc::new(crate::lod_net::Inbox::default()),
recorder: Arc::new(Mutex::new(None)),
}
}
fn start(
address: &str,
name: &str,
@@ -274,9 +291,25 @@ impl Session {
&self,
path: impl Into<PathBuf>,
metadata: Metadata,
) -> std::io::Result<()> {
self.start_recording_with_init(path, metadata, |_| {})
}
/// Start recording and initialize the stream while live packet recording is
/// excluded. This lets callers write the initial snapshot before packets
/// arriving on the network thread.
pub fn start_recording_with_init(
&self,
path: impl Into<PathBuf>,
metadata: Metadata,
initialize: impl FnOnce(&StudioRecorder),
) -> std::io::Result<()> {
let rec = StudioRecorder::start(path.into(), metadata)?;
*self.recorder.lock().unwrap() = Some(rec);
let mut recorder = self.recorder.lock().unwrap();
*recorder = Some(rec);
if let Some(recorder) = recorder.as_ref() {
initialize(recorder);
}
Ok(())
}
/// Stop recording and finalize the `.mcpr` archive.
@@ -296,6 +329,28 @@ impl Session {
pub fn recorder_status(&self) -> Option<RecorderStatus> {
self.recorder.lock().unwrap().as_ref().map(|r| r.status())
}
/// Record an explicit packet into the active recording session at `timestamp_ms`.
pub fn record_packet_at(&self, timestamp_ms: u32, id: i32, payload: &[u8]) {
if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
rec.record_packet_at(timestamp_ms, id, payload);
}
}
/// Record a packet into the active recording session at current elapsed time.
pub fn record_packet(&self, id: i32, payload: &[u8]) {
if let Some(rec) = self.recorder.lock().unwrap().as_ref() {
rec.record_packet(id, payload);
}
}
/// Record an encoded Clientbound packet into the active recording session at current elapsed time.
pub fn record_clientbound(&self, packet: &horizon_protocol::packets::play::Clientbound) {
if self.is_recording() {
let encoded = packet.encode();
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
if let Ok(id) = reader.varint() {
self.record_packet(id, reader.rest());
}
}
}
/// Send a play packet; silently dropped once the connection is gone,
/// and while the server has the connection back in configuration (a
/// proxy switching servers). Vanilla's `handleConfigurationStart` clears
@@ -2349,4 +2404,103 @@ mod tests {
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn studio_session_record_local_player_motion() {
use crate::studio::Metadata;
use horizon_protocol::packets::play;
let session = Session::replay("StudioTester");
let temp_dir =
std::env::temp_dir().join(format!("horizon-studio-motion-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&temp_dir);
let mcpr_path = temp_dir.join("test_motion.mcpr");
let meta = Metadata::new("local".into(), true, "26.3".into());
session.start_recording(&mcpr_path, meta).unwrap();
assert!(session.is_recording());
// Simulate local player movement packet recorded during recording session
let pos_pkt = play::Clientbound::PlayerPosition(play::PlayerPosition {
teleport: 0,
position: [12.5, 64.0, -88.5],
delta: [0.0; 3],
yaw: 90.0,
pitch: 15.0,
relatives: 0,
});
let encoded = pos_pkt.encode();
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
let id = reader.varint().unwrap();
session.record_packet(id, reader.rest());
let saved_path = session.stop_recording().unwrap().unwrap();
assert_eq!(saved_path, mcpr_path);
assert!(!session.is_recording());
// Verify the recording preserves the motion packet for replay playback.
let mut recording = crate::studio::McprReader::open(&mcpr_path).unwrap();
assert_eq!(recording.total_packets(), 1);
let packet = recording.next_packet().unwrap().unwrap();
let decoded = play::Clientbound::decode(packet.id, &packet.payload).unwrap();
assert_eq!(decoded, pos_pkt);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn studio_session_record_local_player_animations() {
use crate::studio::Metadata;
use horizon_protocol::entity_data::{Value, serializer};
use horizon_protocol::packets::play;
let session = Session::replay("StudioTester");
let temp_dir =
std::env::temp_dir().join(format!("horizon-studio-anim-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&temp_dir);
let mcpr_path = temp_dir.join("test_anim.mcpr");
let meta = Metadata::new("local".into(), true, "26.3".into());
session.start_recording(&mcpr_path, meta).unwrap();
assert!(session.is_recording());
// 1. Arm swing animation
let swing_pkt = play::Clientbound::SwingAnimation(play::SwingAnimation {
id: 42,
hand: 0,
kind: 1,
duration: 6,
});
session.record_clientbound(&swing_pkt);
// 2. Crouch and sprint entity metadata
let data_pkt = play::Clientbound::SetEntityData(play::SetEntityData {
id: 42,
values: vec![
(0, serializer::BYTE, Value::Byte(0x02)),
(6, serializer::POSE, Value::Id(5)),
],
});
session.record_clientbound(&data_pkt);
let saved_path = session.stop_recording().unwrap().unwrap();
assert_eq!(saved_path, mcpr_path);
assert!(!session.is_recording());
// Verify the recording preserves both animation packets for playback.
let mut recording = crate::studio::McprReader::open(&mcpr_path).unwrap();
assert_eq!(recording.total_packets(), 2);
let packets = [
recording.next_packet().unwrap().unwrap(),
recording.next_packet().unwrap().unwrap(),
];
let decoded_swing = play::Clientbound::decode(packets[0].id, &packets[0].payload).unwrap();
assert_eq!(decoded_swing, swing_pkt);
let decoded_data = play::Clientbound::decode(packets[1].id, &packets[1].payload).unwrap();
assert_eq!(decoded_data, data_pkt);
let _ = std::fs::remove_dir_all(&temp_dir);
}
}
+11
View File
@@ -30,6 +30,17 @@ impl Pose {
_ => Self::Standing,
}
}
/// Network ID for this pose (`Pose.id`).
pub fn id(self) -> i32 {
match self {
Self::Standing => 0,
Self::FallFlying => 1,
Self::Sleeping => 2,
Self::Swimming => 3,
Self::SpinAttack => 4,
Self::Crouching => 5,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Standing => "STANDING",
+301 -53
View File
@@ -4,22 +4,23 @@
//! - `metaData.json`: metadata about the recording session (version, duration, player UUIDs, etc.)
//! - `recording.tmcpr`: binary stream of chronological timestamped network packets:
//! `[timestamp_ms: u32 big-endian][packet_length: u32 big-endian][packet_data: VarInt(packet_id) + payload]`
//! - Optional `markers.json`: timeline markers/chapters.
//! - Optional `recording.tmcpr.crc32`: CRC32 of the packet stream.
use horizon_protocol::buf::{Reader, write_varint};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File, OpenOptions},
io::{self, BufReader, BufWriter, Read, Write},
io::{self, BufReader, BufWriter, Read, Seek, SeekFrom, Write},
path::{Path, PathBuf},
};
/// The most `metaData.json` may inflate to: real files are a few hundred bytes.
const MAX_METADATA_BYTES: u64 = 1 << 20;
/// The most `recording.tmcpr` may inflate to. The reader keeps the stream in
/// memory, so a hostile archive (deflate reaches about 1000:1) must not be able
/// to ask for more than this.
/// The most `recording.tmcpr` may inflate to. The reader streams to disk, but
/// still caps archive expansion to protect disk space from hostile archives.
const MAX_STREAM_BYTES: u64 = 1 << 30;
/// Keep one seek anchor for every group of this many packets.
const STREAM_INDEX_STRIDE: usize = 1024;
/// How much recording time a stream and its metadata may hold in memory before
/// both reach disk. A crash costs at most this many milliseconds of packets.
const FLUSH_INTERVAL_MS: u32 = 1_000;
@@ -120,6 +121,35 @@ fn sibling(dest: &Path, suffix: &str) -> PathBuf {
dest.with_file_name(name)
}
fn create_private_file(path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.read(true).write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path)
}
pub(super) fn write_private_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
file.write_all(bytes)?;
Ok(())
}
/// The metadata beside a stream: `<stream>.meta.json`.
///
/// It is written when recording starts, long before the archive exists, so a
@@ -294,7 +324,7 @@ impl McprWriter {
metadata.duration = self.last_timestamp_ms;
let json = serde_json::to_vec_pretty(&metadata)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(&self.temp_meta_path, json)
write_private_file(&self.temp_meta_path, &json)
}
/// Complete recording, write `metaData.json`, package into `.mcpr` ZIP,
@@ -362,12 +392,26 @@ fn package_stream(metadata: &Metadata, stream: &Path, bytes: u64, part: &Path) -
// Zip64, so a recording past 4 GiB still finishes.
zip.start_file("recording.tmcpr", options.large_file(true))?;
let mut input = BufReader::new(File::open(stream)?).take(bytes);
if io::copy(&mut input, &mut zip)? != bytes {
let mut checksum = crc32fast::Hasher::new();
let mut copied = 0u64;
let mut buffer = [0; 64 * 1024];
loop {
let count = input.read(&mut buffer)?;
if count == 0 {
break;
}
checksum.update(&buffer[..count]);
zip.write_all(&buffer[..count])?;
copied += count as u64;
}
if copied != bytes {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"the stream file is shorter than what was written",
));
}
zip.start_file("recording.tmcpr.crc32", options)?;
write!(zip, "{:08x}", checksum.finalize())?;
let file = zip.finish().map_err(io::Error::other)?;
file.sync_all()?;
@@ -402,33 +446,112 @@ fn frame_head(data: &[u8], offset: usize) -> Option<(u32, usize, usize)> {
Some((timestamp_ms, length, id_bytes.len() - reader.rest().len()))
}
/// The frame at `offset`: `(timestamp_ms, id, payload range, next offset)`, or `None` when it
/// isn't whole (a short header, a length past the end, or an id that doesn't
/// fit in its length). The index and `next_packet` both read frames through
/// this, so they agree on where a damaged stream ends.
fn frame_at(data: &[u8], offset: usize) -> Option<(u32, i32, std::ops::Range<usize>, usize)> {
let (timestamp_ms, length, _) = frame_head(data, offset)?;
let start = offset + 8;
let end = start.checked_add(length)?;
let body = data.get(start..end)?;
let mut reader = Reader::new(body);
let id = reader.varint().ok()?;
let payload_start = end - reader.rest().len();
Some((timestamp_ms, id, payload_start..end, end))
/// Byte positions for one complete packet frame in the temporary stream.
struct FrameLocation {
timestamp_ms: u32,
id: i32,
payload_start: u64,
payload_end: u64,
next: u64,
}
/// Read one whole frame from a seekable stream. A partial header/body/ID ends
/// the readable stream, matching crash recovery's handling of truncated data.
fn frame_at<R: Read + Seek>(
stream: &mut R,
offset: u64,
end: u64,
) -> io::Result<Option<FrameLocation>> {
if end.saturating_sub(offset) < 8 {
return Ok(None);
}
stream.seek(SeekFrom::Start(offset))?;
let mut header = [0; 8];
stream.read_exact(&mut header)?;
let timestamp_ms = u32::from_be_bytes(header[..4].try_into().unwrap());
let length = u32::from_be_bytes(header[4..].try_into().unwrap()) as u64;
let body_start = offset + 8;
let Some(next) = body_start.checked_add(length) else {
return Ok(None);
};
if length == 0 || next > end {
return Ok(None);
}
let id_bytes_len = length.min(5) as usize;
let mut id_bytes = [0; 5];
stream.read_exact(&mut id_bytes[..id_bytes_len])?;
let mut packet = Reader::new(&id_bytes[..id_bytes_len]);
let Ok(id) = packet.varint() else {
return Ok(None);
};
let payload_start = body_start + (id_bytes_len - packet.rest().len()) as u64;
stream.seek(SeekFrom::Start(next))?;
Ok(Some(FrameLocation {
timestamp_ms,
id,
payload_start,
payload_end: next,
next,
}))
}
struct SparseIndexEntry {
timestamp_ms: u32,
frame_offset: u64,
group_start: u64,
}
/// Disk-backed inflated stream removed when the reader closes.
struct TemporaryStream {
path: PathBuf,
reader: BufReader<File>,
}
impl TemporaryStream {
fn create() -> io::Result<Self> {
let mut last_error = None;
for attempt in 0..16 {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let path = std::env::temp_dir().join(format!(
"horizon-mcpr-{}-{nanos}-{attempt}.tmcpr",
std::process::id()
));
match create_private_file(&path) {
Ok(file) => {
return Ok(Self {
path,
reader: BufReader::with_capacity(64 * 1024, file),
});
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
last_error = Some(error);
}
Err(error) => return Err(error),
}
}
Err(last_error.unwrap_or_else(|| io::Error::other("could not create stream temp file")))
}
}
impl Drop for TemporaryStream {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
/// Reader for opening and streaming `.mcpr` replay files.
pub struct McprReader {
pub metadata: Metadata,
tmcpr_data: Vec<u8>,
stream: TemporaryStream,
/// Where `next_packet` reads next.
position: usize,
position: u64,
/// The end of the last whole frame: the stream stops here.
end: usize,
/// `(timestamp_ms, byte_offset)` of each whole frame. Timestamps never go
/// backwards here: one earlier than the frame before it is indexed at that
/// frame's time, so seeking stays defined on a damaged file.
packet_index: Vec<(u32, usize)>,
end: u64,
/// Sparse frame index; timestamps are normalized to never go backwards.
packet_index: Vec<SparseIndexEntry>,
total_packets: usize,
}
fn read_entry(archive: &mut zip::ZipArchive<File>, name: &str, max: u64) -> io::Result<Vec<u8>> {
@@ -466,31 +589,87 @@ impl McprReader {
));
}
let tmcpr_data = read_entry(&mut archive, "recording.tmcpr", MAX_STREAM_BYTES)?;
let mut stream_entry = archive
.by_name("recording.tmcpr")
.map_err(|e| io::Error::new(io::ErrorKind::NotFound, e))?;
if stream_entry.size() > MAX_STREAM_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("recording.tmcpr is larger than {MAX_STREAM_BYTES} bytes"),
));
}
let mut stream = TemporaryStream::create()?;
let mut stream_size = 0u64;
let mut stream_crc = crc32fast::Hasher::new();
let mut buffer = [0; 64 * 1024];
loop {
let count = stream_entry.read(&mut buffer)?;
if count == 0 {
break;
}
stream_size = stream_size.checked_add(count as u64).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "recording.tmcpr is too large")
})?;
if stream_size > MAX_STREAM_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("recording.tmcpr is larger than {MAX_STREAM_BYTES} bytes"),
));
}
stream_crc.update(&buffer[..count]);
stream.reader.get_mut().write_all(&buffer[..count])?;
}
let actual_crc = stream_crc.finalize();
drop(stream_entry);
if let Ok(crc_entry) = archive.by_name("recording.tmcpr.crc32") {
let mut expected = String::new();
crc_entry.take(64).read_to_string(&mut expected)?;
let expected = expected.trim();
let actual = format!("{actual_crc:08x}");
if expected.len() != 8 || !expected.eq_ignore_ascii_case(&actual) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"recording.tmcpr CRC32 mismatch",
));
}
}
// A recording that stopped mid-write ends in a frame that isn't whole:
// indexing and playback both stop before it.
let mut packet_index = Vec::new();
let mut offset = 0;
let mut offset = 0u64;
let mut latest = 0;
while let Some((timestamp_ms, _, _, next)) = frame_at(&tmcpr_data, offset) {
latest = latest.max(timestamp_ms);
packet_index.push((latest, offset));
offset = next;
let mut group_start = 0u64;
let mut total_packets = 0usize;
while let Some(frame) = frame_at(&mut stream.reader, offset, stream_size)? {
if total_packets == 0 || frame.timestamp_ms > latest {
latest = frame.timestamp_ms;
group_start = offset;
}
if total_packets.is_multiple_of(STREAM_INDEX_STRIDE) {
packet_index.push(SparseIndexEntry {
timestamp_ms: latest,
frame_offset: offset,
group_start,
});
}
total_packets += 1;
offset = frame.next;
}
stream.reader.seek(SeekFrom::Start(0))?;
Ok(Self {
metadata,
tmcpr_data,
stream,
position: 0,
end: offset,
packet_index,
total_packets,
})
}
/// Total number of packets indexed in the recording.
pub fn total_packets(&self) -> usize {
self.packet_index.len()
self.total_packets
}
/// Read the next timestamped packet from the current position, or `None`
@@ -499,15 +678,23 @@ impl McprReader {
if self.position >= self.end {
return Ok(None);
}
let Some((timestamp_ms, id, payload, next)) = frame_at(&self.tmcpr_data, self.position)
else {
let Some(frame) = frame_at(&mut self.stream.reader, self.position, self.end)? else {
return Ok(None);
};
self.position = next;
let payload_len =
usize::try_from(frame.payload_end - frame.payload_start).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidData, "packet payload is too large")
})?;
let mut payload = vec![0; payload_len];
self.stream
.reader
.seek(SeekFrom::Start(frame.payload_start))?;
self.stream.reader.read_exact(&mut payload)?;
self.position = frame.next;
Ok(Some(TimestampedPacket {
timestamp_ms,
id,
payload: self.tmcpr_data[payload].to_vec(),
timestamp_ms: frame.timestamp_ms,
id: frame.id,
payload,
}))
}
@@ -518,24 +705,40 @@ impl McprReader {
/// Starting at the first packet of a millisecond keeps the packets that
/// arrived together, such as a chunk and the entities in it, together.
pub fn seek_to_timestamp(&mut self, target_timestamp_ms: u32) -> io::Result<Option<u32>> {
let Some(first) = self.packet_index.first() else {
return Ok(None);
};
let after = self
.packet_index
.partition_point(|&(ts, _)| ts <= target_timestamp_ms);
let chosen = match after.checked_sub(1) {
Some(last) => self.packet_index[last].0,
None => match self.packet_index.first() {
Some(&(ts, _)) => ts,
None => return Ok(None),
},
};
let idx = self.packet_index.partition_point(|&(ts, _)| ts < chosen);
self.position = self.packet_index[idx].1;
Ok(Some(chosen))
.partition_point(|entry| entry.timestamp_ms <= target_timestamp_ms);
let anchor = &self.packet_index[after.saturating_sub(1)];
let mut chosen_timestamp = anchor.timestamp_ms;
let mut chosen_position = anchor.group_start;
let mut latest = anchor.timestamp_ms;
let mut offset = anchor.frame_offset;
while let Some(frame) = frame_at(&mut self.stream.reader, offset, self.end)? {
latest = latest.max(frame.timestamp_ms);
if latest > target_timestamp_ms {
break;
}
if latest > chosen_timestamp {
chosen_timestamp = latest;
chosen_position = offset;
}
offset = frame.next;
}
if after == 0 {
chosen_timestamp = first.timestamp_ms;
chosen_position = first.group_start;
}
self.position = chosen_position;
Ok(Some(chosen_timestamp))
}
/// Reset stream reading to the beginning.
pub fn rewind(&mut self) -> io::Result<()> {
self.position = 0;
self.stream.reader.seek(SeekFrom::Start(0))?;
Ok(())
}
}
@@ -933,6 +1136,51 @@ mod tests {
}
}
/// Large timestamp groups remain seekable even when sparse anchors land
/// inside them, while index memory grows with stream size rather than every
/// packet.
#[test]
fn sparse_index_seeks_to_timestamp_group_starts() {
let scratch = Scratch::new("sparse-index");
let mut tmcpr = Vec::new();
for packet in 0..3072u32 {
let timestamp = u32::from(packet >= 1500) * 10;
frame(&mut tmcpr, timestamp, 2, &[0x01, packet as u8]);
}
let mut reader = open_stream(&scratch, &tmcpr);
let temp_path = reader.stream.path.clone();
assert_eq!(reader.total_packets(), 3072);
assert_eq!(reader.packet_index.len(), 3);
assert_eq!(reader.seek_to_timestamp(0).unwrap(), Some(0));
assert_eq!(reader.next_packet().unwrap().unwrap().payload, vec![0x00]);
assert_eq!(reader.seek_to_timestamp(10).unwrap(), Some(10));
assert_eq!(reader.next_packet().unwrap().unwrap().payload, vec![220]);
drop(reader);
assert!(!temp_path.exists(), "temporary stream is removed on close");
}
#[test]
fn streaming_reader_checks_the_mcpr_stream_crc() {
let scratch = Scratch::new("crc");
let path = scratch.0.join("bad-crc.mcpr");
let mut zip = zip::ZipWriter::new(File::create(&path).unwrap());
let options = zip::write::SimpleFileOptions::default();
zip.start_file("metaData.json", options).unwrap();
zip.write_all(&serde_json::to_vec(&Metadata::default()).unwrap())
.unwrap();
zip.start_file("recording.tmcpr", options).unwrap();
zip.write_all(&[1, 2, 3]).unwrap();
zip.start_file("recording.tmcpr.crc32", options).unwrap();
zip.write_all(b"00000000").unwrap();
zip.finish().unwrap();
let error = McprReader::open(&path).err().unwrap();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("CRC32 mismatch"));
}
/// A timestamp that goes backwards is played as stored but indexed at the
/// time before it, so seeking still finds the right place.
#[test]
+2
View File
@@ -4,8 +4,10 @@
pub mod mcpr;
pub mod recorder;
pub mod replay;
pub use mcpr::{
McprReader, McprWriter, Metadata, RecoveryReport, TimestampedPacket, recover_crashed_recordings,
};
pub use recorder::{RecorderStatus, StudioRecorder, generate_recording_filename};
pub use replay::ReplayDriver;
+433
View File
@@ -0,0 +1,433 @@
//! Studio Replay Playback Driver
//!
//! Feeds recorded `.mcpr` packet streams into the client's packet processing
//! loop at user-controlled playback speeds (slow-motion, 1x, fast-forward, pause).
use super::mcpr::{McprReader, Metadata, TimestampedPacket};
use std::{io, path::Path, time::Duration};
/// Playback controller driving recorded packets according to a virtual timeline.
pub struct ReplayDriver {
pub metadata: Metadata,
reader: McprReader,
cursor_ms: f64,
speed: f64,
paused: bool,
looping: bool,
finished: bool,
next_packet_cache: Option<TimestampedPacket>,
}
impl ReplayDriver {
/// Open an `.mcpr` recording archive for replay playback.
pub fn open(path: &Path) -> io::Result<Self> {
let mut reader = McprReader::open(path)?;
validate_metadata(&reader.metadata)?;
let metadata = reader.metadata.clone();
let next_packet_cache = reader.next_packet()?;
let first = next_packet_cache
.as_ref()
.ok_or_else(|| invalid_replay("recording contains no play-state packets"))?;
validate_play_stream_start(first)?;
Ok(Self {
metadata,
reader,
cursor_ms: 0.0,
speed: 1.0,
paused: false,
looping: false,
finished: false,
next_packet_cache,
})
}
/// Current timeline position in milliseconds.
pub fn position_ms(&self) -> u32 {
self.cursor_ms as u32
}
/// Total recording duration in milliseconds.
pub fn duration_ms(&self) -> u32 {
self.metadata.duration
}
/// Total number of indexed packets in the recording stream.
pub fn packet_count(&self) -> usize {
self.reader.total_packets()
}
/// Current playback progress as a fraction from 0.0 to 1.0.
pub fn progress(&self) -> f32 {
if self.metadata.duration == 0 {
0.0
} else {
(self.cursor_ms / self.metadata.duration as f64).clamp(0.0, 1.0) as f32
}
}
/// Current playback speed multiplier (e.g. 0.25, 1.0, 2.0).
pub fn speed(&self) -> f64 {
self.speed
}
/// Set playback speed multiplier. Clamped between 0.01x and 20.0x.
pub fn set_speed(&mut self, speed: f64) {
self.speed = speed.clamp(0.01, 20.0);
}
/// Check if playback is paused.
pub fn is_paused(&self) -> bool {
self.paused
}
/// Set paused state.
pub fn set_paused(&mut self, paused: bool) {
self.paused = paused;
}
/// Toggle paused state.
pub fn toggle_pause(&mut self) {
self.paused = !self.paused;
}
/// Set whether replay should loop when reaching the end.
pub fn set_looping(&mut self, looping: bool) {
self.looping = looping;
}
/// Check if playback reached the end of the recording.
pub fn is_finished(&self) -> bool {
self.finished
}
/// Advance virtual time by `delta` and collect all packets scheduled up to the new time.
pub fn advance(&mut self, delta: Duration) -> io::Result<Vec<TimestampedPacket>> {
if self.paused || self.finished {
return Ok(Vec::new());
}
let delta_ms = delta.as_secs_f64() * 1000.0 * self.speed;
self.cursor_ms += delta_ms;
let mut ready = Vec::new();
let target_ms = self.cursor_ms as u32;
loop {
let Some(pkt) = &self.next_packet_cache else {
if self.cursor_ms >= self.metadata.duration as f64 {
if self.looping {
self.seek(0)?;
} else {
self.finished = true;
}
}
break;
};
if pkt.timestamp_ms <= target_ms {
ready.push(self.next_packet_cache.take().unwrap());
self.next_packet_cache = self.reader.next_packet()?;
} else {
break;
}
}
Ok(ready)
}
/// Seek directly to a specific timestamp in milliseconds.
pub fn seek(&mut self, target_ms: u32) -> io::Result<()> {
let clamped_ms = target_ms.min(self.metadata.duration);
self.cursor_ms = clamped_ms as f64;
self.finished = false;
self.reader.seek_to_timestamp(clamped_ms)?;
self.next_packet_cache = self.reader.next_packet()?;
Ok(())
}
/// Seek from the beginning and return all packets needed to reconstruct the
/// replay state at `target_ms`. The first packet after the target remains
/// cached for normal playback.
pub fn seek_and_collect(&mut self, target_ms: u32) -> io::Result<Vec<TimestampedPacket>> {
let target_ms = target_ms.min(self.metadata.duration);
self.reader.seek_to_timestamp(0)?;
self.next_packet_cache = self.reader.next_packet()?;
let mut packets = Vec::new();
while self
.next_packet_cache
.as_ref()
.is_some_and(|packet| packet.timestamp_ms <= target_ms)
{
packets.push(self.next_packet_cache.take().expect("checked above"));
self.next_packet_cache = self.reader.next_packet()?;
}
self.cursor_ms = target_ms as f64;
self.finished = false;
Ok(packets)
}
/// Move forward from the current cursor and return the newly crossed packets.
pub fn seek_forward(&mut self, target_ms: u32) -> io::Result<Vec<TimestampedPacket>> {
let target_ms = target_ms.min(self.metadata.duration);
if (target_ms as f64) < self.cursor_ms {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"cannot seek backwards without rebuilding replay state",
));
}
let mut packets = Vec::new();
while self
.next_packet_cache
.as_ref()
.is_some_and(|packet| packet.timestamp_ms <= target_ms)
{
packets.push(self.next_packet_cache.take().expect("checked above"));
self.next_packet_cache = self.reader.next_packet()?;
}
self.cursor_ms = target_ms as f64;
self.finished = false;
Ok(packets)
}
/// Rewind playback to the beginning.
pub fn restart(&mut self) -> io::Result<()> {
self.seek(0)
}
}
fn invalid_replay(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
fn validate_metadata(metadata: &Metadata) -> io::Result<()> {
if metadata.file_format != "MCPR" {
return Err(invalid_replay(format!(
"unsupported replay file format {:?}",
metadata.file_format
)));
}
if metadata.file_format_version != 14 {
return Err(invalid_replay(format!(
"unsupported MCPR format version {}; this build supports 14",
metadata.file_format_version
)));
}
match metadata.protocol {
Some(protocol) if protocol == horizon_protocol::PROTOCOL_VERSION => {}
Some(protocol) => {
return Err(invalid_replay(format!(
"unsupported Minecraft protocol {protocol}; this build supports {}",
horizon_protocol::PROTOCOL_VERSION
)));
}
None => {
return Err(invalid_replay(
"replay does not declare a Minecraft protocol version",
));
}
}
Ok(())
}
/// Playback uses the Play packet table, so refuse streams that do not start
/// with the Play login snapshot before interpreting later packet IDs.
fn validate_play_stream_start(first: &TimestampedPacket) -> io::Result<()> {
let packet = horizon_protocol::packets::play::Clientbound::decode(first.id, &first.payload)
.map_err(|error| invalid_replay(format!("invalid initial play packet: {error}")))?;
if !matches!(
packet,
horizon_protocol::packets::play::Clientbound::Login(_)
) {
return Err(invalid_replay(
"recording does not start with the Play Login packet",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::studio::mcpr::McprWriter;
use std::fs;
fn play_login_packet() -> (i32, Vec<u8>) {
use horizon_protocol::{buf::Reader, packets::play};
let encoded = play::Clientbound::Login(play::Login {
player_id: 1,
hardcore: false,
levels: vec!["minecraft:overworld".into()],
max_players: 20,
chunk_radius: 8,
simulation_distance: 8,
reduced_debug_info: false,
show_death_screen: true,
limited_crafting: false,
spawn: play::SpawnInfo {
dimension_type: 0,
dimension: "minecraft:overworld".into(),
seed: 0,
game_type: 0,
previous_game_type: None,
debug: false,
flat: false,
last_death: None,
portal_cooldown: 0,
sea_level: 63,
},
online_mode: false,
enforces_secure_chat: false,
})
.encode();
let mut reader = Reader::new(&encoded);
let id = reader.varint().unwrap();
(id, reader.rest().to_vec())
}
#[test]
fn playback_refuses_unknown_format_or_protocol_metadata() {
let mut metadata = Metadata::default();
assert!(validate_metadata(&metadata).is_ok());
metadata.file_format = "OTHER".into();
assert_eq!(
validate_metadata(&metadata).unwrap_err().kind(),
io::ErrorKind::InvalidData
);
metadata = Metadata::default();
metadata.file_format_version = 13;
assert_eq!(
validate_metadata(&metadata).unwrap_err().kind(),
io::ErrorKind::InvalidData
);
metadata = Metadata::default();
metadata.protocol = None;
assert_eq!(
validate_metadata(&metadata).unwrap_err().kind(),
io::ErrorKind::InvalidData
);
metadata.protocol = Some(horizon_protocol::PROTOCOL_VERSION + 1);
assert_eq!(
validate_metadata(&metadata).unwrap_err().kind(),
io::ErrorKind::InvalidData
);
}
#[test]
fn playback_requires_a_play_login_phase_start() {
let (id, payload) = play_login_packet();
assert!(validate_play_stream_start(&TimestampedPacket::new(0, id, payload)).is_ok());
use horizon_protocol::packets::play;
let encoded =
play::Clientbound::SetChunkCacheCenter(play::SetChunkCacheCenter { x: 0, z: 0 })
.encode();
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
let id = reader.varint().unwrap();
assert_eq!(
validate_play_stream_start(&TimestampedPacket::new(0, id, reader.rest().to_vec()))
.unwrap_err()
.kind(),
io::ErrorKind::InvalidData
);
}
#[test]
fn test_replay_driver_advance_and_speed() {
let temp_dir =
std::env::temp_dir().join(format!("replay_driver_test_{}", std::process::id()));
let _ = fs::create_dir_all(&temp_dir);
let mcpr_path = temp_dir.join("driver_test.mcpr");
let meta = Metadata {
duration: 1000,
..Default::default()
};
let mut writer = McprWriter::new(mcpr_path.clone(), meta).unwrap();
let (login_id, login_payload) = play_login_packet();
writer.write_packet(0, login_id, &login_payload).unwrap();
writer.write_packet(0, 0x01, &[1]).unwrap();
writer.write_packet(100, 0x02, &[2]).unwrap();
writer.write_packet(200, 0x03, &[3]).unwrap();
writer.write_packet(500, 0x04, &[4]).unwrap();
writer.finish(1000).unwrap();
let mut driver = ReplayDriver::open(&mcpr_path).unwrap();
assert_eq!(driver.position_ms(), 0);
assert_eq!(driver.duration_ms(), 1000);
// Advance 50ms at 1.0x -> receives packet at 0ms
let batch1 = driver.advance(Duration::from_millis(50)).unwrap();
assert_eq!(batch1.len(), 2);
assert_eq!(batch1[0].id, login_id);
assert_eq!(batch1[1].id, 0x01);
// Advance another 100ms (now 150ms) -> receives packet at 100ms
let batch2 = driver.advance(Duration::from_millis(100)).unwrap();
assert_eq!(batch2.len(), 1);
assert_eq!(batch2[0].id, 0x02);
// Paused -> advance produces nothing
driver.set_paused(true);
let batch_paused = driver.advance(Duration::from_millis(200)).unwrap();
assert!(batch_paused.is_empty());
driver.set_paused(false);
// Speed 2.0x -> advance 100ms real time advances 200ms virtual time (150ms -> 350ms)
driver.set_speed(2.0);
let batch3 = driver.advance(Duration::from_millis(100)).unwrap();
assert_eq!(batch3.len(), 1);
assert_eq!(batch3[0].id, 0x03);
// Seek back to 0
driver.seek(0).unwrap();
assert_eq!(driver.position_ms(), 0);
let _ = fs::remove_file(mcpr_path);
let _ = fs::remove_dir(temp_dir);
}
#[test]
fn seek_reconstructs_packets_and_forward_seek_skips_consumed_packets() {
let temp_dir =
std::env::temp_dir().join(format!("replay_driver_seek_test_{}", std::process::id()));
let _ = fs::create_dir_all(&temp_dir);
let mcpr_path = temp_dir.join("seek_test.mcpr");
let meta = Metadata {
duration: 1000,
..Default::default()
};
let mut writer = McprWriter::new(mcpr_path.clone(), meta).unwrap();
let (login_id, login_payload) = play_login_packet();
writer.write_packet(0, login_id, &login_payload).unwrap();
for (timestamp, id) in [(0, 1), (100, 2), (200, 3), (500, 4)] {
writer.write_packet(timestamp, id, &[id as u8]).unwrap();
}
writer.finish(1000).unwrap();
let mut driver = ReplayDriver::open(&mcpr_path).unwrap();
let at_200 = driver.seek_and_collect(200).unwrap();
assert_eq!(
at_200.iter().map(|packet| packet.id).collect::<Vec<_>>(),
[login_id, 1, 2, 3]
);
assert_eq!(driver.position_ms(), 200);
let forward = driver.seek_forward(500).unwrap();
assert_eq!(
forward.iter().map(|packet| packet.id).collect::<Vec<_>>(),
[4]
);
assert_eq!(driver.position_ms(), 500);
let _ = fs::remove_file(mcpr_path);
let _ = fs::remove_dir(temp_dir);
}
}
+8
View File
@@ -284,6 +284,14 @@ impl VanillaTerrain {
"Horizon: {} placed features cannot run and will be skipped",
unsupported.len()
);
// Name them, so a pack's author can find what to fix; capped, so a broken pack can't flood the log.
const NAMED: usize = 20;
for (name, reason) in unsupported.iter().take(NAMED) {
log::warn!("Horizon: {name}: {reason}");
}
if unsupported.len() > NAMED {
log::warn!("Horizon: and {} more", unsupported.len() - NAMED);
}
if !dir.join("blocks/block-properties.nbt").exists() {
log::warn!(
"Horizon: the worldgen bundle lacks the block behavior exports (worldgen/blocks/*.nbt); \
@@ -6,6 +6,33 @@ use horizon_client::mod_api::{
struct Keys;
#[test]
fn a_mod_observes_the_applied_chat_suggestion() {
use horizon_client::mod_api::{ChatSuggestionAppliedEvent, dispatch_chat_suggestion_applied};
use std::sync::{Arc, Mutex};
struct TestHost;
impl Host for TestHost {}
struct Listener(Arc<Mutex<Vec<String>>>);
impl Hooks for Listener {
fn chat_suggestion_applied(
&self,
_host: &dyn Host,
event: &mut ChatSuggestionAppliedEvent<'_>,
) {
assert_eq!(event.original, "😀 hi st");
assert_eq!((event.start, event.end), (6, 8));
assert_eq!(event.suggestion, "Steve");
assert_eq!(event.input, "😀 hi Steve");
self.0.lock().unwrap().push(event.input.to_owned());
}
}
let calls = Arc::new(Mutex::new(Vec::new()));
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Listener(calls.clone()))];
dispatch_chat_suggestion_applied(&TestHost, &mods, "😀 hi st", 6, 8, "Steve", "😀 hi Steve");
assert_eq!(*calls.lock().unwrap(), ["😀 hi Steve"]);
}
impl Hooks for Keys {
fn key(&self, _host: &mut dyn Host, event: &mut KeyEvent<'_>) {
let _ = (event.key, event.pressed);
@@ -673,3 +700,37 @@ fn a_screen_background_hook_can_cancel_vanilla_gradient() {
"inventory cancels vanilla gradient for blur/custom draw"
);
}
#[test]
fn wind_burst_filter_preserves_order_and_context() {
use horizon_client::mod_api::{WindBurstEvent, dispatch_wind_burst};
struct TestHost;
impl Host for TestHost {}
struct Claims;
impl Hooks for Claims {
fn wind_burst(&self, _host: &mut dyn Host, event: &mut WindBurstEvent<'_>) {
assert_eq!(event.dimension, "minecraft:the_nether");
assert_eq!(event.center, [1.5, 70.0, -2.5]);
event.blocks.retain(|p| p[0] != 1);
}
}
let original = vec![[3, 70, -2], [1, 70, -2], [2, 70, -2]];
let mut blocks = original.clone();
dispatch_wind_burst(
&mut TestHost,
&[],
"minecraft:the_nether",
[1.5, 70.0, -2.5],
&mut blocks,
);
assert_eq!(blocks, original);
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Claims)];
dispatch_wind_burst(
&mut TestHost,
&mods,
"minecraft:the_nether",
[1.5, 70.0, -2.5],
&mut blocks,
);
assert_eq!(blocks, vec![[3, 70, -2], [2, 70, -2]]);
}
+122
View File
@@ -57,6 +57,41 @@ impl std::fmt::Display for StorageError {
impl std::error::Error for StorageError {}
/// One entry's word and bit shift, so a read and the write that follows it
/// compute the position once.
#[derive(Clone, Copy, Debug)]
pub struct Slot {
word: usize,
shift: u32,
}
/// A run of entries `stride` apart that all keep the same bit shift, which is
/// the case for its own stride when a word divides it. A vertical fill walks
/// [`write_back`](Self::write_back) instead of locating every block again.
pub struct WordRun<'a> {
words: &'a mut [u64],
/// The word of the next entry down, and the words between entries.
word: usize,
step: usize,
shift: u32,
/// The bits of a word outside the entry, and the value pre-shifted into it.
keep: u64,
value: u64,
}
impl WordRun<'_> {
/// Writes the run's value at the next entry down and returns the entry it
/// replaced. A word that already holds the value ends up unchanged.
#[inline]
pub fn write_back(&mut self) -> u32 {
self.word -= self.step;
let cell = &mut self.words[self.word];
let old = ((*cell & !self.keep) >> self.shift) as u32;
*cell = (*cell & self.keep) | self.value;
old
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PackedStorage {
bits: u8,
@@ -169,6 +204,56 @@ impl PackedStorage {
old
}
/// The word and bit shift of an entry: [`read_at`](Self::read_at) and
/// [`write_at`](Self::write_at) take it in place of an index, so a read
/// followed by a write computes the position once. `index` must be less
/// than `len`.
#[inline]
pub fn slot_of(&self, index: usize) -> Slot {
debug_assert!(index < self.len);
let (word, shift) = self.slot(index);
Slot { word, shift }
}
/// Whether a run of entries `stride` apart keeps one bit shift: true when a
/// word holds a whole number of strides, which the widths that divide 64
/// bits per entry (`per_word` 64, 32, 16, 8, 4 and 2) do for a stride of
/// 256 entries. Other widths move the shift along the run.
#[inline]
pub fn fixed_shift(&self, stride: usize) -> bool {
stride.is_multiple_of(self.per_word)
}
/// The run of entries `stride` apart starting **one stride before**
/// `index`, which must keep one bit shift ([`fixed_shift`](Self::fixed_shift))
/// and hold `value`. Its calls write `index - stride`, then `index - 2 *
/// stride` and so on.
pub fn word_run(&mut self, index: usize, stride: usize, value: u32) -> WordRun<'_> {
debug_assert!(self.fixed_shift(stride) && u64::from(value) <= self.mask);
let (word, shift) = self.slot(index);
let mask = self.mask;
WordRun {
words: &mut self.words,
word,
step: stride / self.per_word,
shift,
keep: !(mask << shift),
value: u64::from(value) << shift,
}
}
/// [`read`](Self::read) at a slot `slot_of` returned for the same index.
#[inline]
pub fn read_at(&self, slot: Slot) -> u32 {
((self.words[slot.word] >> slot.shift) & self.mask) as u32
}
/// [`write`](Self::write) at a slot `slot_of` returned for the same index.
#[inline]
pub fn write_at(&mut self, slot: Slot, value: u32) -> u32 {
debug_assert!(u64::from(value) <= self.mask);
let word = &mut self.words[slot.word];
let old = ((*word >> slot.shift) & self.mask) as u32;
*word = (*word & !(self.mask << slot.shift)) | (u64::from(value) << slot.shift);
old
}
/// Validation fails before mutation. Writes accept nonnegative Java ints;
/// imported 32-bit words may still decode to any u32 bit pattern.
pub fn set(&mut self, index: usize, value: u32) -> Result<u32, StorageError> {
@@ -230,6 +315,43 @@ mod tests {
assert_eq!(storage.words(), &[31 << 55, 7]);
}
/// A slot read and written at the position `slot_of` gave holds what
/// `read`/`write` hold, and a word run steps exactly where `write` would.
#[test]
fn slots_and_word_runs_write_where_index_writes() {
for bits in 1..=32u8 {
let mut indexed = PackedStorage::new(bits, 4096).unwrap();
let mut slotted = indexed.clone();
let mask = (1u64 << bits) - 1;
let mut rng = 0x9e37_79b9_7f4a_7c15u64 ^ u64::from(bits);
let mut next = || {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
rng
};
for _ in 0..500 {
let index = (next() % 4096) as usize;
let value = ((next() >> 20) & mask) as u32;
let slot = slotted.slot_of(index);
assert_eq!(slotted.read_at(slot), slotted.read(index));
assert_eq!(slotted.write_at(slot, value), indexed.write(index, value));
}
assert_eq!(indexed, slotted);
if !slotted.fixed_shift(256) {
continue;
}
// The top entry of a column at x = z = 15, then down it.
let top = 0xfff;
let value = ((next() >> 20) & mask) as u32;
let mut run = slotted.word_run(top, 256, value);
for step in 1..16 {
assert_eq!(run.write_back(), indexed.write(top - step * 256, value));
}
assert_eq!(indexed, slotted);
}
}
#[test]
fn all_widths_survive_overwrites_and_unpack() {
for bits in 1..=32 {
+25 -1
View File
@@ -111,6 +111,20 @@ pub fn resolve_relay(url: &str) -> io::Result<Vec<SocketAddr>> {
Ok(found)
}
/// Configure TCP keepalive probes so dead peers (e.g. lost Wi-Fi, sleep, or silent NAT
/// teardown) are detected by the OS instead of holding threads indefinitely.
pub fn set_tcp_keepalive(stream: &TcpStream) -> io::Result<()> {
let sock = socket2::SockRef::from(stream);
let mut keepalive = socket2::TcpKeepalive::new()
.with_time(Duration::from_secs(10))
.with_interval(Duration::from_secs(3));
#[cfg(not(any(target_os = "windows", target_os = "openbsd")))]
{
keepalive = keepalive.with_retries(3);
}
sock.set_tcp_keepalive(&keepalive)
}
/// Carry one connection both ways between a plain stream (the game's) and a
/// protected one, until either side ends; then both are closed.
pub fn pump(
@@ -122,13 +136,14 @@ pub fn pump(
for s in [&plain, reader.get_ref(), writer.get_ref()] {
s.set_read_timeout(None)?;
let _ = s.set_nodelay(true);
let _ = set_tcp_keepalive(s);
}
let mut plain_in = plain.try_clone()?;
let mut plain_out = plain;
let outbound = std::thread::Builder::new()
.name("horizon-friends-out".into())
.spawn(move || {
let mut buf = vec![0u8; crypto::MAX_PAYLOAD];
let mut buf = [0u8; crypto::MAX_PAYLOAD];
loop {
match plain_in.read(&mut buf) {
Ok(0) | Err(_) => break,
@@ -227,4 +242,13 @@ mod tests {
started.elapsed()
);
}
#[test]
fn tcp_keepalive_configures_stream() {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let client = TcpStream::connect(l.local_addr().unwrap()).unwrap();
let (server, _) = l.accept().unwrap();
assert!(set_tcp_keepalive(&client).is_ok());
assert!(set_tcp_keepalive(&server).is_ok());
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "horizon-polytone"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Polytone-format resource pack files: targets, and the biome and dimension modifiers' environment attribute layers"
[dependencies]
regex = { version = "1", default-features = false, features = ["std", "unicode-perl"] }
serde_json = "1"
[lints]
workspace = true
+237
View File
@@ -0,0 +1,237 @@
//! Environment attribute layers (wiki "Environment Attributes"): a map from attribute id to a
//! value, which sets the attribute, a `{argument, modifier}` object, which modifies the value the
//! earlier layers gave, or `REMOVE`, which drops the game's own layer for it.
use serde_json::{Map, Value};
use std::collections::BTreeMap;
/// The vanilla `modifier` operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Modifier {
Override,
Add,
Subtract,
Multiply,
Minimum,
Maximum,
AlphaBlend,
BlendToGray,
And,
Nand,
Or,
Nor,
Xor,
Xnor,
}
impl Modifier {
/// The `modifier` field, in any case (the wiki writes both `add` and `ADD`).
fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"override" => Self::Override,
"add" => Self::Add,
"subtract" => Self::Subtract,
"multiply" => Self::Multiply,
"minimum" => Self::Minimum,
"maximum" => Self::Maximum,
"alpha_blend" => Self::AlphaBlend,
"blend_to_gray" => Self::BlendToGray,
"and" => Self::And,
"nand" => Self::Nand,
"or" => Self::Or,
"nor" => Self::Nor,
"xor" => Self::Xor,
"xnor" => Self::Xnor,
_ => return None,
})
}
}
/// A layer's value or `argument`.
#[derive(Clone, Debug, PartialEq)]
pub enum Argument {
/// A number: a float, an angle, or a packed color, as the attribute's type reads it.
Number(f64),
Bool(bool),
/// A `#rrggbb` / `#aarrggbb` string (the `#` optional), packed ARGB; opaque without alpha.
Color(u32),
/// A `namespace:path` string: a colormap sampled for the value.
Colormap(String),
/// Any other string: a scripting expression, kept as source.
Expression(String),
/// Vanilla's structured arguments, such as `alpha_blend`'s `{value, alpha}`.
Object(Map<String, Value>),
}
impl Argument {
fn parse(v: &Value) -> Result<Self, String> {
Ok(match v {
Value::Number(n) => Self::Number(n.as_f64().unwrap_or_default()),
Value::Bool(b) => Self::Bool(*b),
Value::Object(o) => Self::Object(o.clone()),
Value::String(s) => {
let s = s.trim();
let hex = s.strip_prefix('#').unwrap_or(s);
let color =
matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit());
if let (true, Ok(packed)) = (color, u32::from_str_radix(hex, 16)) {
Self::Color(if hex.len() == 6 {
0xff00_0000 | packed
} else {
packed
})
} else if s.contains(':') && crate::target::parse_id(s).is_some() {
Self::Colormap(s.to_owned())
} else {
Self::Expression(s.to_owned())
}
}
_ => return Err(format!("{v} is no attribute value")),
})
}
}
/// What one modifier file does to one attribute.
#[derive(Clone, Debug, PartialEq)]
pub enum Layer {
/// `REMOVE`: the game's own layer for the attribute in this group is dropped.
Remove,
/// A value (`Override`) or a `{argument, modifier}` object. `biome_blend` and `time_blend`
/// (default `true`) say whether a computed value is mixed across biome borders and faded
/// over time when it jumps.
Apply {
modifier: Modifier,
argument: Argument,
biome_blend: bool,
time_blend: bool,
},
}
impl Layer {
fn parse(v: &Value) -> Result<Self, String> {
if v.as_str().is_some_and(|s| s.trim() == "REMOVE") {
return Ok(Self::Remove);
}
let object = v.as_object().filter(|o| o.contains_key("modifier"));
let Some(o) = object else {
return Ok(Self::Apply {
modifier: Modifier::Override,
argument: Argument::parse(v)?,
biome_blend: true,
time_blend: true,
});
};
let name = o["modifier"].as_str().ok_or("modifier is not a string")?;
let flag = |key: &str| o.get(key).and_then(Value::as_bool).unwrap_or(true);
Ok(Self::Apply {
modifier: Modifier::parse(name).ok_or_else(|| format!("unknown modifier {name:?}"))?,
argument: Argument::parse(o.get("argument").ok_or("modifier without an argument")?)?,
biome_blend: flag("biome_blend"),
time_blend: flag("time_blend"),
})
}
}
/// `visual/fog_color` and `minecraft:visual/fog_color` are the same attribute.
pub fn normalize_attribute(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
/// One group's layers, by attribute id (with its namespace).
pub type Attributes = BTreeMap<String, Layer>;
pub(crate) fn parse_attributes(v: &Value) -> Result<Attributes, String> {
let map = v.as_object().ok_or("attributes are not an object")?;
map.iter()
.map(|(id, layer)| {
let layer = Layer::parse(layer).map_err(|e| format!("{id}: {e}"))?;
Ok((normalize_attribute(id), layer))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn layer(v: Value) -> Layer {
Layer::parse(&v).unwrap()
}
fn set(argument: Argument) -> Layer {
Layer::Apply {
modifier: Modifier::Override,
argument,
biome_blend: true,
time_blend: true,
}
}
#[test]
fn plain_values_set_the_attribute() {
assert_eq!(layer(json!(192.5)), set(Argument::Number(192.5)));
assert_eq!(layer(json!(false)), set(Argument::Bool(false)));
assert_eq!(layer(json!("REMOVE")), Layer::Remove);
}
#[test]
fn color_strings_pack_to_argb_with_or_without_the_hash() {
assert_eq!(layer(json!("4cb1a8")), set(Argument::Color(0xff4c_b1a8)));
assert_eq!(layer(json!("#332211")), set(Argument::Color(0xff33_2211)));
assert_eq!(layer(json!("#867a7aff")), set(Argument::Color(0x867a_7aff)));
assert_eq!(layer(json!("#00000000")), set(Argument::Color(0)));
}
#[test]
fn ids_name_colormaps_and_other_strings_are_expressions() {
let colormap = Argument::Colormap("my_pack:swamp_sky_color".into());
assert_eq!(layer(json!("my_pack:swamp_sky_color")), set(colormap));
let expression = Argument::Expression("r.rand() + 0.1".into());
assert_eq!(layer(json!("r.rand() + 0.1")), set(expression));
// Not hex, so no color, though six letters long.
let word = Argument::Expression("g.rain".into());
assert_eq!(layer(json!("g.rain")), set(word));
}
#[test]
fn modifier_objects_read_any_case_and_the_blend_flags() {
let l = layer(json!({"argument": "0.2 * g.rain", "modifier": "ADD", "biome_blend": false}));
assert_eq!(
l,
Layer::Apply {
modifier: Modifier::Add,
argument: Argument::Expression("0.2 * g.rain".into()),
biome_blend: false,
time_blend: true,
}
);
let blend =
layer(json!({"argument": {"value": 4.0, "alpha": 0.5}, "modifier": "alpha_blend"}));
assert!(matches!(
blend,
Layer::Apply {
modifier: Modifier::AlphaBlend,
argument: Argument::Object(_),
..
}
));
}
#[test]
fn unknown_modifiers_and_missing_arguments_are_errors() {
assert!(Layer::parse(&json!({"argument": 1, "modifier": "divide"})).is_err());
assert!(Layer::parse(&json!({"modifier": "add"})).is_err());
assert!(Layer::parse(&json!(null)).is_err());
}
#[test]
fn attribute_ids_get_the_minecraft_namespace() {
let a =
parse_attributes(&json!({"visual/fog_end_distance": 64, "mod:visual/x": 1})).unwrap();
let ids: Vec<&str> = a.keys().map(String::as_str).collect();
assert_eq!(ids, ["minecraft:visual/fog_end_distance", "mod:visual/x"]);
}
}
+17
View File
@@ -0,0 +1,17 @@
//! Polytone-format resource pack files (`assets/<namespace>/polytone/...`), the format of the
//! Polytone mod's packs (#11486).
//!
//! Clean room: written from the public wiki (<https://github.com/MehVahdJukaar/polytone/wiki>) and
//! from observing packs, never from the mod's GPL source. This crate only reads the files: no GPU,
//! no gameplay. The game applies what it reads; nothing changes without a Polytone pack.
//!
//! Read so far: [`Targets`] (what a modifier aims at), and the [`BiomeModifier`] and
//! [`DimensionModifier`] environment attribute layers. Expressions stay source text
//! ([`Argument::Expression`]) until the expression language lands.
mod attributes;
mod modifiers;
mod target;
pub use attributes::{Argument, Attributes, Layer, Modifier, normalize_attribute};
pub use modifiers::{BiomeModifier, DimensionModifier};
pub use target::{Target, Targets, implicit_id};
+189
View File
@@ -0,0 +1,189 @@
//! Biome modifiers (`polytone/biome_modifiers/`) and dimension modifiers
//! (`polytone/dimension_modifiers/`): their targets, priority and `attributes_modifiers` groups.
use crate::attributes::{Attributes, parse_attributes};
use crate::target::Targets;
use serde_json::Value;
/// The fields every modifier shares. `None` when the file is switched off (`polytone_ignore`).
fn common(json: &Value, implicit: Option<&str>) -> Result<Option<(Targets, i32)>, String> {
if json.get("polytone_ignore").and_then(Value::as_bool) == Some(true) {
return Ok(None);
}
let targets = Targets::parse(json.get("targets"), implicit)?;
let priority = json.get("priority").and_then(Value::as_i64).unwrap_or(0);
let priority = i32::try_from(priority).map_err(|_| "priority out of range")?;
Ok(Some((targets, priority)))
}
/// `attributes_modifiers` split into its groups. Without any group key, the whole object is the
/// `base` group.
fn groups<const N: usize>(json: &Value, names: [&str; N]) -> Result<[Attributes; N], String> {
let mut out = std::array::from_fn(|_| Attributes::new());
let Some(field) = json.get("attributes_modifiers") else {
return Ok(out);
};
let object = field
.as_object()
.ok_or("attributes_modifiers is not an object")?;
if !object.keys().any(|k| names.contains(&k.as_str())) {
out[0] = parse_attributes(field)?;
return Ok(out);
}
for (key, value) in object {
let i = names
.iter()
.position(|n| n == key)
.ok_or_else(|| format!("{key:?} is no attribute group (expected one of {names:?})"))?;
out[i] = parse_attributes(value).map_err(|e| format!("{key}: {e}"))?;
}
Ok(out)
}
/// A biome modifier's attribute layers: `base` applies in the biome group, before the dimension
/// timeline and the weather; `post_process` after them.
#[derive(Clone, Debug)]
pub struct BiomeModifier {
pub targets: Targets,
/// Higher wins where several modifiers apply.
pub priority: i32,
pub base: Attributes,
pub post_process: Attributes,
}
impl BiomeModifier {
/// `implicit` is the id the file's path names ([`crate::implicit_id`]).
pub fn parse(json: &Value, implicit: Option<&str>) -> Result<Option<Self>, String> {
let Some((targets, priority)) = common(json, implicit)? else {
return Ok(None);
};
let [base, post_process] = groups(json, ["base", "post_process"])?;
Ok(Some(Self {
targets,
priority,
base,
post_process,
}))
}
}
/// A dimension modifier's attribute layers: `base` in the dimension group (first of all), `rain`
/// and `thunder` in the weather groups, `post_process` last.
#[derive(Clone, Debug)]
pub struct DimensionModifier {
pub targets: Targets,
pub priority: i32,
pub base: Attributes,
pub rain: Attributes,
pub thunder: Attributes,
pub post_process: Attributes,
}
impl DimensionModifier {
pub fn parse(json: &Value, implicit: Option<&str>) -> Result<Option<Self>, String> {
let Some((targets, priority)) = common(json, implicit)? else {
return Ok(None);
};
let [base, rain, thunder, post_process] =
groups(json, ["base", "rain", "thunder", "post_process"])?;
Ok(Some(Self {
targets,
priority,
base,
rain,
thunder,
post_process,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attributes::{Argument, Layer, Modifier};
use serde_json::json;
const FOG: &str = "minecraft:visual/fog_color";
#[test]
fn a_flat_biome_modifier_fills_the_base_group() {
let json = json!({"attributes_modifiers": {"visual/fog_color": "#102030"}});
let m = BiomeModifier::parse(&json, Some("minecraft:forest"))
.unwrap()
.unwrap();
assert!(m.targets.matches("minecraft:forest", |_| false));
assert_eq!(m.priority, 0);
assert!(m.post_process.is_empty());
assert!(matches!(
m.base[FOG],
Layer::Apply {
modifier: Modifier::Override,
argument: Argument::Color(0xff10_2030),
..
}
));
}
#[test]
fn a_grouped_biome_modifier_keeps_its_groups_apart() {
let json = json!({
"targets": ["minecraft:swamp", "minecraft:mangrove_swamp"],
"priority": -2,
"attributes_modifiers": {
"base": {"minecraft:visual/sky_color": "my_pack:sky"},
"post_process": {"visual/sunrise_sunset_color": "REMOVE"}
}
});
let m = BiomeModifier::parse(&json, None).unwrap().unwrap();
assert_eq!(m.priority, -2);
assert!(m.targets.matches("minecraft:mangrove_swamp", |_| false));
assert_eq!(m.base.len(), 1);
assert_eq!(
m.post_process["minecraft:visual/sunrise_sunset_color"],
Layer::Remove
);
}
#[test]
fn dimension_modifiers_read_the_weather_groups() {
let json = json!({
"targets": "minecraft:overworld",
"attributes_modifiers": {
"base": {"visual/fog_start_distance": "REMOVE"},
"rain": {"visual/cloud_color": "REMOVE", "visual/fog_color": "#3f3f4c"},
"thunder": {"visual/sky_color": {"argument": 0.5, "modifier": "multiply"}}
}
});
let m = DimensionModifier::parse(&json, None).unwrap().unwrap();
assert_eq!(m.base.len(), 1);
assert_eq!(m.rain.len(), 2);
assert!(matches!(
m.thunder["minecraft:visual/sky_color"],
Layer::Apply {
modifier: Modifier::Multiply,
argument: Argument::Number(0.5),
..
}
));
assert!(m.post_process.is_empty());
// Without groups, a dimension modifier's attributes are its base group too.
let flat = json!({"targets": "minecraft:overworld", "attributes_modifiers": {"visual/sky_color": -1}});
let flat = DimensionModifier::parse(&flat, None).unwrap().unwrap();
assert_eq!(flat.base.len(), 1);
assert!(flat.rain.is_empty());
}
#[test]
fn ignored_files_load_nothing_and_broken_ones_fail() {
let ignored = json!({"polytone_ignore": true, "attributes_modifiers": {"x": "REMOVE"}});
assert!(
BiomeModifier::parse(&ignored, Some("minecraft:forest"))
.unwrap()
.is_none()
);
// `rain` is a dimension group, not a biome one.
let wrong_group = json!({"attributes_modifiers": {"base": {}, "rain": {}}});
assert!(BiomeModifier::parse(&wrong_group, Some("minecraft:forest")).is_err());
let bad_layer = json!({"attributes_modifiers": {"visual/fog_color": null}});
assert!(DimensionModifier::parse(&bad_layer, Some("minecraft:overworld")).is_err());
}
}
+150
View File
@@ -0,0 +1,150 @@
//! Targeting (wiki "Shared Concepts"): a modifier aims at the id its file path names, or at the
//! ids, `#tags` and regular expressions of its `targets` field.
use regex::Regex;
use serde_json::Value;
/// One entry of `targets`.
#[derive(Clone, Debug)]
pub enum Target {
/// A literal id, `minecraft` when the namespace is left out.
Id(String),
/// `#namespace:path`, stored without the `#`.
Tag(String),
/// Anything else: a regular expression the whole id must match.
Pattern(Regex),
}
/// What a modifier applies to. Each entry carries `required` (default `true`): whether a missing
/// target is an error.
#[derive(Clone, Debug, Default)]
pub struct Targets(pub Vec<(Target, bool)>);
/// `minecraft:` for an id without a namespace; `None` when it holds characters ids can't
/// (`a-z 0-9 . _ -`, and `/` in the path).
pub(crate) fn parse_id(s: &str) -> Option<String> {
let (namespace, path) = s.split_once(':').unwrap_or(("minecraft", s));
let valid = |part: &str, slash: bool| {
!part.is_empty()
&& part.chars().all(|c| {
matches!(c, 'a'..='z' | '0'..='9' | '.' | '_' | '-') || (slash && c == '/')
})
};
(valid(namespace, false) && valid(path, true)).then(|| format!("{namespace}:{path}"))
}
/// The id a file's path targets when it has no `targets` field:
/// `assets/byg/polytone/biome_modifiers/redwood_forest.json` is `byg:redwood_forest` for the kind
/// `biome_modifiers`.
pub fn implicit_id(path: &str, kind: &str) -> Option<String> {
let path = path.replace('\\', "/");
let after = &path[path.find("assets/")? + "assets/".len()..];
let (namespace, rest) = after.split_once('/')?;
let name = rest.strip_prefix("polytone/")?.strip_prefix(kind)?;
let name = name.strip_prefix('/')?.strip_suffix(".json")?;
parse_id(&format!("{namespace}:{name}"))
}
impl Target {
/// A string is an id first, then a `#tag`, then a regular expression.
fn parse(s: &str) -> Result<Self, String> {
if let Some(id) = parse_id(s) {
return Ok(Self::Id(id));
}
if let Some(tag) = s.strip_prefix('#').and_then(parse_id) {
return Ok(Self::Tag(tag));
}
Regex::new(&format!("^(?:{s})$"))
.map(Self::Pattern)
.map_err(|e| format!("target {s:?} is no id, tag or pattern: {e}"))
}
}
impl Targets {
/// The `targets` field (a string, an object or a list of them), else the file's implicit id.
pub fn parse(field: Option<&Value>, implicit: Option<&str>) -> Result<Self, String> {
let entry = |v: &Value| match v {
Value::String(s) => Ok((Target::parse(s)?, true)),
Value::Object(o) => {
let id = o
.get("id")
.and_then(Value::as_str)
.ok_or("target without an id")?;
let required = o.get("required").and_then(Value::as_bool).unwrap_or(true);
Ok((Target::parse(id)?, required))
}
_ => Err(format!("target {v} is neither a string nor an object")),
};
match field {
Some(Value::Array(list)) => list.iter().map(entry).collect::<Result<_, _>>().map(Self),
Some(v) => Ok(Self(vec![entry(v)?])),
None => {
let id = implicit.ok_or("no targets field and no implicit target")?;
Ok(Self(vec![(Target::parse(id)?, true)]))
}
}
}
/// Whether `id` (`namespace:path`) is a target; `in_tag` answers tag membership.
pub fn matches(&self, id: &str, in_tag: impl Fn(&str) -> bool) -> bool {
self.0.iter().any(|(target, _)| match target {
Target::Id(t) => t == id,
Target::Tag(tag) => in_tag(tag),
Target::Pattern(re) => re.is_match(id),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn targets(v: Value) -> Targets {
Targets::parse(Some(&v), None).unwrap()
}
#[test]
fn the_file_path_names_the_target_without_a_targets_field() {
let path = "pack/assets/byg/polytone/biome_modifiers/redwood_forest.json";
assert_eq!(
implicit_id(path, "biome_modifiers").as_deref(),
Some("byg:redwood_forest")
);
assert_eq!(implicit_id(path, "dimension_modifiers"), None);
let t = Targets::parse(None, Some("byg:redwood_forest")).unwrap();
assert!(t.matches("byg:redwood_forest", |_| false));
assert!(!t.matches("minecraft:forest", |_| false));
}
#[test]
fn ids_default_to_minecraft_and_match_literally() {
let t = targets(json!(["stone", "create:brass_block"]));
assert!(t.matches("minecraft:stone", |_| false));
assert!(t.matches("create:brass_block", |_| false));
// `.` is an id character: no pattern, so it doesn't match other ids.
assert!(!targets(json!("minecraft:a.b")).matches("minecraft:axb", |_| false));
}
#[test]
fn strings_that_are_no_id_are_tags_then_patterns() {
let t = targets(json!(["#minecraft:is_overworld", "create:.*"]));
assert!(t.matches("create:anything", |_| false));
assert!(!t.matches("minecraft:plains", |_| false));
assert!(t.matches("minecraft:plains", |tag| tag == "minecraft:is_overworld"));
}
#[test]
fn the_long_form_carries_required() {
let t =
targets(json!([{"id": "minecraft:pale_oak_leaves", "required": false}, "oak_leaves"]));
let required: Vec<bool> = t.0.iter().map(|e| e.1).collect();
assert_eq!(required, [false, true]);
assert!(t.matches("minecraft:pale_oak_leaves", |_| false));
}
#[test]
fn a_bad_pattern_is_an_error() {
assert!(Targets::parse(Some(&json!("broken(")), None).is_err());
assert!(Targets::parse(None, None).is_err());
}
}
+79
View File
@@ -339,6 +339,30 @@ pub fn renamed_ids(v: &mut Value) -> bool {
}
}
/// Every string of a model or block state file that is a renamed id's block or
/// item model or texture (`minecraft:block/chain`, `item/chain`) set to the
/// new one's, in place; whether any was. 26.3 has only `iron_chain`'s.
pub fn renamed_assets(v: &mut Value) -> bool {
match v {
Value::String(s) => {
let path = s.strip_prefix("minecraft:").unwrap_or(s);
let renamed = RENAMED_IDS.iter().find_map(|(old, new)| {
let (old, new) = (
old.strip_prefix("minecraft:")?,
new.strip_prefix("minecraft:")?,
);
["block/", "item/"].iter().find_map(|kind| {
(path.strip_prefix(kind)? == old).then(|| format!("minecraft:{kind}{new}"))
})
});
renamed.map(|new| *s = new).is_some()
}
Value::Array(a) => a.iter_mut().fold(false, |c, x| renamed_assets(x) | c),
Value::Object(o) => o.values_mut().fold(false, |c, x| renamed_assets(x) | c),
_ => false,
}
}
/// A 1.21.1 advancement in 26.3's shape, in place: an `enter_block`
/// criterion names its block as `blocks`, which 26.3 reads as a block list.
/// 26.3 skips the old `block` field, so the criterion matched any block the
@@ -991,6 +1015,14 @@ pub fn carry(
{
report.worldgen += 1;
}
if name.starts_with("assets/")
&& (rel.starts_with("models/") || rel.starts_with("blockstates/"))
&& name.ends_with(".json")
&& let Ok(mut model) = serde_json::from_slice::<Value>(&bytes)
&& renamed_assets(&mut model)
{
bytes = serde_json::to_vec_pretty(&model).unwrap_or(bytes);
}
if let Some(file) = rel
.strip_prefix("recipe/")
.filter(|_| name.starts_with("data/"))
@@ -2433,4 +2465,51 @@ mod tests {
assert!(!dir.join("evil.json").exists());
fs::remove_dir_all(&dir).unwrap();
}
/// A 1.21.1 model drawing vanilla's chain is carried with 26.3's
/// `iron_chain` model and texture; other ids that start alike stay.
#[test]
fn carried_models_name_the_renamed_chain() {
let dir =
std::env::temp_dir().join(format!("horizon-port-pack-chain-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let jar = dir.join("pulley-1.0.jar");
let mut zip = zip::ZipWriter::new(fs::File::create(&jar).unwrap());
for (name, text) in [
(
"META-INF/neoforge.mods.toml",
"license = \"MIT\"\n[[mods]]\nmodId = \"pulley\"\nversion = \"1.0\"\n",
),
(
"assets/pulley/models/block/pulley.json",
r#"{"parent": "block/chain", "textures": {"0": "minecraft:block/chain",
"1": "item/chain", "2": "pulley:block/chain",
"3": "minecraft:block/chain_command_block_back"}}"#,
),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
let info = crate::jar::read(&jar).unwrap();
let out = dir.join("work/mods/pulley");
let pack = Pack {
loaded: &|_| true,
mods: &|_| false,
};
carry(&jar, &info, &out, None, &|_| true, &pack, &BTreeMap::new()).unwrap();
let model: Value = serde_json::from_slice(
&fs::read(out.join("carry/assets/pulley/models/block/pulley.json")).unwrap(),
)
.unwrap();
assert_eq!(
model,
json!({"parent": "minecraft:block/iron_chain", "textures": {
"0": "minecraft:block/iron_chain", "1": "minecraft:item/iron_chain",
"2": "pulley:block/chain", "3": "minecraft:block/chain_command_block_back"}})
);
fs::remove_dir_all(&dir).unwrap();
}
}
+25 -4
View File
@@ -42,6 +42,16 @@ const RENAMED: [(&str, &str); 5] = [
/// hooked when every one of them is.
const PARENTS: [(&str, &[&str]); 1] = [("ModConfigEvent", &["Loading", "Reloading", "Unloading"])];
/// Events NeoForge dropped after 1.21.1 because vanilla made what they built
/// into data, with the registries a port writes instead (from a native mod's
/// data, or `data_pack_files`). NeoForge's 26.1 primer: "Villager trades has
/// now become a data-generated registry from its original map-based setup",
/// in `trade_set` and `villager_trade`; 26.3's javadoc has neither event.
const DATA: [(&str, &[&str]); 2] = [
("VillagerTradesEvent", &["trade_set", "villager_trade"]),
("WandererTradesEvent", &["trade_set", "villager_trade"]),
];
/// The catalog's NeoForge names: the hooks that are each name's
/// counterpart, and the hooks whose `none (…)` label mentions it.
#[derive(Debug, Default)]
@@ -61,6 +71,9 @@ pub enum Status {
Phase(Vec<String>),
/// The hooks whose label says why they aren't its counterpart.
Noted(Vec<String>),
/// No hook is needed: the registries (`DATA`) that took the event's
/// place.
Data(Vec<String>),
Missing,
}
@@ -102,7 +115,13 @@ impl Catalog {
}
}
}
(Status::Missing, None)
match DATA.iter().find(|(event, _)| *event == name) {
Some((_, registries)) => (
Status::Data(registries.iter().map(|r| (*r).to_owned()).collect()),
None,
),
None => (Status::Missing, None),
}
}
fn lookup(&self, name: &str) -> Status {
@@ -286,7 +305,7 @@ pub fn table(mods: &BTreeMap<String, BTreeSet<String>>, catalog: &Catalog) -> St
let hooks: Vec<String> = hooks.iter().map(|h| format!("`{h}`")).collect();
hooks.join(", ")
};
let mut counts = [0usize; 4];
let mut counts = [0usize; 5];
let mut body = String::new();
for (name, ids) in &rows {
let (status, under) = catalog.status(name);
@@ -294,7 +313,8 @@ pub fn table(mods: &BTreeMap<String, BTreeSet<String>>, catalog: &Catalog) -> St
Status::Hooked(h) => (0, code(h)),
Status::Phase(h) => (1, format!("other phase: {}", code(h))),
Status::Noted(h) => (2, format!("not quite: {}", code(h))),
Status::Missing => (3, "none".to_owned()),
Status::Data(r) => (3, format!("data: {}", code(r))),
Status::Missing => (4, "none".to_owned()),
};
counts[i] += 1;
let under = under.map(|u| format!(" (as `{u}`)")).unwrap_or_default();
@@ -308,7 +328,7 @@ pub fn table(mods: &BTreeMap<String, BTreeSet<String>>, catalog: &Catalog) -> St
let listening = mods.values().filter(|names| !names.is_empty()).count();
format!(
"{} mods; {listening} listen to NeoForge events or use its capabilities.\n\
{} names: {} with a hook, {} with another phase's, {} noted as not quite one, {} with none.\n\n\
{} names: {} with a hook, {} with another phase's, {} noted as not quite one, {} made data, {} with none.\n\n\
| Mods | NeoForge | Hooks | Used by |\n|---:|---|---|---|\n{body}",
mods.len(),
rows.len(),
@@ -316,5 +336,6 @@ pub fn table(mods: &BTreeMap<String, BTreeSet<String>>, catalog: &Catalog) -> St
counts[1],
counts[2],
counts[3],
counts[4],
)
}
+27
View File
@@ -166,6 +166,33 @@ fn labels_give_counterparts_phases_notes_and_new_names() {
}
}
#[test]
fn trade_events_are_answered_by_the_trade_registries() {
let c = Catalog::parse(LABELS).unwrap();
let data = (
Status::Data(vec!["trade_set".to_owned(), "villager_trade".to_owned()]),
None,
);
assert_eq!(c.status("VillagerTradesEvent"), data);
assert_eq!(c.status("WandererTradesEvent"), data);
let mut mods = BTreeMap::new();
mods.insert(
"trader".to_owned(),
BTreeSet::from(["VillagerTradesEvent".to_owned()]),
);
let report = events::table(&mods, &c);
assert!(
report.contains("1 names: 0 with a hook, 0 with another phase's, 0 noted as not quite one, 1 made data, 0 with none."),
"{report}"
);
assert!(
report.contains(
"| 1 | `VillagerTradesEvent` | data: `trade_set`, `villager_trade` | trader |"
),
"{report}"
);
}
#[test]
fn a_parent_is_hooked_when_each_of_its_events_is() {
let all = r#"{"hooks": [
+1
View File
@@ -19,6 +19,7 @@ sha1 = "0.10"
sha2 = "0.10"
# AES-NI at run time on x86 for the connection cipher (crypto/blocks.rs).
aes = "0.8.4"
socket2 = { version = "0.5", features = ["all"] }
# The name servers of Windows' network adapters, for Minecraft SRV redirects.
[target.'cfg(windows)'.dependencies]
+24 -1
View File
@@ -270,6 +270,20 @@ pub struct Connection {
pub reader: ConnReader,
pub writer: ConnWriter,
}
fn configure_stream(stream: &TcpStream) -> Result<()> {
stream.set_nodelay(true)?;
let sock = socket2::SockRef::from(stream);
let mut keepalive = socket2::TcpKeepalive::new()
.with_time(Duration::from_secs(10))
.with_interval(Duration::from_secs(3));
#[cfg(not(any(target_os = "windows", target_os = "openbsd")))]
{
keepalive = keepalive.with_retries(3);
}
let _ = sock.set_tcp_keepalive(&keepalive);
Ok(())
}
impl Connection {
pub fn connect(host: &str, port: u16, timeout: Duration) -> Result<Self> {
let addrs: Vec<_> = (host, port)
@@ -344,7 +358,7 @@ impl Connection {
}
}
pub fn from_stream(stream: TcpStream) -> Result<Self> {
stream.set_nodelay(true)?;
configure_stream(&stream)?;
Self::over(Stream::Tcp(stream))
}
/// A connection over one end of an in-memory [`Pipe`].
@@ -957,4 +971,13 @@ mod tests {
assert_eq!((id, payload.as_slice()), (3, &b"after"[..]));
server.join().unwrap();
}
#[test]
fn tcp_stream_enables_nodelay_and_keepalive() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
let _server = listener.accept().unwrap();
assert!(Connection::from_stream(stream).is_ok());
}
}
+3 -1
View File
@@ -413,6 +413,7 @@ fn handle(state: &Arc<State>, mut stream: TcpStream, peer: SocketAddr) -> io::Re
let first_line = horizon_friends::deadline(&stream, state.config.first_line)?;
stream.set_read_timeout(Some(state.config.first_line))?;
let _ = stream.set_nodelay(true);
let _ = horizon_friends::set_tcp_keepalive(&stream);
let mut line = wire::read_line(&mut stream)?.ok_or(io::ErrorKind::UnexpectedEof)?;
if state.config.proxy_protocol {
if !state
@@ -774,6 +775,7 @@ fn bridge(
for s in [&guest, &host] {
s.set_read_timeout(Some(state.config.idle))?;
let _ = s.set_nodelay(true);
let _ = horizon_friends::set_tcp_keepalive(s);
}
let (g, h, st) = (guest.try_clone()?, host.try_clone()?, state.clone());
let up = std::thread::Builder::new()
@@ -788,7 +790,7 @@ fn bridge(
fn copy(state: &State, mut from: TcpStream, mut to: TcpStream) {
let rate = state.config.session_rate;
let mut bucket = Bucket::new(rate, (rate / 2.0).max(16384.0), Instant::now());
let mut buf = vec![0u8; 16 * 1024];
let mut buf = [0u8; 16 * 1024];
loop {
let n = match from.read(&mut buf) {
Ok(0) | Err(_) => break,
+260 -26
View File
@@ -480,9 +480,30 @@ impl<'r> BlockSection<'r> {
if state.0 as usize >= self.registry.len() {
return Err(invalid("state ID outside registry"));
}
// A uniform section has one palette slot, all 4,096 blocks and no free
// slots, so a write either matches it or grows it to two states.
let Some(storage) = self.storage.as_ref() else {
debug_assert!(self.palette.len() == 1 && self.counts == [4096] && self.free.is_empty());
let old = self.palette[0];
if old == state {
return Ok(old);
}
let bits = bits_for_palette(2, 4);
let mut storage = PackedStorage::new(bits, 4096).map_err(|e| invalid(e.to_string()))?;
storage.write(index, 1);
self.storage = Some(storage);
self.palette.push(state);
self.counts[0] = 4095;
self.counts.push(1);
self.last = Some((state, 1));
return Ok(old);
};
// The slot at `index` is the old state's id, so no palette lookup is
// needed for it; only the new state has to be resolved.
let old_id = self.storage.as_ref().map_or(0, |s| s.read(index));
// needed for it; only the new state has to be resolved. Reading and
// writing that slot share one position computation; growing the palette
// re-packs the storage, which looks the position up again.
let mut slot = storage.slot_of(index);
let old_id = storage.read_at(slot);
let old = self.palette[old_id as usize];
if old == state {
return Ok(old);
@@ -506,11 +527,10 @@ impl<'r> BlockSection<'r> {
if next_bits != self.bits() {
let mut storage = PackedStorage::new(next_bits, 4096)
.map_err(|e| invalid(e.to_string()))?;
if let Some(old) = &self.storage {
for i in 0..4096 {
storage.write(i, old.read(i));
}
for i in 0..4096 {
storage.write(i, self.storage.as_ref().unwrap().read(i));
}
slot = storage.slot_of(index);
self.storage = Some(storage);
}
self.palette.push(state);
@@ -520,16 +540,20 @@ impl<'r> BlockSection<'r> {
}
}
};
self.storage.as_mut().unwrap().write(index, id);
self.counts[old_id as usize] -= 1;
self.counts[id as usize] += 1;
if self.counts[old_id as usize] == 0 {
self.storage.as_mut().unwrap().write_at(slot, id);
// Both counts are read once and written back once: the two checks below
// use the values this call produced.
let old_count = self.counts[old_id as usize] - 1;
self.counts[old_id as usize] = old_count;
let new_count = self.counts[id as usize] + 1;
self.counts[id as usize] = new_count;
if old_count == 0 {
self.free.push(old_id);
if self.last == Some((old, old_id)) {
self.last = None;
}
}
if self.counts[id as usize] == 4096 {
if new_count == 4096 {
self.palette = vec![state];
self.last = Some((state, 0));
self.counts = vec![4096];
@@ -572,29 +596,68 @@ impl<'r> BlockSection<'r> {
let Some(id) = id_in(&self.palette, &self.counts, &mut self.last, state) else {
return Ok(added);
};
let Some(storage) = self.storage.as_mut() else {
return Ok(added);
// Ids are unique among live palette slots, so a cell already holds
// `state` exactly when it holds `id`, and held `air` exactly when it
// held `air_id` (written as a sentinel: no slot is that large).
let air_id = id_in(&self.palette, &self.counts, &mut self.last, air).unwrap_or(u32::MAX);
let head = index(top);
// A run in one column moves 256 entries per Y. While a word holds a
// whole number of those steps (every width generation produces: 1, 2, 4,
// 8, 16 and 32 bits per entry) the bit shift stays put, so the run walks
// its words directly. Other widths step the shift as well and go one
// `set` per block: the palette edits and non-air counts are the same,
// and the run's state is already in the palette by now.
const STRIDE: usize = 1 << 8;
let fixed = match self.storage.as_ref() {
// No word array: every block already is what the first write wrote.
None => return Ok(added),
Some(storage) => storage.fixed_shift(STRIDE),
};
for y in (y_start..top).rev() {
let i = index(y);
let old_id = storage.read(i);
let old_state = self.palette[old_id as usize];
if old_state == state {
if !fixed {
// A cell that already held the run's state is not counted, so a run
// of air over air adds nothing here; the top block above is counted
// either way.
for y in (y_start..top).rev() {
if self.set(index(y), state)? == air && state != air {
added += 1;
}
}
return Ok(added);
}
let counts = &mut self.counts;
let free = &mut self.free;
let last = &mut self.last;
let palette = &self.palette;
// `counts[id]` is read nowhere in the loop, so its shares are added up.
let mut gained = 0u16;
let mut run = self
.storage
.as_mut()
.expect("the section is not uniform")
.word_run(head, STRIDE, id);
// Counted down rather than iterated over the Y range: the range's own
// bookkeeping was a sixth of the loop's instructions.
let mut left = top - y_start;
while left > 0 {
left -= 1;
let old_id = run.write_back();
if old_id == id {
continue;
}
storage.write(i, id);
self.counts[old_id as usize] -= 1;
self.counts[id as usize] += 1;
if old_state == air {
gained += 1;
let old_count = counts[old_id as usize] - 1;
counts[old_id as usize] = old_count;
if old_id == air_id {
added += 1;
}
if self.counts[old_id as usize] == 0 {
self.free.push(old_id);
if self.last == Some((old_state, old_id)) {
self.last = None;
if old_count == 0 {
free.push(old_id);
if *last == Some((palette[old_id as usize], old_id)) {
*last = None;
}
}
}
counts[id as usize] += gained;
if self.counts[id as usize] == 4096 {
self.palette = vec![state];
self.last = Some((state, 0));
@@ -773,6 +836,177 @@ mod tests {
Ok(old)
}
/// `main`'s `fill_run`, unchanged except that it writes through `set`
/// (checked against `baseline_set` above) instead of that file's own copy.
fn reference_fill_run(
section: &mut BlockSection<'_>,
x: i32,
y_start: i32,
z: i32,
len: i32,
state: BlockStateId,
air: BlockStateId,
) -> io::Result<u16> {
if !(0..16).contains(&x)
|| !(0..16).contains(&z)
|| len <= 0
|| y_start < 0
|| y_start + len > 16
{
return Err(invalid("section fill outside its bounds"));
}
let index = |y: i32| ((y as usize) << 8) | ((z as usize) << 4) | x as usize;
let top = y_start + len - 1;
let mut added = 0u16;
let old = section.set(index(top), state)?;
if old == air {
added += 1;
}
let Some(id) = id_in(&section.palette, &section.counts, &mut section.last, state) else {
return Ok(added);
};
let Some(storage) = section.storage.as_mut() else {
return Ok(added);
};
for y in (y_start..top).rev() {
let i = index(y);
let old_id = storage.read(i);
let old_state = section.palette[old_id as usize];
if old_state == state {
continue;
}
storage.write(i, id);
section.counts[old_id as usize] -= 1;
section.counts[id as usize] += 1;
if old_state == air {
added += 1;
}
if section.counts[old_id as usize] == 0 {
section.free.push(old_id);
if section.last == Some((old_state, old_id)) {
section.last = None;
}
}
}
if section.counts[id as usize] == 4096 {
section.palette = vec![state];
section.last = Some((state, 0));
section.counts = vec![4096];
section.free.clear();
section.storage = None;
}
Ok(added)
}
/// Palette order, counts, free slots, width and every packed word.
fn assert_fields(section: &BlockSection<'_>, reference: &BlockSection<'_>, what: &str) {
assert_eq!(section.palette, reference.palette, "{what}: palette");
assert_eq!(section.counts, reference.counts, "{what}: counts");
assert_eq!(section.free, reference.free, "{what}: free slots");
assert_eq!(section.bits(), reference.bits(), "{what}: bits");
assert_eq!(
section.storage.as_ref().map(|s| s.words()),
reference.storage.as_ref().map(|s| s.words()),
"{what}: packed words"
);
}
/// Every block, as `get` reads it.
fn assert_blocks(section: &BlockSection<'_>, reference: &BlockSection<'_>, what: &str) {
for index in 0..4096 {
assert_eq!(
section.state(index),
reference.state(index),
"{what}: block {index}"
);
}
}
/// `set` and `fill_run` against `main`'s implementations, over every width
/// the palette can pick: 1 state, 2..4, 5..16, 17..32, 33..64, 65..128,
/// 129..256 and the global ids past that (4 bits to 12). Runs cover whole
/// sections, single blocks and every length in between.
#[test]
fn fill_run_and_set_match_reference_across_palette_widths() {
let registry = benchmark_registry(4100);
let air = BlockStateId(0);
for &(distinct, seed) in &[
(1u64, 11u64),
(2, 12),
(3, 13),
(4, 14),
(5, 15),
(16, 16),
(17, 17),
(31, 18),
(32, 19),
(33, 20),
(64, 21),
(65, 22),
(128, 23),
(129, 24),
(200, 25),
(256, 26),
(257, 27),
(400, 28),
(4096, 29),
] {
let mut section = BlockSection::uniform(air, &registry).unwrap();
let mut reference = BlockSection::uniform(air, &registry).unwrap();
let mut rng = seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1;
let mut next = || {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
rng
};
for step in 0..600u32 {
let state = BlockStateId((next() % distinct) as u32);
if step % 3 == 0 {
let index = (next() % 4096) as usize;
let what = format!("distinct {distinct} step {step} set {index}");
assert_eq!(
section.set(index, state).unwrap(),
reference.set(index, state).unwrap(),
"{what}"
);
assert_fields(&section, &reference, &what);
continue;
}
let x = (next() % 16) as i32;
let z = (next() % 16) as i32;
let y_start = (next() % 16) as i32;
let len = 1 + (next() % (16 - y_start as u64)) as i32;
// Every section width, and fills that land on the section top
// and bottom.
let len = if step % 7 == 0 { 16 - y_start } else { len };
let what = format!("distinct {distinct} step {step} run {x},{y_start},{z}+{len}");
assert_eq!(
section.fill_run(x, y_start, z, len, state, air).unwrap(),
reference_fill_run(&mut reference, x, y_start, z, len, state, air).unwrap(),
"{what}: air blocks added"
);
assert_fields(&section, &reference, &what);
if step % 16 == 0 {
assert_blocks(&section, &reference, &what);
}
}
// One state over the whole section collapses it to a uniform one.
let state = BlockStateId((next() % distinct) as u32);
for x in 0..16 {
for z in 0..16 {
assert_eq!(
section.fill_run(x, 0, z, 16, state, air).unwrap(),
reference_fill_run(&mut reference, x, 0, z, 16, state, air).unwrap()
);
}
}
assert_fields(&section, &reference, "collapsed");
assert_blocks(&section, &reference, "collapsed");
assert_eq!(section.bits(), 0, "a uniform section has no words");
}
}
fn benchmark_registry(states: usize) -> BlockRegistry {
let list: Vec<Tag> = (0..states)
.map(|i| {
+2 -2
View File
@@ -369,7 +369,7 @@ Steam + X opens it at any time. "On-Screen Keyboard: Auto / Horizon / Steam"
Both modes send the same pointer events, but neither reaches everything. Focus mode
registers targets on: the title menus (main, world list and its search, create
and edit world, delete, multiplayer list and its edit screens), every options
page (vanilla's and Horizon Settings with the pages it opens except Replay Studio, key and
page (vanilla's and Horizon Settings with the pages it opens, key and
controller binds, language list), pause and Play with Friends, death, chat, signs and books (their buttons), the
survival and creative inventories, every container and station (chests,
furnaces, brewing, crafter, crafting table with its recipe book, anvil,
@@ -377,7 +377,7 @@ grindstone, smithing, stonecutter, loom, cartography, enchanting, beacon,
villager and wandering-trader trades, mount inventories), and the LAN /
world options. Where a screen draws something clickable no target names
(the advancements map, the statistics lists, the pack screen's rows, the
book's page text), the pad drives the pointer instead, as it does on Replay Studio, which registers nothing yet.
book's page text), the pad drives the pointer instead.
## Glyphs
+10 -3
View File
@@ -377,16 +377,23 @@ events a mod listens to and the capabilities it uses. A listener is an `@Subscri
parameter, or a method reference or lambda that takes one event
(`NeoForge.EVENT_BUS.addListener(this::onTick)`). It looks each name up in the hook catalog's
`neoforge` names ([native-hooks-neoforge.md](native-hooks-neoforge.md)). Over a folder of jars it
ranks the names by how many mods use them. Each name gets one of four answers:
ranks the names by how many mods use them. Each name gets one of five answers:
- a hook;
- another phase's hook (`player_tick` for `PlayerTickEvent.Pre`);
- a hook whose label says why it isn't quite the counterpart;
- data: the event is gone because vanilla made what it built into data, and the answer names the
registries a port writes instead;
- none.
Names NeoForge changed after 1.21.1 (`Capabilities.ItemHandler`, `Capabilities.EnergyStorage`,
`AddReloadListenerEvent`) are looked up under their new ones. The report reads names only, never
code: it says where a port starts, for a human to check.
`AddReloadListenerEvent`) are looked up under their new ones. `VillagerTradesEvent` and
`WandererTradesEvent` are data: 26.1 made villager trades the `trade_set` and `villager_trade`
registries (NeoForge's 26.1 primer), and 26.3's NeoForge has neither event. A port writes each
trade as a `villager_trade` file and adds it to the tag a set reads (`armorer/level_1`'s set takes
`#minecraft:armorer/level_1`). Tags merge, so several mods can add to one level. The files come from
the mod's own data or from `data_pack_files`. The report reads names only, never code: it says
where a port starts, for a human to check.
On ATM10's 498 jars (2026-11-05, 8 s), 416 of 497 mods listen to events or use capabilities: 6,616
uses of 326 names. Main's catalog had a hook for 23% of those uses, and 43% once #9924 labelled 32
+12
View File
@@ -1,5 +1,7 @@
# Native mods
`native_chat_suggestion_applied(original, start, end, suggestion, input)` reports a suggestion accepted in the open chat by Tab or a mouse click, after the text and caret have changed. `original` is the suggestion list's input before any completion; `start` and `end` are the replaced range in that string, in Java UTF-16 units (end exclusive). They keep referring to `original` when Tab cycles through the list. `suggestion` is the selected replacement text, and `input` is the actual resulting field value, including any length-limit truncation. This is a read-only notification: previews, list navigation, server replies, ordinary typing and command-block editors do not fire it. Trait mods implement `Hooks::chat_suggestion_applied`; mixin bundles use `chat_suggestion_applied`, hook version 1. Bundles need "hooks": @API:chat_suggestion_applied@ (assigned at merge).
API 362 adds `native_compute_fov_modifier(player, base, modifier)` on the client each tick for the local player's FOV scale, after vanilla effects and before zoom and camera easing. `player` contains the calculation inputs and `base` is the original scale. Non-finite modifiers or values outside 0.01..=10 fall back to `base`. It does not affect a spectator camera on a mob. Bundles need "hooks": 362.
The client hook `native_client_packet(packet)` hears each play packet the server sends, decoded (`ClientboundPlayPacket`, `horizon_protocol`'s `play::Clientbound`), just before the game handles it, custom payloads included and in the order they came. It only reads: the game handles the packet as it would without the mod. Packets the network thread handles itself (keep-alives, bundle delimiters, resource packs, cookies, server links, transfers, disconnects, the configuration switch, `minecraft:brand`, far terrain tiles) and the configuration phase are not heard. Bundles need "hooks": 408.
@@ -5000,3 +5002,13 @@ The client `tooltip_color` hook changes the background and top and bottom border
# Camera and collision reads
The `native_camera()` helper returns the most recently extracted world frame's camera, or none before one exists. Its position, interpolated eye height, partial tick, angles, final FOV, near/far planes and dimension accompany column-major view and projection matrices. The view is camera-relative and includes bob and spin; projection uses reversed Z. Earlier callbacks in a frame can see the previous frame. `native_block_collision(pos)` returns block-local `[min_x, min_y, min_z, max_x, max_y, max_z]` boxes with an empty collision context; unloaded blocks and collision errors give no boxes.
### Wind burst block protection
The pending `wind_burst` hook receives `dimension`, `center` and mutable `blocks`
just before wind-charge, breeze-charge or Wind Burst mace block interactions.
Remove positions to keep their doors, levers, candles and other reactive blocks
unchanged. The list has already been shuffled; entity knockback and the shuffle's
random draws are unaffected. A breeze blocked by `mob_griefing` does not call the hook.
This is separate from `explosion_detonate`, and does not cancel the burst's sound,
particles or entity effects. The pending fragment assigns its API number at merge.
+5 -4
View File
@@ -9,8 +9,9 @@ numerical slack and all yaw rotations covered. Invalid/unusual dimensions keep t
Mobs (including charged blazes), dropped items, armor stands and the visible local body use
the gate before light lookup. Fire simulation, damage, dynamic lights and visible flame geometry
are unchanged. Native mods do not disable the optimization. Shader packs and TAA keep the
original path; TAA's jittered projection can extend beyond this camera frustum. This is
are unchanged. Native mods do not disable the optimization. Shader packs keep the original
path. Under TAA the gate uses a frustum widened by one rendered pixel per side, which covers
every jitter phase (`taa_frustum` in gfx.rs). This is
automatic preparation culling without a new setting.
## Measurement
@@ -52,8 +53,8 @@ visible flames in the reference scene.
Five baseline/candidate comparisons at 1280×720 were identical: front, above the body,
the same edge with OIT enabled, a side view, and behind. Unit tests bound actual emitted
vertices for seven dimensions, three scales, 24 yaws and cameras near the world border,
and verify that unsupported dimensions retain the original path. Shader-pack/TAA paths bypass
the optimization; no shader-pack or TAA rendering gain is claimed.
and verify that unsupported dimensions retain the original path. Shader-pack paths bypass
the optimization; no shader-pack rendering gain is claimed.
Armor-stand flame reception is not used as a benchmark: an existing shared-flag decoding gap
in `remote_objects.rs` ignores its burning bit. This change does not address that separate bug.
+13 -2
View File
@@ -2679,7 +2679,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"socket2",
"socket2 0.6.5",
"spake2",
]
@@ -2815,6 +2815,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"socket2 0.5.10",
]
[[package]]
@@ -3167,7 +3168,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"socket2 0.6.5",
"widestring",
"windows-registry",
"windows-result 0.4.1",
@@ -5466,6 +5467,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.5"
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Kies 'n prent...",
"{source} · needed by another mod": "{source} · benodig deur 'n ander mod",
"Delete…": "Vee uit …",
"Loading patch notes…": "Laai tans pleisternotas …",
"No native mod is turned on.": "Geen inheemse mod is aangeskakel nie.",
"Use these jars": "Gebruik hierdie flesse",
"Starting": "Begin",
@@ -577,7 +576,6 @@
"Add": "Voeg by",
"Clear filters": "Vee filters uit",
"Downloads": "Aflaaie",
"What changed in each release, newest first.": "Wat in elke uitgawe verander het, nuutste eerste.",
"Made the instance {name} from the shared file.": "Het die instansie {name} van die gedeelde lêer gemaak.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Vir die spel en die opdragte; INST_NAME, INST_DIR en die ander instansie veranderlikes is ook gestel.",
"copied from your folder": "uit jou gids gekopieer",
@@ -666,7 +664,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "{name} na die asblik geskuif.",
"Copied": "Gekopieer",
"Couldn't load patch notes. {error}": "Kon nie pleisternotas laai nie. {error}",
"Couldn't load releases. {error}": "Kon nie vrystellings laai nie. {error}",
"Launcher {version}": "Launcher {version}",
"Launcher {version} {percent}%": "Launcher {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "اختر صورة…",
"{source} · needed by another mod": "{source} · مطلوب بواسطة تعديل آخر",
"Delete…": "حذف…",
"Loading patch notes…": "جاري تحميل ملاحظات التحديث…",
"No native mod is turned on.": "لا يوجد أي تعديل أصلي مُفعّل.",
"Use these jars": "استخدام ملفات jars هذه",
"Starting": "جاري البدء",
@@ -579,7 +578,6 @@
"Add": "إضافة",
"Clear filters": "مسح المرشحات",
"Downloads": "التنزيلات",
"What changed in each release, newest first.": "ما تم تغييره في كل إصدار، والأحدث أولاً.",
"Made the instance {name} from the shared file.": "تم إنشاء النسخة {name} من الملف المُشارك.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "للعبة والأوامر؛ يتم تعيين متغيرات INST_NAME و INST_DIR ومتغيرات النسخ الأخرى أيضًا.",
"copied from your folder": "تم النسخ من مجلدك",
@@ -668,7 +666,6 @@
"Modpacks": "حزم التعديلات",
"{name} moved to the trash.": "تم نقل {name} إلى سلة المهملات.",
"Copied": "تم النسخ",
"Couldn't load patch notes. {error}": "تعذر تحميل ملاحظات التحديث. {error}",
"Couldn't load releases. {error}": "تعذر تحميل الإصدارات. {error}",
"Launcher {version}": "المشغل {version}",
"Launcher {version} {percent}%": "المشغل {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Şəkil seçin...",
"{source} · needed by another mod": "{source} · başqa modda lazımdır",
"Delete…": "Sil...",
"Loading patch notes…": "Yamaq qeydləri yüklənir...",
"No native mod is turned on.": "Heç bir yerli mod aktiv deyil.",
"Use these jars": "Bu qablardan istifadə edin",
"Starting": "Başlanır",
@@ -577,7 +576,6 @@
"Add": "əlavə et",
"Clear filters": "Filtrləri təmizləyin",
"Downloads": "Yükləmələr",
"What changed in each release, newest first.": "Hər buraxılışda nə dəyişdi, ən yeni.",
"Made the instance {name} from the shared file.": "Paylaşılan fayldan {name} nümunəsini yaratdı.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Oyun və əmrlər üçün; INST_NAME, INST_DIR və digər instansiya dəyişənləri də təyin edilib.",
"copied from your folder": "qovluğunuzdan kopyalandı",
@@ -666,7 +664,6 @@
"Modpacks": "Mod paketləri",
"{name} moved to the trash.": "{name} zibil qutusuna atıldı.",
"Copied": "Kopyalandı",
"Couldn't load patch notes. {error}": "Yamaq qeydlərini yükləmək mümkün olmadı. {error}",
"Couldn't load releases. {error}": "Buraxılışları yükləmək mümkün olmadı. {error}",
"Launcher {version}": "Başlatıcı {version}",
"Launcher {version} {percent}%": "Başlatıcı {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Һүрәт һайлағыҙ...",
"{source} · needed by another mod": "{source} · икенсе модҡа кәрәк",
"Delete…": "Юйырға...",
"Loading patch notes…": "Патч иҫкәрмәләрен тейәү...",
"No native mod is turned on.": "Бер ниндәй ҙә туған мод ҡабыҙылмай.",
"Use these jars": "Был банкаларҙы ҡулланығыҙ",
"Starting": "Башланғыс",
@@ -577,7 +576,6 @@
"Add": "Өҫтәү",
"Clear filters": "Фильтрҙарҙы таҙартыу",
"Downloads": "Йөкләүҙәр",
"What changed in each release, newest first.": "Һәр релизда нимә үҙгәрҙе, иң яңыһы тәүҙә.",
"Made the instance {name} from the shared file.": "{name} экземплярын уртаҡ файлдан эшләне.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Уйын һәм командалар өсөн; INST_NAME, INST_DIR һәм башҡа үҙгәртеүселәр ҙә ҡуйыла.",
"copied from your folder": "һеҙҙең папканан күсерелгән",
@@ -666,7 +664,6 @@
"Modpacks": "Модпактар",
"{name} moved to the trash.": "{name} сүплеккә күсерелгән.",
"Copied": "Күсерелгән",
"Couldn't load patch notes. {error}": "Патч иҫкәрмәләрен тейәп булманы. {error}",
"Couldn't load releases. {error}": "Релиздарҙы тейәп булманы. {error}",
"Launcher {version}": "Пуск {version}",
"Launcher {version} {percent}%": "Пуск {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Выберыце малюнак…",
"{source} · needed by another mod": "{source} · неабходны для іншага мода",
"Delete…": "Выдаліць...",
"Loading patch notes…": "Загрузка нататак да патча...",
"No native mod is turned on.": "Родны мод не ўключаны.",
"Use these jars": "Выкарыстоўвайце гэтыя банкі",
"Starting": "Пачынаючы",
@@ -577,7 +576,6 @@
"Add": "Дадаць",
"Clear filters": "Ачысціць фільтры",
"Downloads": "Загрузкі",
"What changed in each release, newest first.": "Што змянілася ў кожным выпуску, спачатку самае новае.",
"Made the instance {name} from the shared file.": "Зроблены асобнік {name} з агульнага файла.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Для гульні і каманд; INST_NAME, INST_DIR і іншыя зменныя асобніка таксама ўстаноўлены.",
"copied from your folder": "скапіравана з вашай папкі",
@@ -666,7 +664,6 @@
"Modpacks": "Модпакеты",
"{name} moved to the trash.": "{name} перамешчаны ў сметніцу.",
"Copied": "Скапіравана",
"Couldn't load patch notes. {error}": "Не ўдалося загрузіць нататкі аб выпраўленні. {error}",
"Couldn't load releases. {error}": "Не ўдалося загрузіць выпускі. {error}",
"Launcher {version}": "Пускавая ўстаноўка {version}",
"Launcher {version} {percent}%": "Пускавая ўстаноўка {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "Изберете снимка...",
"{source} · needed by another mod": "{source} · необходим на друг мод",
"Delete…": "изтриване...",
"Loading patch notes…": "Бележките за корекцията се зареждат...",
"No native mod is turned on.": "Не е включен собствен мод.",
"Use these jars": "Използвайте тези буркани",
"Starting": "Стартиране",
@@ -579,7 +578,6 @@
"Add": "Добавете",
"Clear filters": "Изчистване на филтрите",
"Downloads": "Изтегляния",
"What changed in each release, newest first.": "Какво се промени във всяка версия, първо най-новите.",
"Made the instance {name} from the shared file.": "Направи екземпляра {name} от споделения файл.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "За играта и командите; INST_NAME, INST_DIR и другите екземплярни променливи също са зададени.",
"copied from your folder": "копирани от вашата папка",
@@ -668,7 +666,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "{name} преместени в кошчето.",
"Copied": "Копирано",
"Couldn't load patch notes. {error}": "Бележките за корекции не можаха да се заредят. {error}",
"Couldn't load releases. {error}": "Изданията не можаха да се заредят. {error}",
"Launcher {version}": "Стартер {version}",
"Launcher {version} {percent}%": "Стартер {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Dibabit ur skeudenn...",
"{source} · needed by another mod": "{source} · ezhomm gant ur mod all",
"Delete…": "Dilemel…",
"Loading patch notes…": "O kargañ notennoù ar patch...",
"No native mod is turned on.": "N'eus mod orin ebet gweredekaet.",
"Use these jars": "Implijit ar jarloù-mañ",
"Starting": "O kregiñ",
@@ -577,7 +576,6 @@
"Add": "Ouzhpennañ",
"Clear filters": "Diverkañ ar siloù",
"Downloads": "Pellgargadennoù",
"What changed in each release, newest first.": "Petra a cheñch e pep embannadur, an nevesañ da gentañ.",
"Made the instance {name} from the shared file.": "Graet eo bet an ensavadur {name} diwar ar restr rannet.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Evit ar c’hoari hag ar gourc’hemennou ; INST_NAME, INST_DIR hag an argemmennoù skouer all a zo arventennet ivez.",
"copied from your folder": "kopiet eus ho teuliad",
@@ -666,7 +664,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "{name} kaset d'al lastez.",
"Copied": "Eilet",
"Couldn't load patch notes. {error}": "N'haller ket kargañ notennoù ar patch. {error}",
"Couldn't load releases. {error}": "N'haller ket kargañ an embannadurioù. {error}",
"Launcher {version}": "Lañser {version}",
"Launcher {version} {percent}%": "Lañser {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Odaberite sliku…",
"{source} · needed by another mod": "{source} · potreban drugom modu",
"Delete…": "Izbriši…",
"Loading patch notes…": "Učitavanje bilješki o zakrpi…",
"No native mod is turned on.": "Nije uključen izvorni mod.",
"Use these jars": "Koristite ove tegle",
"Starting": "Počinje",
@@ -577,7 +576,6 @@
"Add": "Dodaj",
"Clear filters": "Obriši filtere",
"Downloads": "Preuzimanja",
"What changed in each release, newest first.": "Što se promijenilo u svakom izdanju, prvo najnovije.",
"Made the instance {name} from the shared file.": "Napravio instancu {name} iz dijeljenog fajla.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Za igru i komande; INST_NAME, INST_DIR i druge varijable instance su također postavljene.",
"copied from your folder": "kopirano iz vašeg foldera",
@@ -666,7 +664,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "{name} premješteno u smeće.",
"Copied": "Kopirano",
"Couldn't load patch notes. {error}": "Nije moguće učitati bilješke o zakrpama. {error}",
"Couldn't load releases. {error}": "Učitavanje izdanja nije uspjelo. {error}",
"Launcher {version}": "Pokretač {version}",
"Launcher {version} {percent}%": "Pokretač {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Tria una imatge...",
"{source} · needed by another mod": "{source} · necessari per un altre mod",
"Delete…": "Suprimeix…",
"Loading patch notes…": "S'estan carregant les notes del pedaç...",
"No native mod is turned on.": "No hi ha cap mod natiu activat.",
"Use these jars": "Utilitzeu aquests pots",
"Starting": "Començant",
@@ -577,7 +576,6 @@
"Add": "Afegeix",
"Clear filters": "Esborra els filtres",
"Downloads": "Descàrregues",
"What changed in each release, newest first.": "Què va canviar en cada llançament, primer el més nou.",
"Made the instance {name} from the shared file.": "S'ha fet la instància {name} des del fitxer compartit.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Per al joc i les ordres; També s'estableixen INST_NAME, INST_DIR i les altres variables d'instància.",
"copied from your folder": "copiat de la teva carpeta",
@@ -666,7 +664,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "{name} mogut a la paperera.",
"Copied": "Copiat",
"Couldn't load patch notes. {error}": "No s'han pogut carregar les notes del pedaç. {error}",
"Couldn't load releases. {error}": "No s'han pogut carregar les versions. {error}",
"Launcher {version}": "Llançador {version}",
"Launcher {version} {percent}%": "Llançador {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "Vyberte obrázek…",
"{source} · needed by another mod": "{source} · potřebuje jiný mod",
"Delete…": "Smazat…",
"Loading patch notes…": "Načítání poznámek k opravě…",
"No native mod is turned on.": "Není zapnutý žádný nativní mod.",
"Use these jars": "Použijte tyto sklenice",
"Starting": "Spuštění",
@@ -579,7 +578,6 @@
"Add": "Přidat",
"Clear filters": "Vymazat filtry",
"Downloads": "Stahování",
"What changed in each release, newest first.": "Co se změnilo v každém vydání, od nejnovějšího.",
"Made the instance {name} from the shared file.": "Vytvořil instanci {name} ze sdíleného souboru.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Pro hru a příkazy; INST_NAME, INST_DIR a další proměnné instance jsou také nastaveny.",
"copied from your folder": "zkopírován z vaší složky",
@@ -668,7 +666,6 @@
"Modpacks": "Modpacky",
"{name} moved to the trash.": "{name} přesunuto do koše.",
"Copied": "Zkopírováno",
"Couldn't load patch notes. {error}": "Poznámky k opravě se nepodařilo načíst. {error}",
"Couldn't load releases. {error}": "Vydání se nepodařilo načíst. {error}",
"Launcher {version}": "Spouštěč {version}",
"Launcher {version} {percent}%": "Spouštěč {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Ӳкерчӗк суйласа илӗр...",
"{source} · needed by another mod": "{source} · тепӗр мод кирлӗ",
"Delete…": "Пӗтер...",
"Loading patch notes…": "Патч заметкисене ҫӗклетпӗр...",
"No native mod is turned on.": "Тӑван мод ҫук.",
"Use these jars": "Ҫак банкӑсемпе усӑ курӑр",
"Starting": "Пуҫласа",
@@ -577,7 +576,6 @@
"Add": "Хуш",
"Clear filters": "Фильтрсене тасатмалла",
"Downloads": "Скачать",
"What changed in each release, newest first.": "Кашни кӑларӑмра мӗн улшӑннӑ, чи ҫӗнни малтан.",
"Made the instance {name} from the shared file.": "Пӗрлехи файлран {name} экземпляр тунӑ.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Вӑйӑ тата командӑсемшӗн; INST_NAME, INST_DIR тата ытти экземпляр улшӑнӑвӗсене те лартнӑ.",
"copied from your folder": "сирӗн папкӑран копилленӗ",
@@ -666,7 +664,6 @@
"Modpacks": "Модпаксем",
"{name} moved to the trash.": "{name} ҫӳп-ҫап купи ҫине куҫарнӑ.",
"Copied": "Копиленӗ",
"Couldn't load patch notes. {error}": "Патч-нотӑсене ҫӗклеймерӗм. {error}",
"Couldn't load releases. {error}": "Релизсене загрузка тума май килмерӗ. {error}",
"Launcher {version}": "Пускатель {version}",
"Launcher {version} {percent}%": "Пускатель {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Dewiswch lun…",
"{source} · needed by another mod": "{source} · angen mod arall",
"Delete…": "Dileu…",
"Loading patch notes…": "Wrthi'n llwytho nodiadau clytiau…",
"No native mod is turned on.": "Nid oes unrhyw mod brodorol wedi'i droi ymlaen.",
"Use these jars": "Defnyddiwch y jariau hyn",
"Starting": "Yn dechrau",
@@ -577,7 +576,6 @@
"Add": "Ychwanegu",
"Clear filters": "Clirio hidlwyr",
"Downloads": "Lawrlwythiadau",
"What changed in each release, newest first.": "Beth newidiodd ym mhob datganiad, mwyaf newydd yn gyntaf.",
"Made the instance {name} from the shared file.": "Wedi gwneud yr enghraifft {name} o'r ffeil a rennir.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Am y gêm a'r gorchmynion; Mae INST_NAME, INST_DIR a'r newidynnau enghraifft eraill wedi'u gosod hefyd.",
"copied from your folder": "wedi'i gopïo o'ch ffolder",
@@ -666,7 +664,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "Symudodd {name} i'r sbwriel.",
"Copied": "Wedi'i gopïo",
"Couldn't load patch notes. {error}": "Methu llwytho nodiadau clytiau. {error}",
"Couldn't load releases. {error}": "Methu llwytho datganiadau. {error}",
"Launcher {version}": "Lansiwr {version}",
"Launcher {version} {percent}%": "Lansiwr {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "Vælg et billede...",
"{source} · needed by another mod": "{source} · nødvendig af en anden mod",
"Delete…": "Slet...",
"Loading patch notes…": "Indlæser patch-noter...",
"No native mod is turned on.": "Ingen indbygget mod er slået til.",
"Use these jars": "Brug disse krukker",
"Starting": "Starter",
@@ -579,7 +578,6 @@
"Add": "Tilføj",
"Clear filters": "Ryd filtre",
"Downloads": "Downloads",
"What changed in each release, newest first.": "Hvad ændrede sig i hver udgivelse, nyeste først.",
"Made the instance {name} from the shared file.": "Lavede forekomsten {name} fra den delte fil.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Til spillet og kommandoerne; INST_NAME, INST_DIR og de andre instansvariabler er også indstillet.",
"copied from your folder": "kopieret fra din mappe",
@@ -668,7 +666,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "{name} flyttet til papirkurven.",
"Copied": "Kopieret",
"Couldn't load patch notes. {error}": "Patch-noter kunne ikke indlæses. {error}",
"Couldn't load releases. {error}": "Kunne ikke indlæse udgivelser. {error}",
"Launcher {version}": "Launcher {version}",
"Launcher {version} {percent}%": "Launcher {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "Bild auswählen…",
"{source} · needed by another mod": "{source} · von einem anderen Mod benötigt",
"Delete…": "Löschen…",
"Loading patch notes…": "Patchnotes werden geladen…",
"No native mod is turned on.": "Keine native Mod ist eingeschaltet.",
"Use these jars": "Use these jars",
"Starting": "Startet",
@@ -579,7 +578,6 @@
"Add": "Hinzufügen",
"Clear filters": "Filter zurücksetzen",
"Downloads": "Downloads",
"What changed in each release, newest first.": "Was sich in den einzelnen Versionen geändert hat, neueste zuerst.",
"Made the instance {name} from the shared file.": "Die Instanz {name} wurde aus der geteilten Datei erstellt.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Für das Spiel und die Befehle; INST_NAME, INST_DIR und die anderen Instanzvariablen werden ebenfalls gesetzt.",
"copied from your folder": "copied from your folder",
@@ -668,7 +666,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "{name} wurde in den Papierkorb verschoben.",
"Copied": "Kopiert",
"Couldn't load patch notes. {error}": "Couldn't load patch notes. {error}",
"Couldn't load releases. {error}": "Couldn't load releases. {error}",
"Launcher {version}": "Launcher {version}",
"Launcher {version} {percent}%": "Launcher {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "Επιλέξτε μια εικόνα…",
"{source} · needed by another mod": "{source} · απαιτείται από άλλο mod",
"Delete…": "Διαγραφή…",
"Loading patch notes…": "Φόρτωση σημειώσεων ενημέρωσης κώδικα…",
"No native mod is turned on.": "Κανένα εγγενές mod δεν είναι ενεργοποιημένο.",
"Use these jars": "Χρησιμοποιήστε αυτά τα βάζα",
"Starting": "Ξεκινώντας",
@@ -579,7 +578,6 @@
"Add": "Προσθήκη",
"Clear filters": "Καθαρίστε τα φίλτρα",
"Downloads": "Λήψεις",
"What changed in each release, newest first.": "Τι άλλαξε σε κάθε κυκλοφορία, πρώτα το νεότερο.",
"Made the instance {name} from the shared file.": "Έγινε η παρουσία {name} από το κοινόχρηστο αρχείο.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Για το παιχνίδι και τις εντολές? Τα INST_NAME, INST_DIR και οι άλλες μεταβλητές παρουσίας έχουν επίσης οριστεί.",
"copied from your folder": "αντιγράφηκε από το φάκελό σας",
@@ -668,7 +666,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "Ο {name} μεταφέρθηκε στον κάδο απορριμμάτων.",
"Copied": "Αντιγράφηκε",
"Couldn't load patch notes. {error}": "Δεν ήταν δυνατή η φόρτωση των σημειώσεων ενημέρωσης κώδικα. {error}",
"Couldn't load releases. {error}": "Δεν ήταν δυνατή η φόρτωση των κυκλοφοριών. {error}",
"Launcher {version}": "Εκτοξευτής {version}",
"Launcher {version} {percent}%": "Εκτοξευτής {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Elektu bildon...",
"{source} · needed by another mod": "{source} · bezonata de alia mod",
"Delete…": "Forigi…",
"Loading patch notes…": "Ŝarĝante flikaj notoj...",
"No native mod is turned on.": "Neniu denaska modo estas ŝaltita.",
"Use these jars": "Uzu ĉi tiujn kruĉojn",
"Starting": "Komencante",
@@ -577,7 +576,6 @@
"Add": "Aldoni",
"Clear filters": "Malplenigi filtrilojn",
"Downloads": "Elŝutoj",
"What changed in each release, newest first.": "Kio ŝanĝiĝis en ĉiu eldono, plej nova unue.",
"Made the instance {name} from the shared file.": "Faris la ekzemplon {name} el la komuna dosiero.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Por la ludo kaj la ordonoj; INST_NAME, INST_DIR kaj la aliaj instancvariabloj ankaŭ estas fiksitaj.",
"copied from your folder": "kopiita el via dosierujo",
@@ -666,7 +664,6 @@
"Modpacks": "Modpakaĵoj",
"{name} moved to the trash.": "{name} movita al la rubujo.",
"Copied": "Kopiita",
"Couldn't load patch notes. {error}": "Ne eblis ŝargi fliknotojn. {error}",
"Couldn't load releases. {error}": "Ne eblis ŝarĝi eldonojn. {error}",
"Launcher {version}": "Lanĉilo {version}",
"Launcher {version} {percent}%": "Lanĉilo {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "Elegir una imagen…",
"{source} · needed by another mod": "{source} · lo necesita otro mod",
"Delete…": "Eliminar…",
"Loading patch notes…": "Cargando notas de la versión…",
"No native mod is turned on.": "No hay ningún mod nativo activado.",
"Use these jars": "Usar estos jars",
"Starting": "Iniciando",
@@ -579,7 +578,6 @@
"Add": "Añadir",
"Clear filters": "Limpiar filtros",
"Downloads": "Descargas",
"What changed in each release, newest first.": "Qué cambió en cada versión, las más recientes primero.",
"Made the instance {name} from the shared file.": "Se creó la instancia {name} a partir del archivo compartido.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Para el juego y los comandos; también se definen INST_NAME, INST_DIR y las demás variables de la instancia.",
"copied from your folder": "copiados de tu carpeta",
@@ -668,7 +666,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "Se movió {name} a la papelera.",
"Copied": "Copiado",
"Couldn't load patch notes. {error}": "Couldn't load patch notes. {error}",
"Couldn't load releases. {error}": "Couldn't load releases. {error}",
"Launcher {version}": "Launcher {version}",
"Launcher {version} {percent}%": "Launcher {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Valige pilt…",
"{source} · needed by another mod": "{source} · vajab teine mod",
"Delete…": "Kustuta…",
"Loading patch notes…": "Paigutuste märkmete laadimine…",
"No native mod is turned on.": "Ükski algmoodul pole sisse lülitatud.",
"Use these jars": "Kasutage neid purke",
"Starting": "Käivitamine",
@@ -577,7 +576,6 @@
"Add": "Lisa",
"Clear filters": "Selge filtrid",
"Downloads": "Allalaadimised",
"What changed in each release, newest first.": "Mis igas versioonis muutus, uusim enne.",
"Made the instance {name} from the shared file.": "Tegi eksemplari {name} jagatud failist.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Mängu ja käskude jaoks; Määratud on ka INST_NAME, INST_DIR ja muud eksemplari muutujad.",
"copied from your folder": "kopeeritud teie kaustast",
@@ -666,7 +664,6 @@
"Modpacks": "Modpackid",
"{name} moved to the trash.": "{name} teisaldati prügikasti.",
"Copied": "Kopeeritud",
"Couldn't load patch notes. {error}": "Paigutuste märkmeid ei saanud laadida. {error}",
"Couldn't load releases. {error}": "Väljalaseid ei saanud laadida. {error}",
"Launcher {version}": "Käivitusprogramm {version}",
"Launcher {version} {percent}%": "Käivitusprogramm {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Aukeratu argazki bat...",
"{source} · needed by another mod": "{source} · beste mod batek behar du",
"Delete…": "Ezabatu…",
"Loading patch notes…": "Adabaki-oharrak kargatzen…",
"No native mod is turned on.": "Ez dago mod natiborik aktibatuta.",
"Use these jars": "Erabili pote hauek",
"Starting": "Hasi",
@@ -577,7 +576,6 @@
"Add": "Gehitu",
"Clear filters": "Garbitu iragazkiak",
"Downloads": "Deskargak",
"What changed in each release, newest first.": "Argitalpen bakoitzean zer aldatu den, berriena lehenik.",
"Made the instance {name} from the shared file.": "{name} instantzia egin da partekatutako fitxategitik.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Jokorako eta aginduetarako; INST_NAME, INST_DIR eta beste instantzia-aldagaiak ere ezarrita daude.",
"copied from your folder": "zure karpetatik kopiatu",
@@ -666,7 +664,6 @@
"Modpacks": "Modpackak",
"{name} moved to the trash.": "{name} zakarrontzira eraman dute.",
"Copied": "Kopiatua",
"Couldn't load patch notes. {error}": "Ezin izan dira adabaki-oharrak kargatu. {error}",
"Couldn't load releases. {error}": "Ezin izan dira kargatu bertsioak. {error}",
"Launcher {version}": "Abiarazlea {version}",
"Launcher {version} {percent}%": "Abiarazlea {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "انتخاب عکس…",
"{source} · needed by another mod": "{source} · مورد نیاز یک مد دیگر",
"Delete…": "حذف…",
"Loading patch notes…": "در حال بارگیری یادداشت‌های وصله…",
"No native mod is turned on.": "هیچ حالت بومی روشن نیست.",
"Use these jars": "از این شیشه ها استفاده کنید",
"Starting": "شروع کردن",
@@ -579,7 +578,6 @@
"Add": "اضافه کنید",
"Clear filters": "فیلترها را پاک کنید",
"Downloads": "دانلودها",
"What changed in each release, newest first.": "آنچه در هر نسخه تغییر کرد، ابتدا جدیدترین.",
"Made the instance {name} from the shared file.": "نمونه {name} را از فایل مشترک ساخته شد.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "برای بازی و دستورات؛ INST_NAME، INST_DIR و سایر متغیرهای نمونه نیز تنظیم شده‌اند.",
"copied from your folder": "از پوشه شما کپی شده است",
@@ -668,7 +666,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "{name} به سطل زباله منتقل شد.",
"Copied": "کپی شده است",
"Couldn't load patch notes. {error}": "یادداشت‌های وصله بارگیری نشدند. {error}",
"Couldn't load releases. {error}": "انتشارات بارگیری نشد. {error}",
"Launcher {version}": "لانچر {version}",
"Launcher {version} {percent}%": "لانچر {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "Valitse kuva…",
"{source} · needed by another mod": "{source} · tarvitaan toiselle modille",
"Delete…": "Poista…",
"Loading patch notes…": "Ladataan korjausmuistiinpanoja…",
"No native mod is turned on.": "Mitään alkuperäistä modia ei ole kytketty päälle.",
"Use these jars": "Käytä näitä purkkeja",
"Starting": "Alkaa",
@@ -579,7 +578,6 @@
"Add": "Lisää",
"Clear filters": "Selkeät suodattimet",
"Downloads": "Lataukset",
"What changed in each release, newest first.": "Mikä muuttui jokaisessa julkaisussa, uusin ensin.",
"Made the instance {name} from the shared file.": "Teki ilmentymän {name} jaetusta tiedostosta.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Peliä ja komentoja varten; INST_NAME, INST_DIR ja muut ilmentymämuuttujat on myös asetettu.",
"copied from your folder": "kopioitu kansiostasi",
@@ -668,7 +666,6 @@
"Modpacks": "Modpackit",
"{name} moved to the trash.": "{name} siirretty roskakoriin.",
"Copied": "Kopioitu",
"Couldn't load patch notes. {error}": "Korjausmuistiinpanoja ei voitu ladata. {error}",
"Couldn't load releases. {error}": "Julkaisuja ei voitu ladata. {error}",
"Launcher {version}": "Käynnistysohjelma {version}",
"Launcher {version} {percent}%": "Käynnistysohjelma {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Pumili ng larawan…",
"{source} · needed by another mod": "{source} · kailangan ng ibang mod",
"Delete…": "Tanggalin…",
"Loading patch notes…": "Nilo-load ang mga patch notes...",
"No native mod is turned on.": "Walang naka-on na native mod.",
"Use these jars": "Gamitin ang mga garapon na ito",
"Starting": "Nagsisimula",
@@ -577,7 +576,6 @@
"Add": "Idagdag",
"Clear filters": "I-clear ang mga filter",
"Downloads": "Mga download",
"What changed in each release, newest first.": "Ano ang nagbago sa bawat release, pinakabago muna.",
"Made the instance {name} from the shared file.": "Ginawa ang instance na {name} mula sa nakabahaging file.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Para sa laro at mga utos; Ang INST_NAME, INST_DIR at ang iba pang mga variable ng instance ay nakatakda rin.",
"copied from your folder": "kinopya mula sa iyong folder",
@@ -666,7 +664,6 @@
"Modpacks": "Mga Modpack",
"{name} moved to the trash.": "{name} inilipat sa basurahan.",
"Copied": "Kinopya",
"Couldn't load patch notes. {error}": "Hindi ma-load ang mga patch notes. {error}",
"Couldn't load releases. {error}": "Hindi ma-load ang mga release. {error}",
"Launcher {version}": "Launcher {version}",
"Launcher {version} {percent}%": "Launcher {version} {percent}%",
-3
View File
@@ -243,7 +243,6 @@
"Choose a picture…": "Vel eina mynd...",
"{source} · needed by another mod": "{source} · tørvur á einum øðrum mod",
"Delete…": "Strika...",
"Loading patch notes…": "Heinta plásturnotur...",
"No native mod is turned on.": "Eingin innføddur mod er tendraður.",
"Use these jars": "Brúka hesar krukkurnar",
"Starting": "Byrjað verður",
@@ -577,7 +576,6 @@
"Add": "Legg afturat",
"Clear filters": "Rudda filtur",
"Downloads": "Niðurtøkur",
"What changed in each release, newest first.": "Hvat broyttist í hvørjari útgávu, nýggjasta fyrst.",
"Made the instance {name} from the shared file.": "Gert instansina {name} úr deildu fíluni.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Fyri leikin og skipanirnar; INST_NAME, INST_DIR og hinar instansbreytirnar eru eisini settar.",
"copied from your folder": "avritað úr tínari mappu",
@@ -666,7 +664,6 @@
"Modpacks": "Modpakkar",
"{name} moved to the trash.": "{name} flutt í ruskspannina.",
"Copied": "Avritað",
"Couldn't load patch notes. {error}": "Kundi ikki heinta plásturseðlar. {error}",
"Couldn't load releases. {error}": "Kundi ikki heinta útgávur. {error}",
"Launcher {version}": "Sjósetari {version}",
"Launcher {version} {percent}%": "Sjósetari {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "Choisir une image…",
"{source} · needed by another mod": "{source} · requis par un autre mod",
"Delete…": "Supprimer…",
"Loading patch notes…": "Chargement des notes de version…",
"No native mod is turned on.": "Aucun mod natif n'est activé.",
"Use these jars": "Utiliser ces jars",
"Starting": "Démarrage",
@@ -579,7 +578,6 @@
"Add": "Ajouter",
"Clear filters": "Effacer les filtres",
"Downloads": "Téléchargements",
"What changed in each release, newest first.": "Ce qui a changé dans chaque version, de la plus récente à la plus ancienne.",
"Made the instance {name} from the shared file.": "L'instance {name} a été créée à partir du fichier partagé.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Pour le jeu et les commandes ; INST_NAME, INST_DIR et les autres variables d'instance sont aussi définies.",
"copied from your folder": "copié depuis votre dossier",
@@ -668,7 +666,6 @@
"Modpacks": "Modpacks",
"{name} moved to the trash.": "{name} a été mis à la corbeille.",
"Copied": "Copié",
"Couldn't load patch notes. {error}": "Impossible de charger les notes de mise à jour. {error}",
"Couldn't load releases. {error}": "Impossible de charger les versions. {error}",
"Launcher {version}": "Lanceur {version}",
"Launcher {version} {percent}%": "Lanceur {version} {percent}%",
-3
View File
@@ -244,7 +244,6 @@
"Choose a picture…": "Sielzi une fotografie...",
"{source} · needed by another mod": "{source} · necessari di un altri mod",
"Delete…": "Elimina...",
"Loading patch notes…": "Caricant notis di corezion...",
"No native mod is turned on.": "Nissun mod natîf al è accedût.",
"Use these jars": "Utilizâ chescj vascj",
"Starting": "Scomençant",
@@ -579,7 +578,6 @@
"Add": "Azontâ",
"Clear filters": "Cancelâ i filtris",
"Downloads": "Scarichi",
"What changed in each release, newest first.": "Ce che al cambià in ogni publicazion, il plui gnûf prin.",
"Made the instance {name} from the shared file.": "Fat l’esempli {name} dal file condividût.",
"For the game and the commands; INST_NAME, INST_DIR and the other instance variables are set too.": "Par il zûc e i comants; Ancje INST_NOME, INST_DIR e lis altris variabilis di esempli a son impostadis.",
"copied from your folder": "copiât de sô cjarte",
@@ -668,7 +666,6 @@
"Modpacks": "Pachetis mod",
"{name} moved to the trash.": "{name} spostât tal spazi vueit.",
"Copied": "Copiât",
"Couldn't load patch notes. {error}": "No si è rivât a cjargjâ lis notis di corezion. {error}",
"Couldn't load releases. {error}": "No si è rivât a cjargjâ lis versions. {error}",
"Launcher {version}": "Lançadôr {version}",
"Launcher {version} {percent}%": "Lançadôr {version} {percent}%",
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more