mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Merge remote-tracking branch 'origin/main' into fix/wool-vibration-drops
This commit is contained in:
commit
2f88719668
175 files changed
+5140
-823
No files matched your search
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: ®istries,
|
||||
};
|
||||
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: ®istries,
|
||||
};
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
®istry,
|
||||
);
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]]);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(§ion.palette, §ion.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, ®istry).unwrap();
|
||||
let mut reference = BlockSection::uniform(air, ®istry).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(§ion, &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(§ion, &reference, &what);
|
||||
if step % 16 == 0 {
|
||||
assert_blocks(§ion, &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(§ion, &reference, "collapsed");
|
||||
assert_blocks(§ion, &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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Generated
+13
-2
@@ -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"
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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}%",
|
||||
|
||||
@@ -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
Reference in new issue
Block a user