mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a86761edf | ||
|
|
60e38c57b5 | ||
|
|
320186c089 | ||
|
|
4ffac208e1 | ||
|
|
40f82d0e99 | ||
|
|
bc08885276 | ||
|
|
bc7b8889de | ||
|
|
7180a52bf2 | ||
|
|
8e37c4a4b7 | ||
|
|
94ba80f90d | ||
|
|
238fc99add | ||
|
|
ac96d3fa35 | ||
|
|
ceff25fca1 | ||
|
|
36d0695742 | ||
|
|
94484675a8 | ||
|
|
9097d2973d | ||
|
|
00e573cf87 | ||
|
|
8d9b80c420 | ||
|
|
2b3b2fe7e5 | ||
|
|
7433b49bef | ||
|
|
edeb6978b9 | ||
|
|
5176dc5a75 | ||
|
|
9f5ed29593 | ||
|
|
afdfc8bfa1 | ||
|
|
13c366a85b | ||
|
|
763bc981f0 | ||
|
|
b7bf458117 | ||
|
|
3dcfd7276b | ||
|
|
bfbf06a439 | ||
|
|
971f341977 | ||
|
|
74f52d0782 | ||
|
|
23f0abf4d2 | ||
|
|
4958be7ce0 | ||
|
|
9d70e670c8 | ||
|
|
5062adfb0c | ||
|
|
195ecc898e | ||
|
|
21aabee4c0 | ||
|
|
7be13f1e9a | ||
|
|
ff83eccf6a | ||
|
|
7aa6f0fda0 | ||
|
|
63408fc7f0 | ||
|
|
b5f396461e | ||
|
|
e963d24058 | ||
|
|
3eec96a8b1 | ||
|
|
249e8db13c | ||
|
|
c992f24496 | ||
|
|
005249b984 | ||
|
|
eb3157d251 | ||
|
|
d577d49a78 | ||
|
|
8a9bd15a3d | ||
|
|
a40f3d82c1 | ||
|
|
af3c22b9f1 | ||
|
|
182eec2cb7 | ||
|
|
5aca6c42ee | ||
|
|
4abfa15d97 | ||
|
|
37bdbf9def | ||
|
|
dd6ad03eea | ||
|
|
67d691b1e3 | ||
|
|
9a0d252abf | ||
|
|
59d958c09e | ||
|
|
1a2015e305 | ||
|
|
197172ef3c | ||
|
|
9d6d292348 | ||
|
|
55b78015fe | ||
|
|
cb1198a38d | ||
|
|
17b50d7959 | ||
|
|
358456bb02 | ||
|
|
aa741b3570 | ||
|
|
34b5df1397 | ||
|
|
72c6784172 | ||
|
|
443a42f97a | ||
|
|
230b167744 | ||
|
|
c71978372d | ||
|
|
2a44a86383 | ||
|
|
de9ab4646d | ||
|
|
2d2a2621f2 | ||
|
|
e0b91d71bf |
No files matched your search
Generated
+2
@@ -1797,6 +1797,7 @@ dependencies = [
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-platform",
|
||||
"horizon-telemetry",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
@@ -1970,6 +1971,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -7,7 +7,8 @@ rust-version.workspace = true
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
|
||||
# The crates above the engine: their settings stores (docs/layers.md, "Platform registries"),
|
||||
# and the hooks that hear each crash (`crash.rs`).
|
||||
horizon-hooks = { path = "../horizon-hooks" }
|
||||
horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
|
||||
|
||||
@@ -204,6 +204,23 @@ impl Report {
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer's adapter ([`crate::gfx::adapter`]), for the hooks above the engine.
|
||||
pub fn gpu() -> Option<horizon_hooks::Gpu> {
|
||||
crate::gfx::adapter().map(|a| horizon_hooks::Gpu {
|
||||
name: a.name,
|
||||
vendor: a.vendor,
|
||||
kind: a.kind,
|
||||
driver: a.driver,
|
||||
backend: a.backend,
|
||||
})
|
||||
}
|
||||
|
||||
/// The build, for what leaves the machine: the release (`v0.2.3`), or `dev <crate version>` for a
|
||||
/// build from source.
|
||||
pub fn build() -> String {
|
||||
release_tag().unwrap_or_else(|| format!("dev {}", env!("CARGO_PKG_VERSION")))
|
||||
}
|
||||
|
||||
/// The release this binary is (`v0.2.3`); a nightly's label (`Nightly 2026-10-28 · 0c02067`); for a
|
||||
/// release from before 0.2, its tag and the client's crate version; for a build from source, that
|
||||
/// version alone.
|
||||
@@ -383,19 +400,20 @@ fn thread_name(side: &str) -> String {
|
||||
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
|
||||
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
|
||||
|
||||
/// What runs on each report before it is saved ([`on_report`]).
|
||||
pub type ReportHook = fn(&Path, &Report);
|
||||
|
||||
static REPORT_HOOKS: std::sync::RwLock<Vec<ReportHook>> = std::sync::RwLock::new(Vec::new());
|
||||
|
||||
/// Runs `hook` with every crash report, before it is saved: the game folder and the report. An
|
||||
/// extension point for what lives above the engine (Horizon's opt-in telemetry keeps the
|
||||
/// report). A hook runs inside the crash handler: it must not panic or block.
|
||||
pub fn on_report(hook: ReportHook) {
|
||||
REPORT_HOOKS
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(hook);
|
||||
/// The hooks above the engine hear the report before it is saved (`Hooks::crashed`; Horizon's
|
||||
/// opt-in telemetry keeps it). They run inside the crash handler: they must not panic or block.
|
||||
fn tell_hooks(home: &Path, report: &Report) {
|
||||
let version = build();
|
||||
let gpu = gpu();
|
||||
horizon_hooks::engine::crashed(&horizon_hooks::Crash {
|
||||
home,
|
||||
version: &version,
|
||||
error: &report.error,
|
||||
stack: &report.stack,
|
||||
thread: &report.thread,
|
||||
details: &report.details,
|
||||
gpu: gpu.as_ref(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Writes the report and tells where it is; the log gets the error too.
|
||||
@@ -418,13 +436,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
),
|
||||
);
|
||||
crate::logging::flush();
|
||||
for hook in REPORT_HOOKS
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
{
|
||||
hook(home, &report);
|
||||
}
|
||||
tell_hooks(home, &report);
|
||||
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
|
||||
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
|
||||
Ok(path) => {
|
||||
|
||||
@@ -231,8 +231,8 @@ pub struct FogInputs<'a> {
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
/// The packs' Polytone `environment` fog settings.
|
||||
pub environment: horizon_polytone::colors::Environment,
|
||||
/// The packs' `environment` fog settings (Polytone's).
|
||||
pub environment: crate::pack_palette::Environment,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -581,7 +581,7 @@ mod tests {
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let environment = horizon_polytone::colors::Environment {
|
||||
let environment = crate::pack_palette::Environment {
|
||||
powder_snow_fog: Some(0xFF0000),
|
||||
blindness_fog_distance: 40.0,
|
||||
water_fog_brightening: 0.0,
|
||||
@@ -613,7 +613,7 @@ mod tests {
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: horizon_polytone::colors::Environment,
|
||||
environment: crate::pack_palette::Environment,
|
||||
) -> FogInputs<'a> {
|
||||
FogInputs {
|
||||
environment,
|
||||
|
||||
@@ -239,6 +239,7 @@ mod reuse;
|
||||
mod stars;
|
||||
mod surface_setup;
|
||||
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
|
||||
mod off_page;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
@@ -250,6 +251,7 @@ mod wgsl_prefix;
|
||||
mod world_post;
|
||||
pub mod world_primitives;
|
||||
mod world_warp;
|
||||
use off_page::move_off_page;
|
||||
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
@@ -726,62 +728,6 @@ impl Part {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Starts moving a page of a large arena far larger than its ranges in use
|
||||
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
|
||||
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
|
||||
@@ -10116,7 +10062,11 @@ impl Renderer {
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
|
||||
// after the world pass, as `GameRenderer.render` hands them the main target's.
|
||||
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
|
||||
let keep_depth = pack_reads_world
|
||||
|| post_reads_depth
|
||||
|| (taa && self.world_post.taa)
|
||||
|| oit_on
|
||||
|| native_render::render_needs().world_depth
|
||||
@@ -13714,6 +13664,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 240,
|
||||
peak: 998,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -13721,6 +13672,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 480,
|
||||
peak: 480,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
|
||||
@@ -13729,6 +13681,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 456,
|
||||
peak: 456,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
|
||||
@@ -15419,11 +15372,9 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
|
||||
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
|
||||
});
|
||||
}
|
||||
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
|
||||
/// white level the brightness slider sets), which is HDR output's scale there: SDR
|
||||
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
|
||||
/// the settings' ranges. The OS's figures often come from the display's EDID and may
|
||||
/// run optimistic; the player's own value always wins.
|
||||
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
|
||||
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
|
||||
let luminance = info.luminance?;
|
||||
let usable = |n: f32| n.is_finite() && n > 0.0;
|
||||
@@ -15435,6 +15386,7 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
|
||||
// Windows' SDR brightness slider can set white above the reported peak (480 against
|
||||
// a calibrated 380): the highlights then stay at white instead of dropping below it.
|
||||
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
|
||||
peak_guessed: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15470,6 +15422,7 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
crate::graphics::Reported {
|
||||
paper_white: 203,
|
||||
peak: peak.clamp(200, 10_000),
|
||||
peak_guessed: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `move_off_page`, moved out of `gfx.rs` unchanged.
|
||||
use super::*;
|
||||
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
pub(super) fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -300,6 +300,7 @@ pub(crate) mod tests {
|
||||
luminance: Some(Reported {
|
||||
paper_white: 400,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
}),
|
||||
hdr: system,
|
||||
},
|
||||
@@ -650,6 +651,7 @@ pub(crate) mod tests {
|
||||
window_reading.luminance = Some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 800,
|
||||
peak_guessed: false,
|
||||
});
|
||||
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
|
||||
assert_eq!(
|
||||
|
||||
@@ -549,6 +549,7 @@ mod tests {
|
||||
reading.luminance = Some(Reported {
|
||||
paper_white: 250,
|
||||
peak: 600,
|
||||
peak_guessed: false,
|
||||
});
|
||||
// 150 nits under the reported paper white of 250: 0.6 of it.
|
||||
settings.hdr_ui_nits = 150;
|
||||
@@ -584,6 +585,7 @@ mod tests {
|
||||
reading.luminance = Some(Reported {
|
||||
paper_white,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
});
|
||||
let next = candidate(&settings, caps, reading, &published);
|
||||
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
|
||||
@@ -729,6 +731,7 @@ mod tests {
|
||||
luminance: (hdr == Some(true)).then_some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 800,
|
||||
peak_guessed: false,
|
||||
}),
|
||||
hdr,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,22 @@ fn marker_driver(text: &str) -> &str {
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
|
||||
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
|
||||
pub peak_guessed: bool,
|
||||
}
|
||||
|
||||
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HdrSetup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// The system says, but only guesses the peak: the player calibrates.
|
||||
Guessed,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
|
||||
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
|
||||
@@ -1207,6 +1223,15 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// How HDR is set up for the display the system `reported`.
|
||||
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
|
||||
match reported {
|
||||
None => HdrSetup::Unknown,
|
||||
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
|
||||
Some(r) if r.peak_guessed => HdrSetup::Guessed,
|
||||
Some(_) => HdrSetup::Automatic,
|
||||
}
|
||||
}
|
||||
/// The black level, in nits. Not the system's: files from before saved 0 for that
|
||||
/// (Auto), and a reported black would lift every display's shadows by default.
|
||||
pub fn black_level_nits(&self) -> f32 {
|
||||
@@ -1320,6 +1345,7 @@ mod tests {
|
||||
let reported = Some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
});
|
||||
assert_eq!(g.ui_nits(reported), 300);
|
||||
g.hdr_paper_white = 120;
|
||||
@@ -1328,6 +1354,33 @@ mod tests {
|
||||
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
|
||||
let wayland = Reported {
|
||||
paper_white: 203,
|
||||
peak: 1015,
|
||||
peak_guessed: false,
|
||||
};
|
||||
let windows = Reported {
|
||||
paper_white: 400,
|
||||
peak: 1000,
|
||||
peak_guessed: true,
|
||||
};
|
||||
let mut g = Graphics::default();
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
|
||||
// Auto still takes the guess, over the 1000-nit default.
|
||||
assert_eq!(g.peak_nits(Some(windows)), 1000);
|
||||
assert_eq!(g.paper_white_nits(Some(windows)), 400);
|
||||
for (peak, paper_white) in [(800, 0), (0, 240)] {
|
||||
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
|
||||
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
|
||||
|
||||
@@ -8,7 +8,8 @@ use crate::{
|
||||
gfx::EffectiveOutput,
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
|
||||
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
|
||||
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
|
||||
Preset, Upscaling,
|
||||
},
|
||||
graphics_style::Cost,
|
||||
lang,
|
||||
@@ -675,28 +676,9 @@ const LOD_STEPS: [u32; 24] = [
|
||||
crate::lod::MAX_CHUNKS,
|
||||
];
|
||||
|
||||
/// Whether the system set HDR up for the display, and whether the player kept it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Setup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
|
||||
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
|
||||
setup_of(&s.horizon, out.reported.is_some())
|
||||
}
|
||||
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
|
||||
if !reported {
|
||||
Setup::Unknown
|
||||
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
|
||||
Setup::Automatic
|
||||
} else {
|
||||
Setup::Changed
|
||||
}
|
||||
s.horizon.hdr_setup(out.reported)
|
||||
}
|
||||
/// What the HDR page says while HDR output is held back on this window's graphics driver
|
||||
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
|
||||
@@ -714,13 +696,19 @@ fn description(
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
|
||||
"Your display didn't say how bright it gets: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
|
||||
"Puts Peak Brightness and Paper White back to your display's setup."
|
||||
}
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
|
||||
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
|
||||
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
|
||||
{
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
|
||||
@@ -944,7 +932,7 @@ impl H {
|
||||
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
|
||||
H::Hdr => labelled("HDR", g.hdr.label()),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
|
||||
Setup::Unknown => "Calibrate...",
|
||||
Setup::Unknown | Setup::Guessed => "Calibrate...",
|
||||
Setup::Automatic => "Calibrate Anyway...",
|
||||
Setup::Changed => "Use Automatic Setup",
|
||||
})),
|
||||
|
||||
@@ -120,14 +120,86 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
|
||||
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
|
||||
}
|
||||
|
||||
/// The setup rows' label and description for each kind of report: none, a compositor's
|
||||
/// (Wayland), and Windows', whose peak is the display's own guess.
|
||||
#[test]
|
||||
fn hdr_setup_follows_what_the_system_reported() {
|
||||
let mut g = crate::graphics::Graphics::default();
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
assert_eq!(setup_of(&g, true), Setup::Automatic);
|
||||
g.hdr_paper_white = 80;
|
||||
assert_eq!(setup_of(&g, true), Setup::Changed);
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
let trusted = offering(true, Some(true));
|
||||
let mut guessed = trusted;
|
||||
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
|
||||
peak_guessed: true,
|
||||
..r
|
||||
});
|
||||
let rows = |s: &Settings, out: &EffectiveOutput| {
|
||||
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
|
||||
let item = Item::Horizon(h);
|
||||
(item.label(s, out).plain(), description(item, s, out, "-"))
|
||||
})
|
||||
};
|
||||
let calibrate = "Calibrate...";
|
||||
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let none = (
|
||||
calibrate.into(),
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &NONE),
|
||||
[
|
||||
none.clone(),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (203)".into(), "-"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &guessed),
|
||||
[
|
||||
(
|
||||
calibrate.into(),
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (400)".into(), "-"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &trusted),
|
||||
[
|
||||
(
|
||||
"Calibrate Anyway...".into(),
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), set_up),
|
||||
("Paper White: Auto (400)".into(), set_up),
|
||||
]
|
||||
);
|
||||
// Calibrated or set by hand, either report offers its automatic setup back.
|
||||
s.horizon.hdr_peak = 800;
|
||||
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
|
||||
let changed = |peak| {
|
||||
[
|
||||
(
|
||||
"Use Automatic Setup".into(),
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
),
|
||||
("Peak Brightness: 800".into(), peak),
|
||||
("Paper White: Auto (400)".into(), set_up),
|
||||
]
|
||||
};
|
||||
assert_eq!(rows(&s, &guessed), changed("-"));
|
||||
assert_eq!(rows(&s, &trusted), changed(set_up));
|
||||
assert_eq!(rows(&s, &NONE)[0], none);
|
||||
let mut ui = ui();
|
||||
ui.screen.output = guessed;
|
||||
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
|
||||
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
|
||||
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(calibrate)),
|
||||
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
|
||||
);
|
||||
}
|
||||
|
||||
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
|
||||
|
||||
@@ -67,7 +67,6 @@ mod jei_sprites;
|
||||
pub mod jigsaw_block;
|
||||
pub mod keypad;
|
||||
pub mod terrain_model;
|
||||
pub use horizon_polytone::colors as polytone_colors;
|
||||
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
|
||||
pub use horizon_world::json_folder;
|
||||
pub mod level_loading;
|
||||
|
||||
@@ -2593,8 +2593,7 @@ impl Lod {
|
||||
self.returned_at = std::time::Instant::now();
|
||||
let nodes = &self.plan.nodes;
|
||||
self.mesh_samples.retain(|key| nodes.contains(key));
|
||||
// Queued tiles the view left are not sampled (a running one is
|
||||
// dropped when it finishes, `finish_sample`).
|
||||
// Queued tiles the view left are not sampled; a running one is dropped (`finish_sample`).
|
||||
drop_queued_samples(&self.shared, &mut self.pending, |key| {
|
||||
self.plan.nodes.contains(key)
|
||||
});
|
||||
@@ -2680,6 +2679,7 @@ impl Lod {
|
||||
}
|
||||
if let Some((columns, mask)) = self.kept.take(&key) {
|
||||
self.ready.insert(key, columns);
|
||||
self.sync_sample(key);
|
||||
if let Some(mask) = mask {
|
||||
self.detail_masks.insert(key, mask);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Polytone's custom colours (`horizon_polytone::colors`, `colors.json` or `color.properties`)
|
||||
//! as the engine's pack colours ([`crate::pack_palette::PackColors`]).
|
||||
use std::path::Path;
|
||||
|
||||
use horizon_polytone::colors::PackColors;
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::pack_palette::{self, Environment};
|
||||
|
||||
/// The colours of every namespace's file under the pack overlay at `root`.
|
||||
pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
let colors = PackColors::read(root);
|
||||
let by_dye = |f: &dyn Fn(&str) -> Option<u32>| std::array::from_fn(|i| f(DYES[i].0));
|
||||
let env = colors.env;
|
||||
pack_palette::PackColors {
|
||||
dye_diffuse: by_dye(&|name| colors.dye(name, "diffuse")),
|
||||
dye_text: by_dye(&|name| colors.dye(name, "text")),
|
||||
sheep: by_dye(&|name| colors.sheep(name)),
|
||||
redstone: std::array::from_fn(|i| colors.redstone(i as u8)),
|
||||
effects: colors.effects(),
|
||||
entity_shadows: colors.entity_shadows(),
|
||||
xp_bar: colors.xp_bar(),
|
||||
env: Environment {
|
||||
powder_snow_fog: env.powder_snow_fog,
|
||||
blindness_fog_distance: env.blindness_fog_distance,
|
||||
darkness_fog_distance: env.darkness_fog_distance,
|
||||
boss_fog_distance: env.boss_fog_distance,
|
||||
water_fog_brightening: env.water_fog_brightening,
|
||||
water_fog_brightening_time: env.water_fog_brightening_time,
|
||||
horizon_height: env.horizon_height,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pack_palette::{diffuse, redstone, sheep_wool, text};
|
||||
|
||||
#[test]
|
||||
fn a_pack_recolours_only_what_it_names() {
|
||||
// Dyes no other test draws: the table is process-wide.
|
||||
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
|
||||
let polytone = dir.join("assets/obs/polytone");
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
crate::pack_palette::set(&read(&dir));
|
||||
let (cyan, pink, purple) = (9, 6, 10);
|
||||
assert_eq!(diffuse(cyan), 0x00ff00);
|
||||
assert_eq!(text(cyan), 0x0000ff);
|
||||
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
|
||||
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
crate::pack_palette::set(&Default::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Polytone's item modifiers' durability bar colours, on the engine's
|
||||
//! [`crate::pack_palette::ItemBars`].
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
|
||||
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
|
||||
struct DamageBar {
|
||||
targets: horizon_polytone::Targets,
|
||||
row: Vec<u32>,
|
||||
}
|
||||
|
||||
/// The packs' damage bars, the last read first.
|
||||
struct Bars(Vec<DamageBar>);
|
||||
|
||||
impl crate::pack_palette::ItemBars for Bars {
|
||||
fn color(&self, item: &str, damage: f32) -> Option<u32> {
|
||||
let bar = self
|
||||
.0
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.targets.matches(item, |_| false))?;
|
||||
let last = (bar.row.len() - 1) as f32;
|
||||
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
|
||||
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
|
||||
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read. `None` without any.
|
||||
pub fn read(root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
let mut bars = Vec::new();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let base = namespace.path().join("polytone/item_modifiers");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Some(bar) = item_bar(root, &base, &path) {
|
||||
bars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(!bars.is_empty()).then(|| Arc::new(Bars(bars)) as _)
|
||||
}
|
||||
|
||||
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let row = |png: &Path| {
|
||||
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
|
||||
let row: Vec<u32> = rgba[..width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
|
||||
.collect();
|
||||
(!row.is_empty()).then_some(row)
|
||||
};
|
||||
if let Some(stem) = name.strip_suffix("_bar.png") {
|
||||
// A json of the same name owns its png.
|
||||
if path.with_file_name(format!("{stem}.json")).exists() {
|
||||
return None;
|
||||
}
|
||||
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
|
||||
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
|
||||
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
|
||||
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
|
||||
return Some(DamageBar {
|
||||
targets,
|
||||
row: row(path)?,
|
||||
});
|
||||
}
|
||||
let stem = name.strip_suffix(".json")?;
|
||||
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
|
||||
if json.get("bar_color")?.as_str()? != "damage" {
|
||||
return None;
|
||||
}
|
||||
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
|
||||
.into_iter()
|
||||
.map(|png| path.with_file_name(png))
|
||||
.find(|png| png.exists())?;
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
|
||||
let targets =
|
||||
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
|
||||
Some(DamageBar {
|
||||
targets,
|
||||
row: row(&png)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
let data: Vec<u8> = pixels
|
||||
.iter()
|
||||
.flat_map(|p| [p[0], p[1], p[2], 255])
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&data)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
|
||||
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
|
||||
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
|
||||
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
|
||||
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
|
||||
std::fs::write(
|
||||
modifiers.join("tools.json"),
|
||||
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
|
||||
let bars = read(&dir).unwrap();
|
||||
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
|
||||
assert_eq!(
|
||||
bars.color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
|
||||
Some(0xFF0000)
|
||||
);
|
||||
assert_eq!(
|
||||
bars.color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
|
||||
Some(0xFFFF00)
|
||||
);
|
||||
assert_eq!(bars.color("minecraft:iron_axe", 0.9), Some(0x040506));
|
||||
assert_eq!(bars.color("minecraft:diamond_shovel", 0.5), None);
|
||||
assert!(read(&dir.join("none")).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
|
||||
//! moves to a platform crate unchanged, and nothing else in the crate names it.
|
||||
|
||||
mod colors;
|
||||
mod custom_sky;
|
||||
mod item_bars;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -14,10 +16,18 @@ use crate::pack_hooks::PackFormats;
|
||||
pub struct Formats;
|
||||
|
||||
impl PackFormats for Formats {
|
||||
fn colors(&self, root: &Path) -> Option<crate::pack_palette::PackColors> {
|
||||
Some(colors::read(root))
|
||||
}
|
||||
|
||||
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
let sky = custom_sky::CustomSky::load(root);
|
||||
(!sky.is_empty()).then(|| Arc::new(sky) as _)
|
||||
}
|
||||
|
||||
fn item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
item_bars::read(root)
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
|
||||
|
||||
@@ -10,10 +10,20 @@ use std::sync::{Arc, OnceLock};
|
||||
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
|
||||
/// (its folder at `root`); the default is "nothing in this format".
|
||||
pub trait PackFormats: Send + Sync {
|
||||
/// The colours the overlay replaces where vanilla hardcodes them.
|
||||
fn colors(&self, _root: &Path) -> Option<crate::pack_palette::PackColors> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The sky layers the overlay draws over the vanilla sky, if it has any.
|
||||
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The durability bar colours the overlay sets by item, if any.
|
||||
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// No format beyond vanilla's.
|
||||
|
||||
@@ -1,14 +1,87 @@
|
||||
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
|
||||
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
|
||||
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
|
||||
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
|
||||
//! The colours a pack replaces where vanilla hardcodes them (Polytone's `colors.json`, read by the
|
||||
//! platform's pack formats, [`crate::pack_hooks`]): dye, sheep and redstone colours, for drawing
|
||||
//! only (the pack recolours collars, tropical fish, banners, wool, sign text and redstone wire and
|
||||
//! dust, while gameplay keeps vanilla's `DyeColor`), mob effect colours, the experience number's,
|
||||
//! entity shadows, the effect fogs' settings and durability bar colours. Lock-free: the draw paths
|
||||
//! and the mesher's workers read the dye, sheep and redstone tables per face.
|
||||
use std::path::Path;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::signs::DYE_TEXT;
|
||||
use horizon_polytone::colors::PackColors;
|
||||
|
||||
/// The fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Environment {
|
||||
/// The fog colour inside powder snow (RGB).
|
||||
pub powder_snow_fog: Option<u32>,
|
||||
/// The fog ends of Blindness (5), Darkness (15) and bosses that add world fog (96).
|
||||
pub blindness_fog_distance: f32,
|
||||
pub darkness_fog_distance: f32,
|
||||
pub boss_fog_distance: f32,
|
||||
/// How much the underwater fog colour washes out (1 vanilla, 0 none).
|
||||
pub water_fog_brightening: f32,
|
||||
/// The ticks under water it takes (vanilla's 600).
|
||||
pub water_fog_brightening_time: f32,
|
||||
/// Below it the sky draws its dark disc (vanilla's 63; flat worlds keep their floor).
|
||||
pub horizon_height: f32,
|
||||
}
|
||||
|
||||
impl Default for Environment {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
powder_snow_fog: None,
|
||||
blindness_fog_distance: 5.0,
|
||||
darkness_fog_distance: 15.0,
|
||||
boss_fog_distance: 96.0,
|
||||
water_fog_brightening: 1.0,
|
||||
water_fog_brightening_time: 600.0,
|
||||
horizon_height: 63.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs' colours; `None` (and the defaults) where they keep vanilla's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PackColors {
|
||||
/// By dye id (`DyeColor` order): `getTextureDiffuseColor`, `getTextColor`, the sheep's wool.
|
||||
pub dye_diffuse: [Option<u32>; 16],
|
||||
pub dye_text: [Option<u32>; 16],
|
||||
pub sheep: [Option<u32>; 16],
|
||||
/// `RedStoneWireBlock.COLORS` by power.
|
||||
pub redstone: [Option<u32>; 16],
|
||||
/// Mob effects' colours, by effect id without vanilla's namespace.
|
||||
pub effects: Vec<(String, u32)>,
|
||||
/// False hides every entity's round shadow, whatever the video option says.
|
||||
pub entity_shadows: bool,
|
||||
/// The experience level number's colour and outline.
|
||||
pub xp_bar: [Option<u32>; 2],
|
||||
/// The effect fogs' settings: the fog reads them every frame.
|
||||
pub env: Environment,
|
||||
}
|
||||
|
||||
impl Default for PackColors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dye_diffuse: [None; 16],
|
||||
dye_text: [None; 16],
|
||||
sheep: [None; 16],
|
||||
redstone: [None; 16],
|
||||
effects: Vec::new(),
|
||||
entity_shadows: true,
|
||||
xp_bar: [None; 2],
|
||||
env: Environment::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackColors {
|
||||
/// Whether entities draw their round shadow, as far as the packs say.
|
||||
pub fn entity_shadows(&self) -> bool {
|
||||
self.entity_shadows
|
||||
}
|
||||
}
|
||||
|
||||
/// 0 is unset, else `1 << 32 | rgb`.
|
||||
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
@@ -16,6 +89,30 @@ static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
|
||||
/// The colours of the pack overlay at `root`, from the installed pack formats; their dye, sheep
|
||||
/// and redstone colours go to the draw paths' tables ([`set`]).
|
||||
pub fn read(root: &Path) -> PackColors {
|
||||
let formats = crate::pack_hooks::formats();
|
||||
let colors = formats.colors(root).unwrap_or_default();
|
||||
set(&colors);
|
||||
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = formats.item_bars(root);
|
||||
colors
|
||||
}
|
||||
|
||||
/// The packs' durability bar colours by item (Polytone's item modifiers' `bar_color`).
|
||||
pub trait ItemBars: Send + Sync {
|
||||
/// The bar colour (RGB) of `item` at this damage (0 to 1), when a pack sets one.
|
||||
fn color(&self, item: &str, damage: f32) -> Option<u32>;
|
||||
}
|
||||
|
||||
static ITEM_BARS: RwLock<Option<Arc<dyn ItemBars>>> = RwLock::new(None);
|
||||
|
||||
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
|
||||
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
|
||||
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
|
||||
bars.as_ref()?.color(item, damage)
|
||||
}
|
||||
|
||||
/// Takes the colours of the packs just loaded (vanilla's where they set none).
|
||||
pub fn set(colors: &PackColors) {
|
||||
let store = |table: &[AtomicU64; 16], i: usize, color: Option<u32>| {
|
||||
@@ -24,129 +121,39 @@ pub fn set(colors: &PackColors) {
|
||||
Relaxed,
|
||||
);
|
||||
};
|
||||
for (i, (name, _)) in DYES.iter().enumerate() {
|
||||
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
|
||||
store(&TEXT, i, colors.dye(name, "text"));
|
||||
store(&SHEEP, i, colors.sheep(name));
|
||||
store(&REDSTONE, i, colors.redstone(i as u8));
|
||||
for i in 0..16 {
|
||||
store(&DIFFUSE, i, colors.dye_diffuse[i]);
|
||||
store(&TEXT, i, colors.dye_text[i]);
|
||||
store(&SHEEP, i, colors.sheep[i]);
|
||||
store(&REDSTONE, i, colors.redstone[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
|
||||
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
|
||||
struct DamageBar {
|
||||
targets: horizon_polytone::Targets,
|
||||
row: Vec<u32>,
|
||||
}
|
||||
static ITEM_BARS: RwLock<Vec<DamageBar>> = RwLock::new(Vec::new());
|
||||
|
||||
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
|
||||
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
|
||||
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read.
|
||||
pub fn set_item_bars(root: &Path) {
|
||||
let mut bars = Vec::new();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let base = namespace.path().join("polytone/item_modifiers");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Some(bar) = item_bar(root, &base, &path) {
|
||||
bars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = bars;
|
||||
}
|
||||
|
||||
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let row = |png: &Path| {
|
||||
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
|
||||
let row: Vec<u32> = rgba[..width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
|
||||
.collect();
|
||||
(!row.is_empty()).then_some(row)
|
||||
};
|
||||
if let Some(stem) = name.strip_suffix("_bar.png") {
|
||||
// A json of the same name owns its png.
|
||||
if path.with_file_name(format!("{stem}.json")).exists() {
|
||||
return None;
|
||||
}
|
||||
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
|
||||
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
|
||||
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
|
||||
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
|
||||
return Some(DamageBar {
|
||||
targets,
|
||||
row: row(path)?,
|
||||
});
|
||||
}
|
||||
let stem = name.strip_suffix(".json")?;
|
||||
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
|
||||
if json.get("bar_color")?.as_str()? != "damage" {
|
||||
return None;
|
||||
}
|
||||
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
|
||||
.into_iter()
|
||||
.map(|png| path.with_file_name(png))
|
||||
.find(|png| png.exists())?;
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
|
||||
let targets =
|
||||
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
|
||||
Some(DamageBar {
|
||||
targets,
|
||||
row: row(&png)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
|
||||
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
|
||||
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
|
||||
let bar = bars
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.targets.matches(item, |_| false))?;
|
||||
let last = (bar.row.len() - 1) as f32;
|
||||
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
|
||||
}
|
||||
|
||||
fn read(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
|
||||
fn get(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
|
||||
let v = table[i & 15].load(Relaxed);
|
||||
(v != 0).then_some(v as u32)
|
||||
}
|
||||
|
||||
/// `DyeColor.getTextureDiffuseColor` (RGB) as the packs draw it.
|
||||
pub fn diffuse(dye: usize) -> u32 {
|
||||
read(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
|
||||
get(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
|
||||
}
|
||||
|
||||
/// `DyeColor.getTextColor` (RGB) as the packs draw it.
|
||||
pub fn text(dye: usize) -> u32 {
|
||||
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
get(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
}
|
||||
|
||||
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
|
||||
pub fn redstone(power: u8) -> Option<u32> {
|
||||
read(&REDSTONE, usize::from(power))
|
||||
get(&REDSTONE, usize::from(power))
|
||||
}
|
||||
|
||||
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
|
||||
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
|
||||
pub fn sheep_wool(dye: u8) -> u32 {
|
||||
let i = usize::from(dye & 15);
|
||||
if let Some(color) = read(&SHEEP, i) {
|
||||
if let Some(color) = get(&SHEEP, i) {
|
||||
return color;
|
||||
}
|
||||
if i == 0 {
|
||||
@@ -156,81 +163,3 @@ pub fn sheep_wool(dye: u8) -> u32 {
|
||||
let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255);
|
||||
f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pack_recolours_only_what_it_names() {
|
||||
// Dyes no other test draws: the table is process-wide.
|
||||
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
|
||||
let polytone = dir.join("assets/obs/polytone");
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
set(&PackColors::read(&dir));
|
||||
let (cyan, pink, purple) = (9, 6, 10);
|
||||
assert_eq!(diffuse(cyan), 0x00ff00);
|
||||
assert_eq!(text(cyan), 0x0000ff);
|
||||
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
|
||||
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
set(&PackColors::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
let data: Vec<u8> = pixels
|
||||
.iter()
|
||||
.flat_map(|p| [p[0], p[1], p[2], 255])
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&data)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
|
||||
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
|
||||
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
|
||||
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
|
||||
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
|
||||
std::fs::write(
|
||||
modifiers.join("tools.json"),
|
||||
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
|
||||
set_item_bars(&dir);
|
||||
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
|
||||
assert_eq!(
|
||||
item_bar_color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
|
||||
Some(0xFF0000)
|
||||
);
|
||||
assert_eq!(
|
||||
item_bar_color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
|
||||
Some(0xFFFF00)
|
||||
);
|
||||
assert_eq!(item_bar_color("minecraft:iron_axe", 0.9), Some(0x040506));
|
||||
assert_eq!(item_bar_color("minecraft:diamond_shovel", 0.5), None);
|
||||
set_item_bars(&dir.join("none"));
|
||||
assert_eq!(item_bar_color("minecraft:diamond_pickaxe", 0.5), None);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -337,13 +337,6 @@ impl Settings {
|
||||
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
|
||||
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
|
||||
/// settings were made from P1's rather than loaded.
|
||||
pub fn load_stores(path: &Path) {
|
||||
let dir = path.parent();
|
||||
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let mut horizon = path
|
||||
.parent()
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn enabled() -> bool {
|
||||
}
|
||||
/// Record this thread's spans even with spans off, their wall time only (no CPU clock read):
|
||||
/// what the render thread's slow frames spent their time in, for the opt-in performance
|
||||
/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]).
|
||||
/// statistics (`play_session::Session::frame`). The thread takes them every frame ([`take`]).
|
||||
pub fn record_here(on: bool) {
|
||||
HERE.with(|h| h.set(on));
|
||||
}
|
||||
@@ -466,7 +466,7 @@ pub mod startup {
|
||||
Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
@@ -489,6 +489,11 @@ pub mod startup {
|
||||
ON.store(sink.is_some(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The time since the process started ([`init`]'s origin; zero before it).
|
||||
pub fn since_start() -> Duration {
|
||||
ORIGIN.get().map_or(Duration::ZERO, Instant::elapsed)
|
||||
}
|
||||
|
||||
/// Whether a trace is being written.
|
||||
#[inline]
|
||||
pub fn on() -> bool {
|
||||
|
||||
@@ -441,6 +441,7 @@
|
||||
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
|
||||
|
||||
@@ -311,7 +311,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
if let Ok(meta) = std::env::current_exe().and_then(std::fs::metadata) {
|
||||
report.metric("game.binary_bytes", &[meta.len() as f64]);
|
||||
}
|
||||
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
|
||||
keep_for_upload(&crate::logging::game_home(), &report);
|
||||
report
|
||||
.write_to(&setup.game_dir.join("benchmarks"))
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -468,10 +468,65 @@ impl RssSampler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the report for the launcher to send, if the player allows benchmark uploads: the report
|
||||
/// as docs/benchmark-report.md defines it, dated to the day (docs/telemetry.md).
|
||||
fn keep_for_upload(home: &Path, report: &Report) {
|
||||
use horizon_telemetry::{Item, Payload, Source, outbox};
|
||||
let Ok(data) = serde_json::to_value(report.for_upload()) else {
|
||||
return;
|
||||
};
|
||||
let item = Item::new(
|
||||
Source::Game,
|
||||
crate::crash::build(),
|
||||
Payload::Benchmark(data),
|
||||
);
|
||||
if let Err(e) = outbox::push(home, &item) {
|
||||
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reports_kept_for_upload_need_their_own_consent_and_go_dated_to_the_day() {
|
||||
use horizon_telemetry::{Choices, Consent, outbox};
|
||||
let home = std::env::temp_dir().join(format!(
|
||||
"horizon-client-benchmark-upload-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
let allow = |choices: Choices| {
|
||||
let mut consent = Consent::load(&home);
|
||||
consent.answer(choices);
|
||||
consent.save(&home).unwrap();
|
||||
};
|
||||
let mut report = Report::new(Kind::Game, Default::default());
|
||||
report.created = "2026-10-15T14:03:27Z".into();
|
||||
report.label = Some("my machine".into());
|
||||
keep_for_upload(&home, &report);
|
||||
allow(Choices {
|
||||
performance: true,
|
||||
..Choices::NONE
|
||||
});
|
||||
keep_for_upload(&home, &report);
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(Choices {
|
||||
benchmarks: true,
|
||||
..Choices::NONE
|
||||
});
|
||||
keep_for_upload(&home, &report);
|
||||
let pending = outbox::pending(&home);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let text = serde_json::to_string(&pending[0].1).unwrap();
|
||||
assert!(
|
||||
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenarios() {
|
||||
assert_eq!(scenes("standard").unwrap().len(), 4);
|
||||
|
||||
@@ -21,11 +21,11 @@ pub use horizon_client_render::{
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
|
||||
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
polytone_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
|
||||
providers, pvp, pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask,
|
||||
server_terrain, settings, shader_options, shader_pack_list, signs, skins, social, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
|
||||
streaming, structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
|
||||
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
|
||||
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
|
||||
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
|
||||
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
@@ -110,6 +110,7 @@ pub mod osk;
|
||||
pub mod patrol;
|
||||
pub mod perf_overlay;
|
||||
pub mod pick_item;
|
||||
pub mod play_session;
|
||||
pub mod player_sync;
|
||||
pub mod portal_sections;
|
||||
pub mod pose;
|
||||
@@ -139,7 +140,6 @@ pub mod stations;
|
||||
pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod studio;
|
||||
pub mod telemetry;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
|
||||
@@ -2666,9 +2666,9 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
m.extra.color = number(tag.get("Color")).map_or(16, |c| c.clamp(0, 16) as u8);
|
||||
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
|
||||
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
|
||||
m.anim.v[0] = m.extra.counter as f32 * 0.01;
|
||||
m.anim.vo[0] = m.anim.v[0];
|
||||
// Shulker.setPos: square on its block.
|
||||
|
||||
@@ -20,14 +20,11 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::options_ui::HORIZON_EXTRAS
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
horizon_hooks::engine::seal();
|
||||
// The process's start, for the startup trace and the start's milestones.
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
horizon_hooks::engine::seal();
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let game_args = args.iter().take_while(|a| *a != "--dedicated");
|
||||
|
||||
@@ -373,7 +373,6 @@ mod place_feature_tests;
|
||||
mod play_input;
|
||||
mod play_keys;
|
||||
mod player_equip;
|
||||
pub(crate) mod player_hooks;
|
||||
mod player_spawn;
|
||||
mod pointer_capture;
|
||||
#[cfg(test)]
|
||||
@@ -2029,7 +2028,7 @@ pub struct PackResources {
|
||||
/// The packs' Polytone post chains (#11486), when a pack has some.
|
||||
polytone_post: Option<Arc<horizon_client::polytone_post::PolytonePost>>,
|
||||
/// The packs' Polytone custom colours (`polytone/colors.json`).
|
||||
polytone_colors: Arc<horizon_client::polytone_colors::PackColors>,
|
||||
polytone_colors: Arc<horizon_client::pack_palette::PackColors>,
|
||||
}
|
||||
/// A quick craft drag as the screen draws it: each dragged slot's count and
|
||||
/// whether the slot caps it (None: hidden), and what stays in hand.
|
||||
@@ -2802,7 +2801,7 @@ struct Game<'a> {
|
||||
agent_last: Option<Instant>,
|
||||
frame_stats: horizon_client::control::FrameStats,
|
||||
/// This play session's frame and tick times, for opt-in performance statistics.
|
||||
perf: horizon_client::telemetry::Session,
|
||||
perf: horizon_client::play_session::Session,
|
||||
/// Control replies waiting for a rendered frame or a benchmark deadline.
|
||||
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
|
||||
/// `screenshot-sequence` while it runs.
|
||||
@@ -6143,7 +6142,7 @@ impl Game<'_> {
|
||||
let atlas = &g.gui.atlas;
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
|
||||
let draws = hud_layout::experience_colored(
|
||||
g.pack.polytone_colors.xp_bar(),
|
||||
g.pack.polytone_colors.xp_bar,
|
||||
wi,
|
||||
hi,
|
||||
g.experience.progress,
|
||||
@@ -6476,18 +6475,6 @@ impl Game<'_> {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
pub(super) fn close_container(&mut self) {
|
||||
// `Gui.setScreen` clears the old screen's focus before it goes: the
|
||||
// key that closed the container (Tab bound to the inventory) has
|
||||
@@ -6531,20 +6518,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.close_ender_chest();
|
||||
}
|
||||
/// A sound a menu click made the player play (a bundle's insert or
|
||||
/// remove): `Entity.playSound` at the player.
|
||||
pub(super) fn player_menu_sound(&mut self, sound: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
|
||||
(1.0, 1.0)
|
||||
} else {
|
||||
(0.8, 0.8 + self.world.random.next_float() * 0.4)
|
||||
};
|
||||
self.audio.play(sound, Some(at), volume, pitch);
|
||||
}
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
@@ -6773,8 +6746,8 @@ impl Game<'_> {
|
||||
.extend(out.drain(player_start..));
|
||||
}
|
||||
self.mark_motion(out.len(), 0);
|
||||
// What crates draw around players: first person, see-through (game/player_hooks.rs).
|
||||
self.players_after_geometry(camera, alpha, &mut out);
|
||||
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
|
||||
self.cosmetics_fx_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
self.fangs_geometry(camera, alpha, &mut out);
|
||||
self.block_item_display_geometry(camera, alpha, &mut out);
|
||||
@@ -7493,6 +7466,7 @@ impl Game<'_> {
|
||||
self.spawn_phantoms();
|
||||
self.tick_advancements();
|
||||
self.tick_player_model();
|
||||
self.tick_emotes();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12661,11 +12635,11 @@ pub fn run_with() -> Result<()> {
|
||||
options.spikes = Some(dedicated::SLOW_TICK_MS);
|
||||
}
|
||||
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
|
||||
// A player who allows performance statistics also says what their slow frames spent their
|
||||
// time in: this thread, which draws the frames, times its steps (wall time only).
|
||||
// A hook that wants to know what slow frames spent their time in (a player who allows
|
||||
// performance statistics): this thread, which draws the frames, times its steps (wall time
|
||||
// only).
|
||||
if options.execution_role.is_client()
|
||||
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
|
||||
.allows(horizon_telemetry::Kind::Perf)
|
||||
&& horizon_hooks::engine::wants_frame_steps(&horizon_client::logging::game_home())
|
||||
{
|
||||
horizon_client::spans::record_here(true);
|
||||
}
|
||||
@@ -12690,7 +12664,7 @@ pub fn run_with() -> Result<()> {
|
||||
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
let version = horizon_client::telemetry::version();
|
||||
let version = horizon_client::crash::build();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
|
||||
// horizon-extras: the official server is Horizon's (docs/forking.md).
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,6 @@ use horizon_client::mobs::Mob;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
use horizon_cosmetics::account::OwnedEmote;
|
||||
use horizon_cosmetics::{Item, Playing, Slot};
|
||||
use horizon_hooks::players::PlayerModels;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
|
||||
@@ -38,15 +37,10 @@ pub(in crate::game) struct Emotes {
|
||||
|
||||
impl Emotes {
|
||||
/// The emote to tell the service about, while it plays: its id and whether it loops.
|
||||
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
|
||||
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
|
||||
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
|
||||
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
|
||||
Some((&p.item.id, looping))
|
||||
}
|
||||
|
||||
/// When the emote that pulled the camera back started (the own model's age), while it plays.
|
||||
pub(in crate::game) fn pull(&self) -> Option<f32> {
|
||||
self.camera_from.and(self.playing.as_ref()).map(|p| p.start)
|
||||
Some((p.item.id.clone(), looping))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,43 +59,23 @@ impl BodyPose for EmotePose {
|
||||
}
|
||||
}
|
||||
|
||||
/// The emote a model's pose plays, if it is one.
|
||||
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
|
||||
let any: &dyn std::any::Any = &**pose?;
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
|
||||
any.downcast_ref::<EmotePose>().map(|e| &e.0)
|
||||
}
|
||||
|
||||
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
|
||||
/// it; a new one allocates).
|
||||
fn replaced(
|
||||
now: Option<&Arc<dyn BodyPose>>,
|
||||
next: Option<Playing>,
|
||||
) -> Option<Option<Arc<dyn BodyPose>>> {
|
||||
let same = match (pose_playing(now), &next) {
|
||||
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
|
||||
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
|
||||
let same = match (playing(m), &next) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
|
||||
}
|
||||
|
||||
/// Makes `m` play `next`, unless it plays that already.
|
||||
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
|
||||
if let Some(pose) = replaced(m.emote.as_ref(), next) {
|
||||
m.emote = pose;
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes model `id` play `next`, unless it plays that already.
|
||||
pub(in crate::game) fn set_model_playing(
|
||||
models: &mut dyn PlayerModels,
|
||||
id: u32,
|
||||
next: Option<Playing>,
|
||||
) {
|
||||
if let Some(pose) = replaced(models.body_pose(id), next) {
|
||||
models.set_body_pose(id, pose);
|
||||
if !same {
|
||||
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +229,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
|
||||
/// Once a tick: my emote, then the other players'.
|
||||
pub(in crate::game) fn tick_emotes(&mut self) {
|
||||
if let Some(done) = self
|
||||
.cosmetics
|
||||
@@ -276,6 +250,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.tick_emote();
|
||||
self.tick_others_emotes();
|
||||
}
|
||||
|
||||
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
|
||||
/// out over the emote's first quarter second.
|
||||
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
|
||||
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
|
||||
return 1.0;
|
||||
};
|
||||
if self.cosmetics.emotes.camera_from.is_none() {
|
||||
return 1.0;
|
||||
}
|
||||
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
|
||||
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -314,7 +314,8 @@ impl Game<'_> {
|
||||
light = FULL_LIGHT;
|
||||
}
|
||||
let mut poses = self.cosmetics.poses.borrow_mut();
|
||||
self.pose_companion(item, m, alpha, &mut poses, &model);
|
||||
self.cosmetics_model(&model, matrix, &poses, uv, light, false, None, out);
|
||||
let poses = &mut poses.1;
|
||||
self.pose_companion(item, m, alpha, poses, &model);
|
||||
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
|
||||
}
|
||||
}
|
||||
@@ -280,18 +280,25 @@ impl Game<'_> {
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// What crates draw on players and mannequins (`game/player_hooks.rs`: cosmetics),
|
||||
// posed on the GPU with the mob's own jobs: culled with it.
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
if !layers_only
|
||||
&& horizon_hooks::engine::any()
|
||||
&& super::super::cosmetics::enabled()
|
||||
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
let mark = self.skin_frame.borrow().mark();
|
||||
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
|
||||
let gpu = skinning;
|
||||
if let Some((lo, hi)) =
|
||||
self.player_layers_geometry(mob, base, camera, alpha as f32, packed, gpu, out)
|
||||
{
|
||||
if let Some((lo, hi)) = self.cosmetics_geometry(
|
||||
mob,
|
||||
false,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
gpu,
|
||||
out,
|
||||
) {
|
||||
skinned = Some(match skinned {
|
||||
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
|
||||
Some((first, None)) => (first, Some((lo, hi))),
|
||||
|
||||
@@ -1820,7 +1820,7 @@ impl Game<'_> {
|
||||
// from carries those, as `save_guest` gives them on leaving. The map
|
||||
// stays their one owner.
|
||||
let live = self.guest_attachments.get(&uuid).cloned();
|
||||
let g = &mut host.hub.guests[i];
|
||||
let g = playerdata::with_warden(&mut host.hub.guests[i], self.warden_tracker_tag());
|
||||
let held = live.map(|a| std::mem::replace(&mut g.attachments, a));
|
||||
playerdata::store(g, &mut self.level, vehicle);
|
||||
if let Some(held) = held {
|
||||
|
||||
@@ -123,7 +123,7 @@ impl Loading {
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
horizon_client::play_session::note_display(&window);
|
||||
// The renderer takes seconds to build and winit's window sits white
|
||||
// until the first frame presents, so paint the boot screen on it and
|
||||
// keep repainting it as the console's lines arrive; the wgpu frames
|
||||
|
||||
@@ -899,4 +899,32 @@ impl Game<'_> {
|
||||
f.input_clicked(world_recipes!(self), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
|
||||
/// A sound a menu click made the player play (a bundle's insert or
|
||||
/// remove): `Entity.playSound` at the player.
|
||||
pub(super) fn player_menu_sound(&mut self, sound: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
|
||||
(1.0, 1.0)
|
||||
} else {
|
||||
(0.8, 0.8 + self.world.random.next_float() * 0.4)
|
||||
};
|
||||
self.audio.play(sound, Some(at), volume, pitch);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ use horizon_core::{
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
mod index;
|
||||
mod nbt;
|
||||
pub(super) use index::CartIndex;
|
||||
use index::cart_at;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(super) enum CartKind {
|
||||
@@ -187,102 +191,6 @@ pub(super) const TNT_PRIME_EVENT: i8 = 70;
|
||||
mod display;
|
||||
use display::{cart_render_bounds, display_offset, shown_unless_air};
|
||||
pub(super) use display::{default_display_offset, forget_unreadable_display};
|
||||
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
|
||||
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
|
||||
/// every mob of the level. Both answer in slot order, as the scans did.
|
||||
///
|
||||
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
|
||||
/// rebuilt once the list's length changed (a cart gone or added), and dropped
|
||||
/// after a command, which can move or replace any cart
|
||||
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
|
||||
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
|
||||
/// unseen, so with mods loaded both scans stay.
|
||||
#[derive(Default)]
|
||||
pub(super) struct CartIndex {
|
||||
carts: Option<(SeenGrid, Vec<u32>)>,
|
||||
/// The grid, and the carts a mob rides.
|
||||
mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
|
||||
near: Vec<u32>,
|
||||
}
|
||||
impl CartIndex {
|
||||
fn usable() -> bool {
|
||||
horizon_client::mod_api::mods().is_empty()
|
||||
}
|
||||
/// The cart in slot `i` has these bounds now.
|
||||
fn moved(&mut self, i: usize, bounds: Aabb) {
|
||||
if let Some((grid, ids)) = &mut self.carts
|
||||
&& i < ids.len()
|
||||
{
|
||||
grid.update(i, bounds);
|
||||
}
|
||||
}
|
||||
fn forget_carts(&mut self) {
|
||||
self.carts = None;
|
||||
}
|
||||
/// The slots of the carts whose box may meet `area`, for the cart in slot
|
||||
/// `i` (just moved); false: scan them all.
|
||||
fn carts_near(&mut self, carts: &[Minecart], i: usize, area: Aabb, out: &mut Vec<u32>) -> bool {
|
||||
if !Self::usable() {
|
||||
return false;
|
||||
}
|
||||
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
|
||||
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
|
||||
self.carts = Some((
|
||||
SeenGrid::new(carts.iter().map(Minecart::bounds)),
|
||||
carts.iter().map(|c| c.id).collect(),
|
||||
));
|
||||
}
|
||||
let Some((grid, ids)) = &mut self.carts else {
|
||||
return false;
|
||||
};
|
||||
grid.update(i, carts[i].bounds());
|
||||
if !grid.candidates(area, out) {
|
||||
return false;
|
||||
}
|
||||
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
|
||||
// A cart was replaced in place: start over next time.
|
||||
self.carts = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The slots of the mobs whose box may meet `area` into `out`, and
|
||||
/// whether a mob rides cart `cart`; None: scan them all.
|
||||
fn mobs_near(
|
||||
&mut self,
|
||||
mobs: &JoinList<Mob>,
|
||||
cart: u32,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> Option<bool> {
|
||||
if !Self::usable() {
|
||||
return None;
|
||||
}
|
||||
if !self
|
||||
.mobs
|
||||
.as_ref()
|
||||
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
|
||||
{
|
||||
self.mobs = Some((
|
||||
mobs.stamp(),
|
||||
SeenGrid::new(mobs.iter().map(Mob::bounds)),
|
||||
mobs.iter().filter_map(|m| m.extra.mount).collect(),
|
||||
));
|
||||
}
|
||||
let (_, grid, mounted) = self.mobs.as_ref()?;
|
||||
grid.candidates(area, out).then(|| mounted.contains(&cart))
|
||||
}
|
||||
}
|
||||
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
|
||||
/// the usual case while carts tick in order) or found by a scan. A world
|
||||
/// with hundreds of mineshaft chest carts asked this of every cart several
|
||||
/// times a tick.
|
||||
fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
if carts.get(hint).is_some_and(|c| c.id == id) {
|
||||
return Some(hint);
|
||||
}
|
||||
carts.iter().position(|c| c.id == id)
|
||||
}
|
||||
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
|
||||
/// the push the cart loses (the entity gets a quarter of it back).
|
||||
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
|
||||
@@ -2984,177 +2892,6 @@ impl Game<'_> {
|
||||
.render_state(&scene, state.0, m, light, out);
|
||||
}
|
||||
}
|
||||
/// Saving and loading.
|
||||
impl Minecart {
|
||||
pub fn to_nbt(
|
||||
&self,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&horizon_world::nbt::Tag>,
|
||||
) -> horizon_world::nbt::Tag {
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
|
||||
self.kind.id(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
[self.yaw, self.pitch],
|
||||
(self.on_ground, self.fall_distance),
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
// ContainerEntity.addChestVehicleSaveData belongs only to the
|
||||
// container subclasses, never to a plain or specialized cart.
|
||||
if self.kind.slots() == 0 {
|
||||
map.remove(&JavaString::from("Items"));
|
||||
}
|
||||
if self.kind == CartKind::CommandBlock {
|
||||
for key in ["LastOutput", "LastExecution"] {
|
||||
map.remove(&JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.remove(&JavaString::from("DisplayOffset"));
|
||||
map.remove(&JavaString::from("DisplayState"));
|
||||
let mut put = |k: &str, v: Tag| {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
// addAdditionalSaveData: the offset only off its default, the state only when set.
|
||||
if let Some(offset) = self.custom_offset
|
||||
&& f64::from(offset) != default_display_offset(self.kind)
|
||||
{
|
||||
put("DisplayOffset", Tag::Int(offset));
|
||||
}
|
||||
if let Some(state) = &self.custom_display {
|
||||
put("DisplayState", state.clone());
|
||||
}
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
if self.kind == CartKind::Hopper {
|
||||
put("Enabled", Tag::Byte(i8::from(self.enabled)));
|
||||
}
|
||||
}
|
||||
CartKind::Furnace => {
|
||||
put("PushX", Tag::Double(self.push.x.to_bits()));
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save (its CustomName shares the entity's key).
|
||||
CartKind::CommandBlock => {
|
||||
for (key, value) in self.command.save() {
|
||||
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
|
||||
CartKind::Spawner => {
|
||||
if let Some(spawner) = &self.spawner {
|
||||
for (key, value) in
|
||||
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
|
||||
{
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
// otherwise the items replace it, as vanilla's unpackLootTable does.
|
||||
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
|
||||
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
|
||||
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
|
||||
{
|
||||
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
}
|
||||
} else {
|
||||
map.remove(&JavaString::from("LootTable"));
|
||||
map.remove(&JavaString::from("LootTableSeed"));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
|
||||
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let mut c = Self::new(id, kind, position);
|
||||
c.load(tag)?;
|
||||
Some(c)
|
||||
}
|
||||
/// `Entity.load` on this cart: the saved fields are read over the live
|
||||
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
|
||||
/// `None`, with nothing changed, when the compound has no position.
|
||||
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let kind = self.kind;
|
||||
let c = self;
|
||||
c.position = position;
|
||||
c.previous = position;
|
||||
c.velocity = velocity;
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
|
||||
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
|
||||
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
|
||||
c.previous_pitch = c.pitch;
|
||||
// ValueInput's numeric getters take any NumericTag: getIntOr is its
|
||||
// intValue, getShortOr the low 16 bits of it, getBooleanOr
|
||||
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
|
||||
let int = |k: &str| {
|
||||
tag.get(k)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
};
|
||||
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
|
||||
let double = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Byte(v)) => f64::from(*v),
|
||||
Some(Tag::Short(v)) => f64::from(*v),
|
||||
Some(Tag::Int(v)) => f64::from(*v),
|
||||
Some(Tag::Long(v)) => *v as f64,
|
||||
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v),
|
||||
_ => 0.0,
|
||||
};
|
||||
// Entity.load: OnGround and fall_distance, as saved.
|
||||
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
|
||||
c.flipped = flag("FlippedRotation", false);
|
||||
c.first_tick = flag("HasTicked", false);
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
c.enabled = flag("Enabled", true);
|
||||
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
|
||||
c.custom_display = tag.get("DisplayState").cloned();
|
||||
c.custom_offset = int("DisplayOffset");
|
||||
if kind == CartKind::CommandBlock {
|
||||
c.command = horizon_client::command_block::CommandBlock::load(tag);
|
||||
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
|
||||
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
|
||||
if c.synced_command != c.command.command {
|
||||
c.synced_command.clone_from(&c.command.command);
|
||||
c.command.set_command(&c.synced_command);
|
||||
}
|
||||
c.synced_output = c.command.last_output_string();
|
||||
}
|
||||
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
|
||||
if kind == CartKind::Spawner {
|
||||
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
|
||||
tag,
|
||||
)));
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
|
||||
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
|
||||
/// TNT cart.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The carts' and mobs' boxes for a tick's cart pushes ([`CartIndex`]).
|
||||
use super::*;
|
||||
|
||||
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
|
||||
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
|
||||
/// every mob of the level. Both answer in slot order, as the scans did.
|
||||
///
|
||||
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
|
||||
/// rebuilt once the list's length changed (a cart gone or added), and dropped
|
||||
/// after a command, which can move or replace any cart
|
||||
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
|
||||
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
|
||||
/// unseen, so with mods loaded both scans stay.
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct CartIndex {
|
||||
pub(super) carts: Option<(SeenGrid, Vec<u32>)>,
|
||||
/// The grid, and the carts a mob rides.
|
||||
pub(super) mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
|
||||
pub(super) near: Vec<u32>,
|
||||
}
|
||||
impl CartIndex {
|
||||
pub(super) fn usable() -> bool {
|
||||
horizon_client::mod_api::mods().is_empty()
|
||||
}
|
||||
/// The cart in slot `i` has these bounds now.
|
||||
pub(super) fn moved(&mut self, i: usize, bounds: Aabb) {
|
||||
if let Some((grid, ids)) = &mut self.carts
|
||||
&& i < ids.len()
|
||||
{
|
||||
grid.update(i, bounds);
|
||||
}
|
||||
}
|
||||
pub(super) fn forget_carts(&mut self) {
|
||||
self.carts = None;
|
||||
}
|
||||
/// The slots of the carts whose box may meet `area`, for the cart in slot
|
||||
/// `i` (just moved); false: scan them all.
|
||||
pub(super) fn carts_near(
|
||||
&mut self,
|
||||
carts: &[Minecart],
|
||||
i: usize,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> bool {
|
||||
if !Self::usable() {
|
||||
return false;
|
||||
}
|
||||
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
|
||||
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
|
||||
self.carts = Some((
|
||||
SeenGrid::new(carts.iter().map(Minecart::bounds)),
|
||||
carts.iter().map(|c| c.id).collect(),
|
||||
));
|
||||
}
|
||||
let Some((grid, ids)) = &mut self.carts else {
|
||||
return false;
|
||||
};
|
||||
grid.update(i, carts[i].bounds());
|
||||
if !grid.candidates(area, out) {
|
||||
return false;
|
||||
}
|
||||
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
|
||||
// A cart was replaced in place: start over next time.
|
||||
self.carts = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The slots of the mobs whose box may meet `area` into `out`, and
|
||||
/// whether a mob rides cart `cart`; None: scan them all.
|
||||
pub(super) fn mobs_near(
|
||||
&mut self,
|
||||
mobs: &JoinList<Mob>,
|
||||
cart: u32,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> Option<bool> {
|
||||
if !Self::usable() {
|
||||
return None;
|
||||
}
|
||||
if !self
|
||||
.mobs
|
||||
.as_ref()
|
||||
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
|
||||
{
|
||||
self.mobs = Some((
|
||||
mobs.stamp(),
|
||||
SeenGrid::new(mobs.iter().map(Mob::bounds)),
|
||||
mobs.iter().filter_map(|m| m.extra.mount).collect(),
|
||||
));
|
||||
}
|
||||
let (_, grid, mounted) = self.mobs.as_ref()?;
|
||||
grid.candidates(area, out).then(|| mounted.contains(&cart))
|
||||
}
|
||||
}
|
||||
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
|
||||
/// the usual case while carts tick in order) or found by a scan. A world
|
||||
/// with hundreds of mineshaft chest carts asked this of every cart several
|
||||
/// times a tick.
|
||||
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
if carts.get(hint).is_some_and(|c| c.id == id) {
|
||||
return Some(hint);
|
||||
}
|
||||
carts.iter().position(|c| c.id == id)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Saving and loading a minecart.
|
||||
use super::*;
|
||||
|
||||
/// Saving and loading.
|
||||
impl Minecart {
|
||||
pub fn to_nbt(
|
||||
&self,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&horizon_world::nbt::Tag>,
|
||||
) -> horizon_world::nbt::Tag {
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
|
||||
self.kind.id(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
[self.yaw, self.pitch],
|
||||
(self.on_ground, self.fall_distance),
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
// ContainerEntity.addChestVehicleSaveData belongs only to the
|
||||
// container subclasses, never to a plain or specialized cart.
|
||||
if self.kind.slots() == 0 {
|
||||
map.remove(&JavaString::from("Items"));
|
||||
}
|
||||
if self.kind == CartKind::CommandBlock {
|
||||
for key in ["LastOutput", "LastExecution"] {
|
||||
map.remove(&JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.remove(&JavaString::from("DisplayOffset"));
|
||||
map.remove(&JavaString::from("DisplayState"));
|
||||
let mut put = |k: &str, v: Tag| {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
// addAdditionalSaveData: the offset only off its default, the state only when set.
|
||||
if let Some(offset) = self.custom_offset
|
||||
&& f64::from(offset) != default_display_offset(self.kind)
|
||||
{
|
||||
put("DisplayOffset", Tag::Int(offset));
|
||||
}
|
||||
if let Some(state) = &self.custom_display {
|
||||
put("DisplayState", state.clone());
|
||||
}
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
if self.kind == CartKind::Hopper {
|
||||
put("Enabled", Tag::Byte(i8::from(self.enabled)));
|
||||
}
|
||||
}
|
||||
CartKind::Furnace => {
|
||||
put("PushX", Tag::Double(self.push.x.to_bits()));
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save (its CustomName shares the entity's key).
|
||||
CartKind::CommandBlock => {
|
||||
for (key, value) in self.command.save() {
|
||||
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
|
||||
CartKind::Spawner => {
|
||||
if let Some(spawner) = &self.spawner {
|
||||
for (key, value) in
|
||||
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
|
||||
{
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
// otherwise the items replace it, as vanilla's unpackLootTable does.
|
||||
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
|
||||
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
|
||||
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
|
||||
{
|
||||
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
}
|
||||
} else {
|
||||
map.remove(&JavaString::from("LootTable"));
|
||||
map.remove(&JavaString::from("LootTableSeed"));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
|
||||
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let mut c = Self::new(id, kind, position);
|
||||
c.load(tag)?;
|
||||
Some(c)
|
||||
}
|
||||
/// `Entity.load` on this cart: the saved fields are read over the live
|
||||
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
|
||||
/// `None`, with nothing changed, when the compound has no position.
|
||||
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let kind = self.kind;
|
||||
let c = self;
|
||||
c.position = position;
|
||||
c.previous = position;
|
||||
c.velocity = velocity;
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
|
||||
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
|
||||
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
|
||||
c.previous_pitch = c.pitch;
|
||||
// ValueInput's numeric getters take any NumericTag: getIntOr is its
|
||||
// intValue, getShortOr the low 16 bits of it, getBooleanOr
|
||||
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
|
||||
let int = |k: &str| {
|
||||
tag.get(k)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
};
|
||||
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
|
||||
let double = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Byte(v)) => f64::from(*v),
|
||||
Some(Tag::Short(v)) => f64::from(*v),
|
||||
Some(Tag::Int(v)) => f64::from(*v),
|
||||
Some(Tag::Long(v)) => *v as f64,
|
||||
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v),
|
||||
_ => 0.0,
|
||||
};
|
||||
// Entity.load: OnGround and fall_distance, as saved.
|
||||
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
|
||||
c.flipped = flag("FlippedRotation", false);
|
||||
c.first_tick = flag("HasTicked", false);
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
c.enabled = flag("Enabled", true);
|
||||
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
|
||||
c.custom_display = tag.get("DisplayState").cloned();
|
||||
c.custom_offset = int("DisplayOffset");
|
||||
if kind == CartKind::CommandBlock {
|
||||
c.command = horizon_client::command_block::CommandBlock::load(tag);
|
||||
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
|
||||
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
|
||||
if c.synced_command != c.command.command {
|
||||
c.synced_command.clone_from(&c.command.command);
|
||||
c.command.set_command(&c.synced_command);
|
||||
}
|
||||
c.synced_output = c.command.last_output_string();
|
||||
}
|
||||
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
|
||||
if kind == CartKind::Spawner {
|
||||
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
|
||||
tag,
|
||||
)));
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
@@ -300,7 +300,11 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let marks = self.hooks_name_marks(m, Some(m.id) == own);
|
||||
let marks = if player || self.cosmetics_local() {
|
||||
self.cosmetics_marks(m, Some(m.id) == own)
|
||||
} else {
|
||||
Marks::default()
|
||||
};
|
||||
let at = match marks.top {
|
||||
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
|
||||
None => p,
|
||||
@@ -766,10 +770,10 @@ impl Game<'_> {
|
||||
if background { BACKGROUND } else { 0 },
|
||||
),
|
||||
};
|
||||
if let Some(mark) = marks.mark.filter(|_| n == last) {
|
||||
if n == last && marks.horizon {
|
||||
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
|
||||
// 8 text pixels square, a pixel left of the background.
|
||||
for &(mx, my, w, h, argb) in mark {
|
||||
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
|
||||
let a = text_alpha * (argb >> 24) / 255;
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
@@ -933,11 +937,10 @@ struct NameTag {
|
||||
marks: Marks,
|
||||
}
|
||||
|
||||
/// What a hook draws by a player's name (`horizon_hooks::players::NameDecoration`): a mark
|
||||
/// before it (Horizon's: they play Horizon) and a name tag style.
|
||||
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct Marks {
|
||||
pub mark: Option<horizon_hooks::players::Mark>,
|
||||
pub horizon: bool,
|
||||
pub style: Option<TagStyle>,
|
||||
/// How high the cosmetics drawn on their body get above the neck, in model pixels: the tag
|
||||
/// goes up over a tall one (`cosmetic_lift`).
|
||||
|
||||
@@ -311,8 +311,8 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let mut view = self.view;
|
||||
// A hook may pull the camera back (an emote, from the player's face).
|
||||
let pull = self.hooks_camera_distance(partial);
|
||||
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
|
||||
let pull = self.emote_camera_pull(partial);
|
||||
let kind = match self.camera_type {
|
||||
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
|
||||
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
|
||||
@@ -800,8 +800,19 @@ impl Game<'_> {
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
let world = horizon_hooks::players::Pass::World;
|
||||
self.own_layers_geometry(m, world, camera, alpha as f32, packed, out);
|
||||
if super::cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
|
||||
self.cosmetics_geometry(
|
||||
m,
|
||||
true,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
false,
|
||||
out,
|
||||
);
|
||||
}
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
|
||||
@@ -915,8 +926,10 @@ impl Game<'_> {
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
let picture = horizon_hooks::players::Pass::Picture;
|
||||
self.own_layers_geometry(&m, picture, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
if super::cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
|
||||
self.cosmetics_geometry(&m, true, base, None, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
}
|
||||
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
|
||||
// wings' faces show from both sides (the elytra texture is empty on
|
||||
// the face that looks at the body).
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
//! Where the game hands the player models to crates above the engine (`horizon_hooks::Hooks`'s
|
||||
//! `frame`, `tick`, `name_decoration`, `player_mark`, `camera`; spec #14091): only a window's
|
||||
//! game calls them, never the integrated server's thread or a dedicated server. With no hook
|
||||
//! registered nothing is built.
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::join_seq::JoinList;
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
|
||||
use horizon_client::mobs::{Mob, Part, Polygon, render};
|
||||
use horizon_hooks::engine;
|
||||
use horizon_hooks::players::{
|
||||
Bone, CameraView, Frame, Gear, Kind, Light, Look, Mesh, Motion, PartPoses, Particle, Pass,
|
||||
PlayerDraw, PlayerModel, PlayerModels, SpriteStore, Sprites, View,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
|
||||
/// moves them out). Before the hooks are sealed.
|
||||
pub(crate) fn register() {
|
||||
if super::cosmetics::enabled()
|
||||
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
|
||||
{
|
||||
log::error!("Cosmetics: their hooks came too late: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The models of the world a window shows: its own first, then the other players and mannequins.
|
||||
struct Models<'a> {
|
||||
own: Option<&'a mut Mob>,
|
||||
mobs: &'a mut JoinList<Mob>,
|
||||
remote: &'a super::remote::Remote,
|
||||
}
|
||||
|
||||
impl Models<'_> {
|
||||
fn find(&mut self, id: u32) -> Option<&mut Mob> {
|
||||
match self.own.as_deref_mut() {
|
||||
Some(m) if m.id == id => Some(m),
|
||||
_ => self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && kind(m).is_some()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of player model `m` is, if it is one (the window's own model aside).
|
||||
fn kind(m: &Mob) -> Option<Kind> {
|
||||
match &*m.kind.id {
|
||||
"minecraft:player" => Some(Kind::Player),
|
||||
"minecraft:mannequin" => Some(Kind::Mannequin),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What `m` wears where a crate may draw: a helmet or head item, a chestplate, a cape or elytra.
|
||||
pub(super) fn gear(m: &Mob) -> Gear {
|
||||
let elytra = m.extra.chest.as_deref() == Some("minecraft:elytra");
|
||||
Gear::new(
|
||||
m.extra.head.is_some(),
|
||||
m.extra.chest.is_some() && !elytra,
|
||||
m.cape.is_some() || elytra,
|
||||
)
|
||||
}
|
||||
|
||||
fn model(m: &Mob, kind: Kind, uuid: Option<u128>) -> PlayerModel {
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
PlayerModel::new(m.id, kind, uuid, m.age, dx * dx + dz * dz > 1e-4)
|
||||
.looks(gear(m), horizon_client::mobs::trader::is_invisible(m))
|
||||
}
|
||||
|
||||
impl PlayerModels for Models<'_> {
|
||||
fn connection(&self) -> u64 {
|
||||
self.remote.session.serial()
|
||||
}
|
||||
|
||||
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
|
||||
if let Some(m) = self.own.as_deref() {
|
||||
f(model(m, Kind::Own, Some(self.remote.profile())));
|
||||
}
|
||||
for m in self.mobs.iter() {
|
||||
if let Some(kind) = kind(m) {
|
||||
let uuid = (kind == Kind::Player)
|
||||
.then(|| self.remote.player_uuid(m.id))
|
||||
.flatten();
|
||||
f(model(m, kind, uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
|
||||
match self.own.as_deref() {
|
||||
Some(m) if m.id == id => m.emote.as_ref(),
|
||||
_ => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && kind(m).is_some())?
|
||||
.emote
|
||||
.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
|
||||
if let Some(m) = self.find(id) {
|
||||
m.emote = pose;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sprite `id`'s place in the atlas's names (`hooks/<hex>`), written into `buf`: no allocation.
|
||||
fn location(id: u64, buf: &mut [u8; 22]) -> &str {
|
||||
use std::io::Write;
|
||||
let mut out = std::io::Cursor::new(&mut buf[..]);
|
||||
let _ = write!(out, "hooks/{id:x}");
|
||||
let len = out.position() as usize;
|
||||
std::str::from_utf8(&buf[..len]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The crates' sprites in the GUI atlas, in the skin slots: read while drawing.
|
||||
struct HookSprites<'a, 'g>(&'a Game<'g>);
|
||||
|
||||
impl Sprites for HookSprites<'_, '_> {
|
||||
fn get(&self, id: u64) -> Option<[f32; 4]> {
|
||||
let mut buf = [0; 22];
|
||||
let at = location(id, &mut buf);
|
||||
let r = self.0.gui.atlas.sprites.get(at)?;
|
||||
// Kept this frame (`skin_slots.next_frame`).
|
||||
self.0.skin_downloads.borrow_mut().drawn(at);
|
||||
Some([r.u0, r.v0, r.u1, r.v1])
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a crate's frame hook puts its sprites.
|
||||
struct HookStore<'a, 'g>(&'a mut Game<'g>);
|
||||
|
||||
impl SpriteStore for HookStore<'_, '_> {
|
||||
fn has(&self, id: u64) -> bool {
|
||||
self.0
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.contains_key(location(id, &mut [0; 22]))
|
||||
}
|
||||
|
||||
fn put(&mut self, id: u64, rgba: Vec<u8>) {
|
||||
let at = location(id, &mut [0; 22]).to_owned();
|
||||
self.0.store_skin(at, rgba);
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The crates' meshes as the renderer takes them, by the mesh's address (each entry holds its
|
||||
/// mesh, so the address isn't reused); render thread only.
|
||||
static MESHES: std::cell::RefCell<FxHashMap<usize, (Arc<Mesh>, Arc<Part>)>> =
|
||||
Default::default();
|
||||
}
|
||||
|
||||
/// The game's model of `mesh`, made once.
|
||||
fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
|
||||
fn part(m: &Mesh) -> Part {
|
||||
Part {
|
||||
name: m.name.clone(),
|
||||
pose: m.pose,
|
||||
polygons: m
|
||||
.quads
|
||||
.iter()
|
||||
.map(|q| Polygon {
|
||||
normal: q.normal,
|
||||
vertices: q.vertices,
|
||||
cull_back: false,
|
||||
})
|
||||
.collect(),
|
||||
children: m.children.iter().map(part).collect(),
|
||||
}
|
||||
}
|
||||
MESHES.with_borrow_mut(|meshes| {
|
||||
let (_, part_of) = meshes
|
||||
.entry(Arc::as_ptr(mesh) as usize)
|
||||
.or_insert_with(|| (mesh.clone(), Arc::new(part(mesh))));
|
||||
part_of.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Full light, packed as the vertices carry it.
|
||||
const FULL: u32 = 240 | 240 << 8;
|
||||
|
||||
thread_local! {
|
||||
/// What crates draw see-through this frame, added after every player.
|
||||
static SEE_THROUGH: std::cell::RefCell<Vec<Vertex>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Forgets the meshes no crate holds any more (once a frame).
|
||||
fn forget_meshes() {
|
||||
let _ = MESHES.try_with(|m| {
|
||||
m.borrow_mut()
|
||||
.retain(|_, (mesh, _)| Arc::strong_count(mesh) > 1)
|
||||
});
|
||||
}
|
||||
|
||||
/// `Poses` as a crate poses a mesh's parts.
|
||||
struct PosesOf<'a>(&'a mut Poses);
|
||||
|
||||
impl PartPoses for PosesOf<'_> {
|
||||
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
|
||||
self.0.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A player model drawn, as a crate draws on it: posed on the GPU's skin path
|
||||
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
|
||||
/// culling), else its vertices go to `out`.
|
||||
struct DrawPlayer<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
m: &'a Mob,
|
||||
pass: Pass,
|
||||
/// The model's matrix (`render::base_transform`).
|
||||
base: glam::Mat4,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &'a mut Vec<Vertex>,
|
||||
bounds: Option<(glam::Vec3, glam::Vec3)>,
|
||||
/// The body as it is drawn, posed on the first `bone`.
|
||||
body: Option<PooledPoses>,
|
||||
poses: PooledPoses,
|
||||
}
|
||||
|
||||
impl DrawPlayer<'_, '_> {
|
||||
fn body(&self) -> &Part {
|
||||
let (key, _) = render::body(self.m);
|
||||
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
|
||||
}
|
||||
|
||||
/// `light` packed; `at`: the place `Light::AtPlace` reads, relative to the camera.
|
||||
fn packed(&self, light: Light, at: glam::Vec3) -> u32 {
|
||||
match light {
|
||||
Light::Full => FULL,
|
||||
Light::AtPlace => {
|
||||
let p = (self.camera + at.as_dvec3()).floor();
|
||||
let l = self.game.world.light(p.x as i32, p.y as i32, p.z as i32);
|
||||
(u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8)
|
||||
}
|
||||
_ => self.light,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
fn pass(&self) -> Pass {
|
||||
self.pass
|
||||
}
|
||||
|
||||
fn ticks(&self) -> f32 {
|
||||
self.m.age as f32 + self.alpha
|
||||
}
|
||||
|
||||
fn base(&self) -> [[f32; 4]; 4] {
|
||||
self.base.to_cols_array_2d()
|
||||
}
|
||||
|
||||
fn motion(&self) -> Motion {
|
||||
let (m, a) = (self.m, self.alpha);
|
||||
let t = f64::from(a);
|
||||
let (p, n) = (m.previous, m.position);
|
||||
let feet = [
|
||||
p.x + (n.x - p.x) * t,
|
||||
p.y + (n.y - p.y) * t,
|
||||
p.z + (n.z - p.z) * t,
|
||||
];
|
||||
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
|
||||
let yaw = m.previous_head_yaw + turn * a;
|
||||
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * a;
|
||||
let asleep = m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping;
|
||||
let swimming = m.extra.swim.amount(a) > 0.0;
|
||||
Motion::new(feet, yaw, pitch).poses(
|
||||
m.extra.crouching,
|
||||
asleep,
|
||||
m.extra.fall_flying,
|
||||
swimming,
|
||||
)
|
||||
}
|
||||
|
||||
fn view(&self) -> View {
|
||||
let q = self.game.camera_orientation();
|
||||
let (right, up) = (
|
||||
(q * glam::Vec3::X).to_array(),
|
||||
(q * glam::Vec3::Y).to_array(),
|
||||
);
|
||||
let (yaw, pitch) = (self.game.yaw.to_radians(), self.game.pitch.to_radians());
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let left = [yaw.cos(), 0.0, yaw.sin()];
|
||||
View::new(self.camera.to_array(), right, up).looking(forward, left)
|
||||
}
|
||||
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
|
||||
if self.pass == Pass::FirstPerson {
|
||||
return None;
|
||||
}
|
||||
if self.body.is_none() {
|
||||
let mut poses = Poses::pooled();
|
||||
poses.reset(self.body());
|
||||
let data = self.game.res.mob_data();
|
||||
let state = anim::state(self.m, self.alpha);
|
||||
anim::setup(self.m, &state, &mut poses, &data.animations);
|
||||
self.body = Some(poses);
|
||||
}
|
||||
let poses = self.body.as_ref()?;
|
||||
let at = render::part_matrix(self.body(), self.base, poses, bone.part(), 0.0)?;
|
||||
Some(at.to_cols_array_2d())
|
||||
}
|
||||
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
|
||||
self.m.emote.as_ref()
|
||||
}
|
||||
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
|
||||
HookSprites(self.game).get(id)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
look: Look,
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
) {
|
||||
let model = part_of(mesh);
|
||||
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
|
||||
let (uv, light) = (look.uv, self.packed(look.light, matrix.w_axis.truncate()));
|
||||
self.poses.reset(&model);
|
||||
pose(&mut PosesOf(&mut self.poses));
|
||||
let poses = &self.poses;
|
||||
if let Some(alpha) = look.see_through {
|
||||
let color = ((alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
let pose_of = |p: &Part| poses.pose(p);
|
||||
SEE_THROUGH.with_borrow_mut(|out| {
|
||||
let start = out.len();
|
||||
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, out);
|
||||
for v in &mut out[start..] {
|
||||
v.color = color;
|
||||
// Seen through, a face turned away would add to the near one: `entity.wgsl`
|
||||
// drops back faces, so it is `alpha` at most.
|
||||
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if self.gpu {
|
||||
let mut models = self.game.skin_models.borrow_mut();
|
||||
let mut frame = self.game.skin_frame.borrow_mut();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
skin.bounds = self.bounds;
|
||||
let paint = horizon_client::mobs::skin::Paint {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(&model, matrix, poses, paint) {
|
||||
self.bounds = skin.bounds;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let pose_of = |p: &Part| poses.pose(p);
|
||||
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, self.out);
|
||||
}
|
||||
|
||||
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light) {
|
||||
let light = self.packed(light, glam::Vec3::ZERO);
|
||||
SEE_THROUGH.with_borrow_mut(|out| {
|
||||
for q in particles {
|
||||
let color = ((q.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
for (corner, [u, v]) in q.corners.iter().zip(q.uv) {
|
||||
out.push(Vertex {
|
||||
pos: *corner,
|
||||
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
// Unlit: a particle keeps its colour whichever way it faces.
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
|
||||
/// cosmetics still drawn here: see [`DrawPlayer`]. The bounds of what went to the GPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn player_layers_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
base: glam::Mat4,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let kind = kind(m)?;
|
||||
let mut bounds = None;
|
||||
if engine::any() {
|
||||
let model = model(m, kind, self.remote_player_uuid(m.id));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::World,
|
||||
base,
|
||||
camera,
|
||||
alpha,
|
||||
light,
|
||||
gpu,
|
||||
out: &mut *out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
bounds = draw.bounds;
|
||||
}
|
||||
let worn = super::cosmetics::enabled()
|
||||
.then(|| self.cosmetics_fx_of(m, false, base, camera, alpha, light, gpu, out))
|
||||
.flatten();
|
||||
match (bounds, worn) {
|
||||
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
|
||||
(b, w) => b.or(w),
|
||||
}
|
||||
}
|
||||
|
||||
/// What crates draw on the window's own model `m` (its vertices to `out`): in the world in
|
||||
/// third person (`Pass::World`, then the cosmetics' companion, trail and aura still drawn
|
||||
/// here), or in the inventory's picture of it (`Pass::Picture`). `camera`: the world view's
|
||||
/// camera, or the picture's.
|
||||
pub(super) fn own_layers_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
pass: Pass,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let fx = pass == Pass::World && super::cosmetics::enabled();
|
||||
if !engine::any() && !fx {
|
||||
return;
|
||||
}
|
||||
let base = render::base_transform(m, alpha, camera);
|
||||
if engine::any() {
|
||||
let model = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass,
|
||||
base,
|
||||
camera,
|
||||
alpha,
|
||||
light,
|
||||
gpu: false,
|
||||
out: &mut *out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
}
|
||||
if fx {
|
||||
self.cosmetics_fx_of(m, true, base, camera, alpha, light, false, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// After every player (`game.rs`): what crates draw around the window's own model in first
|
||||
/// person, the cosmetics' fx still drawn here, then what was drawn see-through, in one span.
|
||||
pub(super) fn players_after_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if engine::any()
|
||||
&& !self.own_body_in_view()
|
||||
&& let Some(m) = self.player_model.as_ref()
|
||||
{
|
||||
let own = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
|
||||
let own = own.looks(own.gear, own.invisible || self.own_invisible());
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::FirstPerson,
|
||||
base: render::base_transform(m, alpha as f32, camera),
|
||||
camera,
|
||||
alpha: alpha as f32,
|
||||
light: FULL,
|
||||
gpu: false,
|
||||
out: &mut *out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::first_person(&own, &mut draw);
|
||||
}
|
||||
self.cosmetics_fx_geometry(camera, alpha, out);
|
||||
let _ = SEE_THROUGH.try_with(|v| {
|
||||
let mut v = v.borrow_mut();
|
||||
if !v.is_empty() {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:player");
|
||||
out.extend_from_slice(&v);
|
||||
v.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Once a frame, before drawing.
|
||||
pub(super) fn client_hooks_frame(&mut self) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
self.cosmetics_say();
|
||||
let connection = self.remote.as_ref().map_or(0, |r| r.session.serial());
|
||||
let frame = Frame::new(connection, self.integrated.is_some());
|
||||
engine::frame(&frame, &mut HookStore(self));
|
||||
forget_meshes();
|
||||
let _ = SEE_THROUGH.try_with(|v| v.borrow_mut().clear());
|
||||
self.cosmetics_frame();
|
||||
}
|
||||
|
||||
/// Once a client tick in a world: the player's own emote, then the hooks.
|
||||
pub(super) fn client_hooks_tick(&mut self) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
self.tick_emotes();
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
self.cosmetics_say();
|
||||
let Some(remote) = self.remote.as_ref() else {
|
||||
return;
|
||||
};
|
||||
engine::tick(&mut Models {
|
||||
own: self.player_model.as_mut(),
|
||||
mobs: &mut self.mobs,
|
||||
remote,
|
||||
});
|
||||
}
|
||||
|
||||
/// What goes by `m`'s name (`own`: the window's own player), from the hooks.
|
||||
pub(super) fn hooks_name_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let mut marks = super::names::Marks::default();
|
||||
if !engine::any() {
|
||||
return marks;
|
||||
}
|
||||
let model = match (own, kind(m)) {
|
||||
(true, _) => model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile())),
|
||||
(false, Some(kind)) => model(m, kind, self.remote_player_uuid(m.id)),
|
||||
(false, None) => return marks,
|
||||
};
|
||||
if let Some(d) = engine::name_decoration(&model, &HookSprites(self)) {
|
||||
marks.mark = d.mark;
|
||||
marks.top = d.top;
|
||||
marks.style = d.badge.map(|b| {
|
||||
let [u0, v0, u1, v1] = b.uv;
|
||||
super::names::TagStyle {
|
||||
mark: horizon_client::hud::Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1,
|
||||
v1,
|
||||
w: 8,
|
||||
h: 8,
|
||||
},
|
||||
line: b.line,
|
||||
}
|
||||
});
|
||||
}
|
||||
marks
|
||||
}
|
||||
|
||||
/// How far the third-person camera stands back, as a share of the usual distance: a hook's
|
||||
/// (an emote pulls it back), else 1.
|
||||
pub(super) fn hooks_camera_distance(&self, partial: f32) -> f32 {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
match &self.player_model {
|
||||
Some(m) if engine::any() => {
|
||||
engine::camera(&CameraView::new(m.age as f32 + partial)).map_or(1.0, |c| c.distance)
|
||||
}
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sprite_locations_fit_the_buffer_and_differ_by_id() {
|
||||
let mut a = [0; 22];
|
||||
let mut b = [0; 22];
|
||||
assert_eq!(location(u64::MAX, &mut a), "hooks/ffffffffffffffff");
|
||||
assert_eq!(location(10, &mut b), "hooks/a");
|
||||
}
|
||||
}
|
||||
@@ -6731,7 +6731,7 @@ impl Game<'_> {
|
||||
if self.studio_replay.is_none() {
|
||||
self.tick_player_model();
|
||||
}
|
||||
self.client_hooks_tick();
|
||||
self.tick_emotes();
|
||||
if !dead {
|
||||
self.remote_tick_using();
|
||||
}
|
||||
@@ -10714,11 +10714,17 @@ impl Game<'_> {
|
||||
/// The profile name of another player the server sent, by entity id.
|
||||
/// The UUID of another player's entity in the level.
|
||||
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
|
||||
self.remote.as_ref()?.player_uuid(id)
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = *r.level_players.get(&(id as i32))?;
|
||||
(uuid != r.profile).then_some(uuid)
|
||||
}
|
||||
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
|
||||
let r = self.remote.as_ref()?;
|
||||
r.level_player_name(id as i32, &r.player_uuid(id)?)
|
||||
let uuid = r.level_players.get(&(id as i32))?;
|
||||
if *uuid == r.profile {
|
||||
return None;
|
||||
}
|
||||
r.level_player_name(id as i32, uuid)
|
||||
}
|
||||
/// Status for `horizon-ctl status`.
|
||||
/// The other players' entities in the level and their names (their
|
||||
@@ -11499,11 +11505,6 @@ impl Remote {
|
||||
pub(super) fn profile(&self) -> u128 {
|
||||
self.profile
|
||||
}
|
||||
/// Another player's profile UUID, by their entity id (None for the local player).
|
||||
pub(super) fn player_uuid(&self, id: u32) -> Option<u128> {
|
||||
let uuid = *self.level_players.get(&(id as i32))?;
|
||||
(uuid != self.profile).then_some(uuid)
|
||||
}
|
||||
/// The entity id of a player in the level, by profile UUID.
|
||||
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
|
||||
self.level_players
|
||||
|
||||
@@ -487,7 +487,7 @@ pub(super) fn load_resources(
|
||||
};
|
||||
let resources = Arc::new(resources);
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_core_ready();
|
||||
horizon_client::play_session::core_ready();
|
||||
log::info!(
|
||||
"Horizon: core-ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
@@ -526,7 +526,7 @@ pub(super) fn load_resources(
|
||||
.set(rest)
|
||||
.map_err(|_| "world resources already loaded".to_owned())?;
|
||||
progress.set(9);
|
||||
horizon_client::telemetry::mark_rest_ready();
|
||||
horizon_client::play_session::rest_ready();
|
||||
log::info!(
|
||||
"Horizon: rest-ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
|
||||
@@ -292,10 +292,8 @@ fn pack_particles(
|
||||
}
|
||||
|
||||
/// The packs' custom colours; their dye and sheep colours go to the draw paths' table.
|
||||
fn read_colors(root: &std::path::Path) -> horizon_client::polytone_colors::PackColors {
|
||||
let colors = horizon_client::polytone_colors::PackColors::read(root);
|
||||
horizon_client::pack_palette::set(&colors);
|
||||
horizon_client::pack_palette::set_item_bars(root);
|
||||
horizon_client::brewing::set_pack_colors(colors.effects());
|
||||
fn read_colors(root: &std::path::Path) -> horizon_client::pack_palette::PackColors {
|
||||
let colors = horizon_client::pack_palette::read(root);
|
||||
horizon_client::brewing::set_pack_colors(colors.effects.clone());
|
||||
colors
|
||||
}
|
||||
@@ -823,7 +823,7 @@ impl Game<'_> {
|
||||
for (location, rgba) in ready {
|
||||
self.store_skin(location, rgba);
|
||||
}
|
||||
self.client_hooks_frame();
|
||||
self.cosmetics_frame();
|
||||
}
|
||||
/// `Hud.extractTabList`: the player list while the player-list key is
|
||||
/// held. A local world shows it with a joined LAN guest or a `list`
|
||||
@@ -900,12 +900,10 @@ impl Game<'_> {
|
||||
let board = &self.score.board;
|
||||
let objective = board.display_objective(sb::SLOT_LIST);
|
||||
let up = |id: u128| upside_down.contains(&id);
|
||||
// A hook's mark before a name (Horizon's: they play Horizon).
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let marked = horizon_hooks::engine::player_mark;
|
||||
// Horizon's mark before the names of Horizon players (the cosmetics service's cache).
|
||||
let horizon = self.horizon_players(players.iter().map(|p| p.uuid));
|
||||
let horizon_mark = super::cosmetics::MARK;
|
||||
let marked = |id: u128| horizon.contains(&id).then_some(horizon_mark);
|
||||
let inputs = TabInputs {
|
||||
players: &players,
|
||||
board,
|
||||
|
||||
@@ -359,12 +359,19 @@ impl Game<'_> {
|
||||
self.sculk.tracker = WardenSpawnTracker::from_nbt(tag);
|
||||
}
|
||||
}
|
||||
/// Write the tracker into the player data being saved.
|
||||
/// The tracker as saved, once it has been loaded.
|
||||
pub(super) fn warden_tracker_tag(&self) -> Option<horizon_world::nbt::Tag> {
|
||||
self.sculk
|
||||
.tracker_loaded
|
||||
.then(|| self.sculk.tracker.to_nbt())
|
||||
}
|
||||
/// Write the tracker into the player data being saved. With a LAN owner,
|
||||
/// `store_owner` rewrites that from the owner's guest, so it puts the
|
||||
/// tracker there as well (`playerdata::with_warden`).
|
||||
pub(super) fn save_warden_tracker(&mut self) {
|
||||
if !self.sculk.tracker_loaded {
|
||||
let Some(tag) = self.warden_tracker_tag() else {
|
||||
return;
|
||||
}
|
||||
let tag = self.sculk.tracker.to_nbt();
|
||||
};
|
||||
let extra = self
|
||||
.level
|
||||
.player_extra
|
||||
|
||||
@@ -261,8 +261,6 @@ impl<'a> Split<'a> {
|
||||
.get(i.wrapping_sub(1))
|
||||
.map(|g| g.options.clone()),
|
||||
);
|
||||
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
|
||||
horizon_hooks::engine::set_seat(seat - 1);
|
||||
self.turn = i;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,6 @@ impl Split<'_> {
|
||||
// Until it has a profile, a scratch options file: never P1's.
|
||||
let options = Self::folder()?.join(".joining/options.txt");
|
||||
OPTIONS.set(Some(options.clone()));
|
||||
horizon_hooks::engine::set_seat(seat - 1);
|
||||
let mut settings = self.host.settings.clone();
|
||||
if pad.is_some() {
|
||||
// The Controller setting is P1's own: a player who pressed a pad to join plays
|
||||
@@ -136,7 +135,6 @@ impl Split<'_> {
|
||||
}
|
||||
let game = self.start_guest(seat, &options, settings);
|
||||
OPTIONS.set(None);
|
||||
horizon_hooks::engine::set_seat(0);
|
||||
let mut game = game?;
|
||||
if let Some(pad) = pad {
|
||||
// The press that joined it doesn't act in its game.
|
||||
@@ -301,31 +299,16 @@ impl Split<'_> {
|
||||
}
|
||||
let profile = profiles.join(&name);
|
||||
let options = profile.join("options.txt");
|
||||
let made = if options.exists() {
|
||||
None
|
||||
let mut settings = if options.exists() {
|
||||
horizon_client::settings::Settings::load(&options)
|
||||
} else {
|
||||
std::fs::create_dir_all(&profile)?;
|
||||
let mut new = self.host.settings.clone();
|
||||
// The Controller setting is P1's, as on the profile page (`join`).
|
||||
new.controller.enabled |= self.guests[i].pad.is_some();
|
||||
// On P1's seat, so the crates' settings stores write P1's values too.
|
||||
let turn = horizon_hooks::seat();
|
||||
horizon_hooks::engine::set_seat(0);
|
||||
new.save(&options);
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
Some(new)
|
||||
new
|
||||
};
|
||||
// The crates' stores load into the guest's seat, before its turn starts below.
|
||||
let turn = horizon_hooks::seat();
|
||||
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
|
||||
let mut settings = match made {
|
||||
Some(new) => {
|
||||
horizon_client::settings::Settings::load_stores(&options);
|
||||
new
|
||||
}
|
||||
None => horizon_client::settings::Settings::load(&options),
|
||||
};
|
||||
horizon_hooks::engine::set_seat(turn);
|
||||
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
|
||||
settings.controller.enabled |= self.guests[i].pad.is_some();
|
||||
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
|
||||
|
||||
@@ -53,7 +53,7 @@ pub(super) struct StartupMenu {
|
||||
pub reload_packs: bool,
|
||||
/// Browse mods' worker (`mod_browser`).
|
||||
mod_jobs: super::mod_browser::Jobs,
|
||||
pub perf: horizon_client::telemetry::Session,
|
||||
pub perf: horizon_client::play_session::Session,
|
||||
}
|
||||
|
||||
impl StartupMenu {
|
||||
|
||||
@@ -304,7 +304,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
horizon_client::play_session::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
Ok(r) => {
|
||||
self.renderer = Some(r);
|
||||
|
||||
@@ -169,33 +169,33 @@ impl Game<'_> {
|
||||
/// Leaving the game with a world open (window closed, Quit, `quit`):
|
||||
/// menu items go back (Player.closeContainer), then the world is saved,
|
||||
/// once.
|
||||
/// Keeps this play session's performance summary for the launcher to send, if the player
|
||||
/// allows performance statistics (`horizon_client::telemetry`), and starts the next one.
|
||||
/// Ends this play session for the hooks (`horizon_client::play_session`): opt-in telemetry
|
||||
/// keeps its performance summary for the launcher to send, if the player allows it.
|
||||
pub(super) fn finish_perf(&mut self) {
|
||||
// One session per computer: P1's (a splitscreen player's would count it again).
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
let s = &self.settings;
|
||||
let settings = horizon_telemetry::payload::Settings {
|
||||
render_distance: s.render_distance.clamp(0, 255) as u8,
|
||||
simulation_distance: s.simulation_distance.clamp(0, 255) as u8,
|
||||
// 260 is the slider's "unlimited".
|
||||
max_fps: if s.max_fps >= 260 {
|
||||
0
|
||||
} else {
|
||||
s.max_fps.min(u32::from(u16::MAX)) as u16
|
||||
},
|
||||
vsync: s.vsync,
|
||||
fullscreen: s.fullscreen,
|
||||
graphics: s.graphics_preset.clone(),
|
||||
};
|
||||
// Again now: on Wayland the window is on a screen only once it has been shown.
|
||||
if let Some(window) = &self.window {
|
||||
horizon_client::telemetry::note_display(window);
|
||||
horizon_client::play_session::note_display(window);
|
||||
}
|
||||
self.perf
|
||||
.finish(&horizon_client::logging::game_home(), settings);
|
||||
let s = &self.settings;
|
||||
let home = horizon_client::logging::game_home();
|
||||
let version = horizon_client::crash::build();
|
||||
let gpu = horizon_client::crash::gpu();
|
||||
self.perf.finish(&horizon_hooks::WorldLeft {
|
||||
home: &home,
|
||||
version: &version,
|
||||
gpu: gpu.as_ref(),
|
||||
render_distance: s.render_distance,
|
||||
simulation_distance: s.simulation_distance,
|
||||
// 260 is the slider's "unlimited".
|
||||
max_fps: (s.max_fps < 260).then_some(s.max_fps),
|
||||
vsync: s.vsync,
|
||||
fullscreen: s.fullscreen,
|
||||
graphics: &s.graphics_preset,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn save_for_exit(&mut self) -> Result<()> {
|
||||
|
||||
@@ -53,6 +53,27 @@ pub fn exists(world: &Path, uuid: u128) -> bool {
|
||||
path(world, uuid).is_file()
|
||||
}
|
||||
|
||||
/// The key `ServerPlayer.addAdditionalSaveData` stores the `WardenSpawnTracker` under.
|
||||
pub const WARDEN_TRACKER_KEY: &str = "warden_spawn_tracker";
|
||||
|
||||
/// Put a player's `warden_spawn_tracker` into the compound `store` writes from
|
||||
/// (`guest.saved`), so a save keeps it rather than the older one loaded with
|
||||
/// the file (the LAN owner's tracker lives in `Game`, `save_warden_tracker`).
|
||||
/// Other saved keys are left as they are; no tracker (not loaded yet) changes
|
||||
/// nothing.
|
||||
pub fn with_warden(guest: &mut Guest, tracker: Option<Tag>) -> &mut Guest {
|
||||
let Some(tracker) = tracker else {
|
||||
return guest;
|
||||
};
|
||||
let saved = guest
|
||||
.saved
|
||||
.get_or_insert_with(|| Tag::Compound(Default::default()));
|
||||
if let Tag::Compound(map) = saved {
|
||||
map.insert(WARDEN_TRACKER_KEY.into(), tracker);
|
||||
}
|
||||
guest
|
||||
}
|
||||
|
||||
/// `PlayerDataStorage.load`: a guest's saved state, if they have been here
|
||||
/// before. The player's file, else its `.dat_old` (what a save cut off
|
||||
/// between its two renames leaves, `write_dat`).
|
||||
@@ -651,6 +672,42 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// #9965: the owner's warden warning level and cooldown reach the file on
|
||||
/// a save, instead of the ones the file was loaded with.
|
||||
#[test]
|
||||
fn a_stored_warden_tracker_replaces_the_loaded_one() {
|
||||
let mut g = guest();
|
||||
g.saved = Some(Tag::compound(vec![
|
||||
(
|
||||
"warden_spawn_tracker",
|
||||
Tag::compound(vec![
|
||||
("warning_level", Tag::Int(2)),
|
||||
("cooldown_ticks", Tag::Int(77)),
|
||||
("ticks_since_last_warning", Tag::Int(5)),
|
||||
]),
|
||||
),
|
||||
("unrelated_key", Tag::Int(9)),
|
||||
]));
|
||||
let tracker = crate::sculk::WardenSpawnTracker {
|
||||
ticks_since_last_warning: 0,
|
||||
warning_level: 3,
|
||||
cooldown_ticks: 200,
|
||||
};
|
||||
with_warden(&mut g, Some(tracker.to_nbt()));
|
||||
let (tag, _) = round_trip(&g);
|
||||
let mut back = guest();
|
||||
let mut level = Level::default();
|
||||
level.read_player(&tag);
|
||||
level.player_extra = Some(tag);
|
||||
apply(&mut back, &level);
|
||||
let saved = back.saved.as_ref().unwrap();
|
||||
let read =
|
||||
crate::sculk::WardenSpawnTracker::from_nbt(saved.get("warden_spawn_tracker").unwrap());
|
||||
assert_eq!((read.warning_level, read.cooldown_ticks), (3, 200));
|
||||
assert_eq!(read.ticks_since_last_warning, 0);
|
||||
assert!(matches!(saved.get("unrelated_key"), Some(Tag::Int(9))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_player_faces_the_world_spawn_angle() {
|
||||
let mut g = guest();
|
||||
|
||||
@@ -771,9 +771,26 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
|
||||
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
|
||||
);
|
||||
}
|
||||
/// A pose laid over the player model's body after its vanilla animation, and the parts it poses:
|
||||
/// the hook's own types (`horizon_hooks::players`), kept here under their old path.
|
||||
pub use horizon_hooks::players::{BODY_PARTS, BodyPose};
|
||||
/// The player model's parts a [`BodyPose`] poses, in its order.
|
||||
pub const BODY_PARTS: [&str; 6] = [
|
||||
"head",
|
||||
"body",
|
||||
"right_arm",
|
||||
"left_arm",
|
||||
"right_leg",
|
||||
"left_leg",
|
||||
];
|
||||
|
||||
/// A pose a higher layer lays over the player model's body after its vanilla animation (Horizon's
|
||||
/// emotes, docs/cosmetics.md), handed to the model through [`Mob::emote`]. The engine names no
|
||||
/// type of that layer (docs/layers.md); it downcasts its own back through [`std::any::Any`].
|
||||
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
|
||||
/// Poses the [`BODY_PARTS`] at `age` (the mob's ticks): `at_rest` holds each part before the
|
||||
/// animation, `poses` as the animation left it; a part the model lacks holds the identity in
|
||||
/// both and is not written back. False when it poses nothing at `age` (`poses` is then
|
||||
/// ignored).
|
||||
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
|
||||
}
|
||||
|
||||
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
|
||||
pub fn setup(
|
||||
|
||||
@@ -855,9 +855,11 @@ pub fn texture(m: &Mob) -> String {
|
||||
"red",
|
||||
"black",
|
||||
];
|
||||
match COLORS.get(m.extra.color as usize) {
|
||||
Some(c) => format!("entity/shulker/shulker_{c}"),
|
||||
None => "entity/shulker/shulker".into(),
|
||||
// Shulker.getColor: the saved byte is signed; 16 and above are undyed, and
|
||||
// DyeColor.byId sends a negative id to white (OutOfBoundsStrategy.ZERO).
|
||||
match m.extra.color as i8 {
|
||||
16.. => "entity/shulker/shulker".into(),
|
||||
c => format!("entity/shulker/shulker_{}", COLORS[c.max(0) as usize]),
|
||||
}
|
||||
}
|
||||
/// `attachFace.getOpposite().getRotation()` by attach face: the open
|
||||
@@ -1144,3 +1146,46 @@ mod tests {
|
||||
assert!((h - 1.0).abs() < 1e-9, "{h}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_bytes_tests {
|
||||
use super::*;
|
||||
|
||||
/// `Shulker.readAdditionalSaveData`: `AttachFace` is
|
||||
/// `Direction.LEGACY_ID_CODEC` (`from3DDataValue` wraps the id through
|
||||
/// `abs(id % 6)`), while `Peek` and `Color` are raw `getByteOr` bytes. The
|
||||
/// load clamped all three to their setters' ranges instead.
|
||||
#[test]
|
||||
fn a_shulker_reads_its_face_and_lid_bytes_like_vanilla() {
|
||||
use crate::entity_nbt::mob_from_nbt;
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let k = std::sync::Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:shulker".into();
|
||||
k.species = Species::Shulker;
|
||||
let types = [("minecraft:shulker".into(), kind)].into();
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String("minecraft:shulker".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
Tag::Double(64.0f64.to_bits()),
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("AttachFace", Tag::Byte(7)),
|
||||
("Peek", Tag::Byte(-1)),
|
||||
("Color", Tag::Byte(-1)),
|
||||
]);
|
||||
let m = mob_from_nbt(&tag, &types, 1, None).unwrap();
|
||||
assert_eq!(m.extra.variant3, 1, "abs(7 % 6) is up");
|
||||
assert_eq!(m.extra.counter, -1, "the peek byte is kept");
|
||||
assert_eq!(m.extra.color, 255, "the color byte is kept");
|
||||
assert_eq!(
|
||||
texture(&m),
|
||||
"entity/shulker/shulker_white",
|
||||
"DyeColor.byId(-1)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2148,18 +2148,16 @@ impl MotionWorld<'_, '_, '_> {
|
||||
let gravity = living_motion::effective_gravity(self)?;
|
||||
self.player.velocity = fall_flying_movement(v, self.player.pitch, self.player.yaw, gravity);
|
||||
let movement = self.player.velocity;
|
||||
let before = self.player.position;
|
||||
self.move_self(movement)?;
|
||||
// LivingEntity.handleFallFlyingCollisions: the fall-damage sound plays
|
||||
// on both sides (a glider's own travel is client-side, and the sound
|
||||
// must reach its own ears, #8906), only the server hurts, and it
|
||||
// compares the delta before the move with the one after it, which
|
||||
// `Entity.move` leaves as the movement it resolved. `move_entity` does
|
||||
// not write that back, so the distance actually travelled stands in.
|
||||
// compares the horizontal speed before `updateFallFlyingMovement`
|
||||
// with the delta `Entity.move` leaves (the collided axes zeroed).
|
||||
if self.player.collision.horizontal {
|
||||
let travelled = self.player.position.subtract(before);
|
||||
let new_h = (travelled.x * travelled.x + travelled.z * travelled.z).sqrt();
|
||||
let dmg = fly_into_wall_damage(move_h, new_h);
|
||||
let v = self.player.velocity;
|
||||
let after_h = (v.x * v.x + v.z * v.z).sqrt();
|
||||
let dmg = fly_into_wall_damage(move_h, after_h);
|
||||
if dmg > 0.0 {
|
||||
let first = self.player.events.len();
|
||||
// getFallDamageSound((int) dmg)
|
||||
@@ -3148,6 +3146,7 @@ mod tests {
|
||||
assert_eq!(fly_into_wall_damage(0.3, 0.0), 0.0);
|
||||
assert!((fly_into_wall_damage(0.5, 0.1) - 1.0).abs() < 1e-5);
|
||||
assert!((fly_into_wall_damage(1.2, 0.0) - 9.0).abs() < 1e-5);
|
||||
assert!(fly_into_wall_damage(0.01, 0.0) < 0.0);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
@@ -4195,17 +4194,16 @@ mod tests {
|
||||
health: &resources.health,
|
||||
};
|
||||
w.travel_fall_flying().unwrap();
|
||||
assert!(
|
||||
w.player.events.iter().any(|e| matches!(
|
||||
let is_fall = |e: &_| {
|
||||
matches!(
|
||||
e,
|
||||
MotionEvent::Fall(crate::landing::FallEvent::Sound {
|
||||
sound: crate::landing::SceneSound::SmallFall,
|
||||
..
|
||||
})
|
||||
)),
|
||||
"{:?}",
|
||||
w.player.events
|
||||
);
|
||||
MotionEvent::Fall(crate::landing::FallEvent::Sound { .. })
|
||||
)
|
||||
};
|
||||
assert_eq!(w.player.events.iter().filter(|e| is_fall(e)).count(), 1);
|
||||
// The sound must play only once on impact, not repeat on subsequent ticks (#12304):
|
||||
w.travel_fall_flying().unwrap();
|
||||
assert_eq!(w.player.events.iter().filter(|e| is_fall(e)).count(), 1);
|
||||
assert!(
|
||||
!w.player
|
||||
.hurt_log
|
||||
|
||||
@@ -149,20 +149,8 @@ pub struct Session {
|
||||
far: Arc<crate::lod_net::Inbox>,
|
||||
/// The recording running, if any.
|
||||
recorder: RecorderSlot,
|
||||
/// This connection's number, new for each one ([`Session::serial`]).
|
||||
serial: u64,
|
||||
}
|
||||
/// The next [`Session::serial`].
|
||||
static NEXT_SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
fn next_serial() -> u64 {
|
||||
NEXT_SERIAL.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
impl Session {
|
||||
/// A number of its own for each connection, never 0: entity ids are per connection, so what
|
||||
/// is kept by id goes when it changes (`horizon_hooks::players::PlayerModels::connection`).
|
||||
pub fn serial(&self) -> u64 {
|
||||
self.serial
|
||||
}
|
||||
/// The negotiated wire release, once the connection has been established.
|
||||
pub fn server_version(&self) -> horizon_protocol::adapter::ServerVersion {
|
||||
self.writer.lock().unwrap().as_ref().map_or(
|
||||
@@ -268,7 +256,6 @@ impl Session {
|
||||
debug: false,
|
||||
far: Arc::new(crate::lod_net::Inbox::default()),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
}
|
||||
}
|
||||
fn start(
|
||||
@@ -346,7 +333,6 @@ impl Session {
|
||||
debug,
|
||||
far,
|
||||
recorder,
|
||||
serial: next_serial(),
|
||||
}
|
||||
}
|
||||
/// Everything received since the last poll.
|
||||
@@ -1843,7 +1829,6 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
if explicit_disconnect {
|
||||
@@ -1947,7 +1932,6 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let (thread_writer, thread_configuring, thread_recorder) = (
|
||||
session.writer.clone(),
|
||||
@@ -2231,7 +2215,6 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let recorder = TestRecorder::default();
|
||||
session.start_recording(recorder.clone(), |_| {});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! A play session as the hooks above the engine hear it (`horizon_hooks`): the title screen's
|
||||
//! first frame, a world opening and its first frame, the frames played, and leaving the world.
|
||||
//! It also marks those moments in the startup trace (`spans::startup`). Opt-in telemetry's
|
||||
//! performance summary is built from these hooks (docs/telemetry.md).
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use horizon_hooks::{Frame, Milestone, WorldLeft, engine};
|
||||
|
||||
use crate::spans::{self, startup};
|
||||
|
||||
/// One window's session: moved from the startup menu to the game with the window.
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
/// When the world being opened started to, until its first frame.
|
||||
opening: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The title screen drew a frame: the first time, how long the start took.
|
||||
pub fn title_shown(&mut self) {
|
||||
static SHOWN: AtomicBool = AtomicBool::new(false);
|
||||
if !SHOWN.swap(true, Ordering::Relaxed) {
|
||||
startup::mark("title.shown");
|
||||
// The frame itself: the menu bundle can be ready seconds before the renderer that
|
||||
// draws it (pipelines compiling on a slow driver).
|
||||
engine::startup(Milestone::TitleShown, startup::since_start());
|
||||
}
|
||||
}
|
||||
|
||||
/// A world starts loading.
|
||||
pub fn world_opening(&mut self) {
|
||||
if self.opening.is_none() {
|
||||
startup::arm("world.opening");
|
||||
}
|
||||
self.opening.get_or_insert_with(Instant::now);
|
||||
}
|
||||
|
||||
/// An entry queued during startup was cancelled before opening a world.
|
||||
pub fn cancel_world_opening(&mut self) {
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
|
||||
/// recorded in it ([`spans::take_raw`]): the step it spent most of its time in is
|
||||
/// [`spans::culprit`].
|
||||
pub fn frame(&mut self, ms: f32, frame_spans: &[spans::Span]) {
|
||||
if let Some(opening) = self.opening.take() {
|
||||
startup::mark("world.first_frame");
|
||||
engine::world_shown(opening.elapsed());
|
||||
}
|
||||
engine::frame(&Frame::new(ms, &|| spans::culprit(frame_spans)));
|
||||
}
|
||||
|
||||
/// The player left the world: the session ends.
|
||||
pub fn finish(&mut self, left: &WorldLeft<'_>) {
|
||||
engine::world_left(left);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells the hooks the screen `window` is on ([`crate::window::display`]).
|
||||
pub fn note_display(window: &winit::window::Window) {
|
||||
if let Some(d) = crate::window::display(window) {
|
||||
engine::display(&horizon_hooks::Display {
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
refresh_hz: d.refresh_hz,
|
||||
scale: d.scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources the menus need are loaded.
|
||||
pub fn core_ready() {
|
||||
engine::startup(Milestone::CoreReady, startup::since_start());
|
||||
}
|
||||
|
||||
/// What a world needs is loaded too.
|
||||
pub fn rest_ready() {
|
||||
engine::startup(Milestone::RestReady, startup::since_start());
|
||||
}
|
||||
@@ -70,8 +70,7 @@ struct Shared {
|
||||
/// The game's handle. Dropping it stops the thread.
|
||||
pub struct Cosmetics {
|
||||
shared: Arc<Shared>,
|
||||
/// Behind a lock so one client can be shared (`Arc<Cosmetics>` is `Send + Sync`).
|
||||
worker: Mutex<Option<Box<dyn FnMut() + Send>>>,
|
||||
worker: Option<Box<dyn FnMut() + Send>>,
|
||||
}
|
||||
|
||||
impl Cosmetics {
|
||||
@@ -79,12 +78,7 @@ impl Cosmetics {
|
||||
/// `cache` when given.
|
||||
pub fn new(transport: impl Transport, cache: Option<PathBuf>) -> Self {
|
||||
let mut c = Self::manual(transport, cache, Arc::new(Instant::now));
|
||||
let mut work = c
|
||||
.worker
|
||||
.get_mut()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take()
|
||||
.expect("worker");
|
||||
let mut work = c.worker.take().expect("worker");
|
||||
let shared = c.shared.clone();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Cosmetics".into())
|
||||
@@ -131,7 +125,7 @@ impl Cosmetics {
|
||||
});
|
||||
Self {
|
||||
shared,
|
||||
worker: Mutex::new(Some(worker)),
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ fn clock() -> (Clock, Arc<AtomicU64>) {
|
||||
}
|
||||
|
||||
fn run(c: &mut Cosmetics) {
|
||||
(c.worker.get_mut().unwrap().as_mut().unwrap())();
|
||||
(c.worker.as_mut().unwrap())();
|
||||
}
|
||||
|
||||
fn ids(worn: Option<Worn>) -> Vec<String> {
|
||||
|
||||
+179
-276
@@ -19,14 +19,14 @@
|
||||
|
||||
pub mod hud;
|
||||
pub mod pages;
|
||||
pub mod players;
|
||||
mod registry;
|
||||
pub mod settings;
|
||||
pub mod tunnel;
|
||||
|
||||
use registry::Registry;
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What a crate above the engine hears from the game. Every method has an empty default:
|
||||
/// implement only those you need. Hooks run on the game's thread (the render thread) unless a
|
||||
@@ -44,43 +44,34 @@ pub trait Hooks: Sync {
|
||||
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
|
||||
fn shutdown(&self) {}
|
||||
|
||||
/// Once a frame, before drawing: `sprites` takes the pictures the crate's
|
||||
/// [`players::Sprites`] lacked. Only a window's game calls it (each splitscreen player in
|
||||
/// turn, [`seat`]), never the integrated server's thread or a dedicated server; so are the
|
||||
/// player methods below (not [`Hooks::tunnel`]).
|
||||
fn frame(&self, _frame: &players::Frame, _sprites: &mut dyn players::SpriteStore) {}
|
||||
/// A moment of the game's start, once each, with the time since the process started.
|
||||
fn startup(&self, _milestone: Milestone, _since_start: Duration) {}
|
||||
|
||||
/// Once a client tick while a world is shown: the player models in view.
|
||||
fn tick(&self, _models: &mut dyn players::PlayerModels) {}
|
||||
|
||||
/// What goes by a player model's name over its head, each frame its name tag is drawn. The
|
||||
/// first hook that answers is used.
|
||||
fn name_decoration(
|
||||
&self,
|
||||
_model: &players::PlayerModel,
|
||||
_sprites: &dyn players::Sprites,
|
||||
) -> Option<players::NameDecoration> {
|
||||
None
|
||||
/// Whether this hook wants the render thread to time its steps in every frame, for
|
||||
/// [`Frame::slowest_step`]. Asked once as the game starts (not on a dedicated server);
|
||||
/// `home` is the game's home folder. Timing the steps costs microseconds a frame.
|
||||
fn wants_frame_steps(&self, _home: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The mark before the name of the player `uuid` in the player list, each frame it shows.
|
||||
/// The first hook that answers is used.
|
||||
fn player_mark(&self, _uuid: u128) -> Option<players::Mark> {
|
||||
None
|
||||
}
|
||||
/// A world's first frame was drawn: the time since it started opening (single player, a
|
||||
/// server, LAN). Every time a world is entered.
|
||||
fn world_shown(&self, _since_opening: Duration) {}
|
||||
|
||||
/// How the camera stands in third person, each frame. The first hook that answers is used.
|
||||
fn camera(&self, _own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
None
|
||||
}
|
||||
/// A frame drawn while playing: not in the menus or while a world loads. Every frame.
|
||||
fn frame(&self, _frame: &Frame<'_>) {}
|
||||
|
||||
/// Draws on a player model, after its vanilla layers, each time it is drawn.
|
||||
fn player_layers(&self, _model: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
|
||||
/// The player left a world (to the title screen, or by quitting): the end of a play
|
||||
/// session, with the settings it was played with.
|
||||
fn world_left(&self, _left: &WorldLeft<'_>) {}
|
||||
|
||||
/// Draws around the window's own model while its body is out of view (first person), once a
|
||||
/// frame after the first-person model: [`players::Pass::FirstPerson`].
|
||||
fn first_person(&self, _own: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
|
||||
/// The screen the window is on: when the window opens, and again as a session ends (on
|
||||
/// Wayland a window is on a screen only once it has been shown).
|
||||
fn display(&self, _display: &Display) {}
|
||||
|
||||
/// The game crashed, once, before its report is saved in `crash-reports/`. Called on the
|
||||
/// thread that crashed, whatever the mode (a dedicated server too).
|
||||
fn crashed(&self, _crash: &Crash<'_>) {}
|
||||
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
|
||||
/// offers one is used. Asked on the network thread when joining and on the game thread when
|
||||
/// an address is shown, never per frame.
|
||||
@@ -89,6 +80,106 @@ pub trait Hooks: Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphics adapter the renderer runs on.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Gpu {
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu` or `other`.
|
||||
pub kind: String,
|
||||
pub driver: String,
|
||||
/// `metal`, `vulkan`, `dx12` or `gl`.
|
||||
pub backend: String,
|
||||
}
|
||||
|
||||
/// The screen a window is on ([`Hooks::display`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Display {
|
||||
/// Physical pixels.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// `None` where the OS doesn't say.
|
||||
pub refresh_hz: Option<u32>,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// What [`Hooks::crashed`] gets: the crash report's parts.
|
||||
pub struct Crash<'a> {
|
||||
/// The game's home folder.
|
||||
pub home: &'a Path,
|
||||
/// The build: the release (`v0.2.3`), or `dev <crate version>` for a build from source.
|
||||
pub version: &'a str,
|
||||
/// The error's message.
|
||||
pub error: &'a str,
|
||||
/// Where it happened and how it got there: one frame a line.
|
||||
pub stack: &'a str,
|
||||
pub thread: &'a str,
|
||||
/// The report's details (`Horizon Version`, `World`, `Server`...), as written in it.
|
||||
pub details: &'a [(String, String)],
|
||||
/// The renderer's adapter, once it has one.
|
||||
pub gpu: Option<&'a Gpu>,
|
||||
}
|
||||
|
||||
/// A moment of the game's start ([`Hooks::startup`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Milestone {
|
||||
/// The resources the menus need are loaded.
|
||||
CoreReady,
|
||||
/// The rest (what a world needs) too.
|
||||
RestReady,
|
||||
/// The title screen drew its first frame.
|
||||
TitleShown,
|
||||
}
|
||||
|
||||
impl Milestone {
|
||||
fn bit(self) -> u8 {
|
||||
match self {
|
||||
Self::CoreReady => 1,
|
||||
Self::RestReady => 2,
|
||||
Self::TitleShown => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Hooks::frame`] gets.
|
||||
pub struct Frame<'a> {
|
||||
/// How long the frame took.
|
||||
pub ms: f32,
|
||||
slowest_step: &'a dyn Fn() -> Option<(String, f32)>,
|
||||
}
|
||||
|
||||
impl<'a> Frame<'a> {
|
||||
/// `slowest_step` gives [`Frame::slowest_step`], only when asked.
|
||||
pub fn new(ms: f32, slowest_step: &'a dyn Fn() -> Option<(String, f32)>) -> Self {
|
||||
Self { ms, slowest_step }
|
||||
}
|
||||
|
||||
/// The step the frame spent the most time in (`render/terrain`) and its milliseconds, when
|
||||
/// the steps were timed ([`Hooks::wants_frame_steps`]). Worked out on each call: ask only
|
||||
/// for a slow frame.
|
||||
pub fn slowest_step(&self) -> Option<(String, f32)> {
|
||||
(self.slowest_step)()
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Hooks::world_left`] gets: the game's home folder, the build, the adapter and the
|
||||
/// settings played with.
|
||||
pub struct WorldLeft<'a> {
|
||||
pub home: &'a Path,
|
||||
/// As [`Crash::version`].
|
||||
pub version: &'a str,
|
||||
/// The renderer's adapter.
|
||||
pub gpu: Option<&'a Gpu>,
|
||||
pub render_distance: i32,
|
||||
pub simulation_distance: i32,
|
||||
/// `None`: unlimited.
|
||||
pub max_fps: Option<u32>,
|
||||
pub vsync: bool,
|
||||
pub fullscreen: bool,
|
||||
/// The graphics preset's name.
|
||||
pub graphics: &'a str,
|
||||
}
|
||||
|
||||
/// What [`Hooks::started`] gets.
|
||||
#[non_exhaustive]
|
||||
pub struct Started<'a> {
|
||||
@@ -128,27 +219,10 @@ fn all() -> &'static [&'static dyn Hooks] {
|
||||
HOOKS.all()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SEAT: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The splitscreen player whose turn it is: 0 for P1 (and without splitscreen), 1 for P2...
|
||||
/// Splitscreen players are one process, each `Game` in turn on the game's thread, so a crate's
|
||||
/// statics are shared by them: a crate that keeps per-player state (settings, pages, HUD) indexes
|
||||
/// it by this. A player keeps its seat while it plays. Other threads always see 0.
|
||||
pub fn seat() -> usize {
|
||||
SEAT.get()
|
||||
}
|
||||
|
||||
/// The engine's side: where it calls the hooks. Only the engine calls these.
|
||||
pub mod engine {
|
||||
use super::*;
|
||||
|
||||
/// The player whose turn starts on this thread ([`seat`]).
|
||||
pub fn set_seat(seat: usize) {
|
||||
SEAT.set(seat);
|
||||
}
|
||||
|
||||
/// Seals every list (hooks and the registries); their first use does it anyway. Call where
|
||||
/// registration ends.
|
||||
pub fn seal() {
|
||||
@@ -186,57 +260,57 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any hook is registered: without one the engine builds no view to hand them.
|
||||
pub fn any() -> bool {
|
||||
!all().is_empty()
|
||||
/// The milestones already said.
|
||||
static MILESTONES: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// [`Hooks::startup`], the first time `milestone` is reached; later calls do nothing.
|
||||
pub fn startup(milestone: Milestone, since_start: Duration) {
|
||||
if MILESTONES.fetch_or(milestone.bit(), Ordering::Relaxed) & milestone.bit() == 0 {
|
||||
for hooks in all() {
|
||||
hooks.startup(milestone, since_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::wants_frame_steps`]: whether any hook wants them.
|
||||
pub fn wants_frame_steps(home: &Path) -> bool {
|
||||
all().iter().any(|h| h.wants_frame_steps(home))
|
||||
}
|
||||
|
||||
/// [`Hooks::world_shown`].
|
||||
pub fn world_shown(since_opening: Duration) {
|
||||
for hooks in all() {
|
||||
hooks.world_shown(since_opening);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::frame`].
|
||||
pub fn frame(frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
|
||||
pub fn frame(frame: &Frame<'_>) {
|
||||
for hooks in all() {
|
||||
hooks.frame(frame, sprites);
|
||||
hooks.frame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::name_decoration`].
|
||||
pub fn name_decoration(
|
||||
model: &players::PlayerModel,
|
||||
sprites: &dyn players::Sprites,
|
||||
) -> Option<players::NameDecoration> {
|
||||
all().iter().find_map(|h| h.name_decoration(model, sprites))
|
||||
}
|
||||
|
||||
/// [`Hooks::player_mark`].
|
||||
pub fn player_mark(uuid: u128) -> Option<players::Mark> {
|
||||
all().iter().find_map(|h| h.player_mark(uuid))
|
||||
}
|
||||
|
||||
/// [`Hooks::player_layers`].
|
||||
pub fn player_layers(model: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
/// [`Hooks::world_left`].
|
||||
pub fn world_left(left: &WorldLeft<'_>) {
|
||||
for hooks in all() {
|
||||
hooks.player_layers(model, draw);
|
||||
hooks.world_left(left);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::first_person`].
|
||||
pub fn first_person(own: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
/// [`Hooks::display`].
|
||||
pub fn display(display: &Display) {
|
||||
for hooks in all() {
|
||||
hooks.first_person(own, draw);
|
||||
hooks.display(display);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::camera`].
|
||||
pub fn camera(own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
all().iter().find_map(|h| h.camera(own))
|
||||
}
|
||||
|
||||
/// [`Hooks::tick`].
|
||||
pub fn tick(models: &mut dyn players::PlayerModels) {
|
||||
/// [`Hooks::crashed`].
|
||||
pub fn crashed(crash: &Crash<'_>) {
|
||||
for hooks in all() {
|
||||
hooks.tick(models);
|
||||
hooks.crashed(crash);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::tunnel`]: the first registered hook's resolver.
|
||||
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
|
||||
all().iter().find_map(|hooks| hooks.tunnel())
|
||||
@@ -246,7 +320,6 @@ pub mod engine {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use players::PlayerModels as _;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
/// Records what it heard. One process-wide list, as the game has: this test is the only
|
||||
@@ -263,168 +336,23 @@ mod tests {
|
||||
fn published(&self, open: bool) {
|
||||
self.0.lock().unwrap().push(format!("published {open}"));
|
||||
}
|
||||
fn frame(&self, frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
|
||||
fn startup(&self, milestone: Milestone, since_start: Duration) {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("frame {}", frame.connection));
|
||||
if !sprites.has(1) {
|
||||
sprites.put(1, vec![0; 64 * 64 * 4]);
|
||||
}
|
||||
.push(format!("{milestone:?} {}", since_start.as_millis()));
|
||||
}
|
||||
fn name_decoration(
|
||||
&self,
|
||||
model: &players::PlayerModel,
|
||||
sprites: &dyn players::Sprites,
|
||||
) -> Option<players::NameDecoration> {
|
||||
let badge = sprites.get(1).map(|uv| players::Badge::new(uv, 0xFF0000));
|
||||
(model.kind == players::Kind::Player)
|
||||
.then(|| players::NameDecoration::new(Some(MARK), badge, Some(2.0)))
|
||||
fn wants_frame_steps(&self, _home: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn player_mark(&self, uuid: u128) -> Option<players::Mark> {
|
||||
(uuid == 9).then_some(MARK)
|
||||
}
|
||||
fn camera(&self, own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
Some(players::CameraOverride::new(own.age / 10.0))
|
||||
}
|
||||
/// A hat on the head, its brim turned by the model's age.
|
||||
fn player_layers(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
let (Some(head), Some(uv)) = (draw.bone(players::Bone::Head), draw.sprite(1)) else {
|
||||
return;
|
||||
};
|
||||
let turn = draw.ticks() / 100.0;
|
||||
let look = players::Look::new(uv);
|
||||
draw.draw(&HAT, head, look, &mut |p| p.pose("brim")[4] = turn);
|
||||
}
|
||||
/// A spark at the eyes, as bright as the head turns.
|
||||
fn first_person(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
let alpha = draw.motion().head_yaw / 90.0;
|
||||
let spark = players::Particle::new([[0.0; 3]; 4], [[0.0; 2]; 4], alpha);
|
||||
draw.particles([0.0; 4], &[spark], players::Light::Full);
|
||||
}
|
||||
fn tick(&self, models: &mut dyn players::PlayerModels) {
|
||||
let mut ids = Vec::new();
|
||||
models.for_each(&mut |m| ids.push(m.id));
|
||||
for id in ids {
|
||||
models.set_body_pose(id, Some(std::sync::Arc::new(Still)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Still;
|
||||
impl players::BodyPose for Still {
|
||||
fn pose(&self, _: f32, _: &[[f32; 9]; 6], _: &mut [[f32; 9]; 6]) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Two models, poses by id.
|
||||
struct Two(std::collections::BTreeMap<u32, std::sync::Arc<dyn players::BodyPose>>);
|
||||
impl players::PlayerModels for Two {
|
||||
fn connection(&self) -> u64 {
|
||||
1
|
||||
}
|
||||
fn for_each(&self, f: &mut dyn FnMut(players::PlayerModel)) {
|
||||
for id in [3, 7] {
|
||||
f(players::PlayerModel::new(
|
||||
id,
|
||||
players::Kind::Player,
|
||||
None,
|
||||
0,
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
fn body_pose(&self, id: u32) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
|
||||
self.0.get(&id)
|
||||
}
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<std::sync::Arc<dyn players::BodyPose>>) {
|
||||
match pose {
|
||||
Some(p) => self.0.insert(id, p),
|
||||
None => self.0.remove(&id),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const MARK: players::Mark = &[(0, 0, 8, 8, 0xFF00_00FF)];
|
||||
|
||||
/// One sprite slot, as the game's atlas has many.
|
||||
#[derive(Default)]
|
||||
struct Atlas(Option<(u64, usize)>);
|
||||
impl players::SpriteStore for Atlas {
|
||||
fn has(&self, id: u64) -> bool {
|
||||
self.0.is_some_and(|(i, _)| i == id)
|
||||
}
|
||||
fn put(&mut self, id: u64, rgba: Vec<u8>) {
|
||||
self.0 = Some((id, rgba.len()));
|
||||
}
|
||||
}
|
||||
impl players::Sprites for Atlas {
|
||||
fn get(&self, id: u64) -> Option<[f32; 4]> {
|
||||
players::SpriteStore::has(self, id).then_some([0.0, 0.0, 0.5, 0.5])
|
||||
}
|
||||
}
|
||||
|
||||
static HAT: std::sync::LazyLock<std::sync::Arc<players::Mesh>> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
let brim = players::Mesh::new("brim".into(), [0.0; 9], Vec::new(), Vec::new());
|
||||
std::sync::Arc::new(players::Mesh::new(
|
||||
"hat".into(),
|
||||
[0.0; 9],
|
||||
Vec::new(),
|
||||
vec![brim],
|
||||
))
|
||||
});
|
||||
|
||||
/// A player drawn at age 50, its head where the camera is, turned 45 degrees; logs each draw
|
||||
/// and its particles' alphas.
|
||||
#[derive(Default)]
|
||||
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>, Vec<f32>);
|
||||
impl players::PlayerDraw for Canvas {
|
||||
fn pass(&self) -> players::Pass {
|
||||
players::Pass::World
|
||||
}
|
||||
fn ticks(&self) -> f32 {
|
||||
50.0
|
||||
}
|
||||
fn base(&self) -> [[f32; 4]; 4] {
|
||||
[[1.0, 0.0, 0.0, 0.0]; 4]
|
||||
}
|
||||
fn motion(&self) -> players::Motion {
|
||||
players::Motion::new([0.0; 3], 45.0, 0.0)
|
||||
}
|
||||
fn view(&self) -> players::View {
|
||||
players::View::default()
|
||||
}
|
||||
fn bone(&mut self, bone: players::Bone) -> Option<[[f32; 4]; 4]> {
|
||||
(bone == players::Bone::Head).then_some([[1.0, 0.0, 0.0, 0.0]; 4])
|
||||
}
|
||||
fn body_pose(&self) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
|
||||
None
|
||||
}
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
|
||||
(id == 1).then_some([0.0, 0.0, 0.5, 0.5])
|
||||
}
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &std::sync::Arc<players::Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
look: players::Look,
|
||||
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
|
||||
) {
|
||||
struct Rest([f32; 9]);
|
||||
impl players::PartPoses for Rest {
|
||||
fn pose(&mut self, _: &str) -> &mut [f32; 9] {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
let mut brim = Rest(mesh.children[0].pose);
|
||||
pose(&mut brim);
|
||||
self.0.push((mesh.name.clone(), matrix, look.uv, brim.0[4]));
|
||||
}
|
||||
fn particles(&mut self, _: [f32; 4], particles: &[players::Particle], _: players::Light) {
|
||||
self.1.extend(particles.iter().map(|p| p.alpha));
|
||||
fn frame(&self, frame: &Frame<'_>) {
|
||||
let step = frame
|
||||
.slowest_step()
|
||||
.map(|(name, ms)| format!(" {name} {ms}"));
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("frame {}{}", frame.ms, step.unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,48 +371,23 @@ mod tests {
|
||||
for open in [false, true, true, true, false, false] {
|
||||
engine::published(open);
|
||||
}
|
||||
// Each milestone once: the first time it is reached.
|
||||
engine::startup(Milestone::CoreReady, Duration::from_millis(900));
|
||||
engine::startup(Milestone::TitleShown, Duration::from_millis(1500));
|
||||
engine::startup(Milestone::CoreReady, Duration::from_millis(4000));
|
||||
// Any hook that wants the steps timed has them.
|
||||
assert!(engine::wants_frame_steps(Path::new("home")));
|
||||
engine::frame(&Frame::new(70.0, &|| Some(("render/terrain".into(), 50.0))));
|
||||
engine::shutdown();
|
||||
assert!(engine::any());
|
||||
let mut atlas = Atlas::default();
|
||||
let model = |kind| players::PlayerModel::new(5, kind, Some(9), 0, false);
|
||||
// Before the frame put the sprite in: the decoration without its badge.
|
||||
let before = engine::name_decoration(&model(players::Kind::Player), &atlas).unwrap();
|
||||
assert!(before.badge.is_none() && before.top == Some(2.0));
|
||||
engine::frame(&players::Frame::new(4, false), &mut atlas);
|
||||
assert_eq!(atlas.0, Some((1, 64 * 64 * 4)));
|
||||
let after = engine::name_decoration(&model(players::Kind::Player), &atlas).unwrap();
|
||||
assert_eq!(
|
||||
after.badge.map(|b| (b.uv, b.line)),
|
||||
Some(([0.0, 0.0, 0.5, 0.5], 0xFF0000))
|
||||
);
|
||||
assert!(engine::name_decoration(&model(players::Kind::Mannequin), &atlas).is_none());
|
||||
assert_eq!(engine::player_mark(9), Some(MARK));
|
||||
assert_eq!(engine::player_mark(8), None);
|
||||
let camera = engine::camera(&players::CameraView::new(5.0));
|
||||
assert_eq!(camera.map(|c| c.distance), Some(0.5));
|
||||
let mut canvas = Canvas::default();
|
||||
engine::player_layers(&model(players::Kind::Player), &mut canvas);
|
||||
assert_eq!(
|
||||
canvas.0,
|
||||
[(
|
||||
"hat".to_owned(),
|
||||
[[1.0, 0.0, 0.0, 0.0]; 4],
|
||||
[0.0, 0.0, 0.5, 0.5],
|
||||
0.5
|
||||
)]
|
||||
);
|
||||
engine::first_person(&model(players::Kind::Own), &mut canvas);
|
||||
assert_eq!(canvas.1, [0.5]);
|
||||
let mut models = Two(Default::default());
|
||||
engine::tick(&mut models);
|
||||
assert!(models.body_pose(3).is_some() && models.body_pose(7).is_some());
|
||||
assert_eq!(
|
||||
*RECORDER.0.lock().unwrap(),
|
||||
[
|
||||
"started 0.2.31",
|
||||
"published true",
|
||||
"published false",
|
||||
"frame 4"
|
||||
"CoreReady 900",
|
||||
"TitleShown 1500",
|
||||
"frame 70 render/terrain 50",
|
||||
]
|
||||
);
|
||||
assert_eq!(register(&Silent), Err(TooLate));
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
//! The players the game shows, as a crate above the engine sees them (Horizon's emotes, spec
|
||||
//! #14091): [`PlayerModels`] in [`crate::Hooks::tick`], [`Frame`] in [`crate::Hooks::frame`],
|
||||
//! [`BodyPose`], a pose laid over a player's body after its vanilla animation, what goes by their
|
||||
//! names, and the crate's [`Sprites`]. Plain types only: no engine type crosses this line.
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The player model's parts a [`BodyPose`] poses, in its order.
|
||||
pub const BODY_PARTS: [&str; 6] = [
|
||||
"head",
|
||||
"body",
|
||||
"right_arm",
|
||||
"left_arm",
|
||||
"right_leg",
|
||||
"left_leg",
|
||||
];
|
||||
|
||||
/// A pose a crate lays over a player model's body after its vanilla animation (Horizon's emotes,
|
||||
/// docs/cosmetics.md), handed to the model with [`PlayerModels::set_body_pose`]. The crate
|
||||
/// downcasts its own back through [`std::any::Any`].
|
||||
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
|
||||
/// Poses the [`BODY_PARTS`] at `age` (the model's ticks): `at_rest` holds each part before the
|
||||
/// animation, `poses` as the animation left it (x, y, z, x/y/z rotation, x/y/z scale); a part
|
||||
/// the model lacks holds the identity in both and is not written back. False when it poses
|
||||
/// nothing at `age` (`poses` is then ignored).
|
||||
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
|
||||
}
|
||||
|
||||
/// What a player model is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Kind {
|
||||
/// The player of this window, seen from outside (third person, the inventory picture).
|
||||
Own,
|
||||
/// Another player.
|
||||
Player,
|
||||
/// A mannequin: a player model with no player.
|
||||
Mannequin,
|
||||
}
|
||||
|
||||
/// One player model, as [`PlayerModels::for_each`] shows it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct PlayerModel {
|
||||
/// Its entity id on this connection ([`PlayerModels::connection`]).
|
||||
pub id: u32,
|
||||
pub kind: Kind,
|
||||
/// The player's UUID as the server gave it (the window's own: its profile); None for
|
||||
/// mannequins.
|
||||
pub uuid: Option<u128>,
|
||||
/// Its age in ticks, the clock a [`BodyPose`] is timed on.
|
||||
pub age: u32,
|
||||
/// It moved sideways since the last tick (more than 0.01 blocks).
|
||||
pub moved: bool,
|
||||
/// What it wears that covers a crate's drawing.
|
||||
pub gear: Gear,
|
||||
/// It is drawn invisible (the effect, or a trader's disguise).
|
||||
pub invisible: bool,
|
||||
}
|
||||
|
||||
impl PlayerModel {
|
||||
pub fn new(id: u32, kind: Kind, uuid: Option<u128>, age: u32, moved: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
kind,
|
||||
uuid,
|
||||
age,
|
||||
moved,
|
||||
gear: Gear::default(),
|
||||
invisible: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// With what it wears and whether it is invisible.
|
||||
pub fn looks(self, gear: Gear, invisible: bool) -> Self {
|
||||
Self {
|
||||
gear,
|
||||
invisible,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a player model wears where a crate may draw.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct Gear {
|
||||
/// A helmet or a head item.
|
||||
pub head: bool,
|
||||
/// A chestplate (an elytra is on the back).
|
||||
pub chest: bool,
|
||||
/// A cape or an elytra.
|
||||
pub back: bool,
|
||||
}
|
||||
|
||||
impl Gear {
|
||||
pub fn new(head: bool, chest: bool, back: bool) -> Self {
|
||||
Self { head, chest, back }
|
||||
}
|
||||
}
|
||||
|
||||
/// The player models of the world this window shows, once a tick ([`crate::Hooks::tick`]).
|
||||
pub trait PlayerModels {
|
||||
/// Changes on every join: entity ids are per connection, so what a crate keeps by id goes
|
||||
/// when this changes.
|
||||
fn connection(&self) -> u64;
|
||||
/// Each player model in view (other players and mannequins; the window's own model first).
|
||||
fn for_each(&self, f: &mut dyn FnMut(PlayerModel));
|
||||
/// The pose laid over model `id`, if any.
|
||||
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>>;
|
||||
/// Lays `pose` over model `id` (None: its vanilla animation alone). Unknown ids are ignored.
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>);
|
||||
}
|
||||
|
||||
/// What [`crate::Hooks::frame`] gets, once a frame before drawing.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Frame {
|
||||
/// As [`PlayerModels::connection`]; 0 with no world.
|
||||
pub connection: u64,
|
||||
/// The world is the window's own single-player one: its server names the player by an
|
||||
/// offline profile.
|
||||
pub solo: bool,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new(connection: u64, solo: bool) -> Self {
|
||||
Self { connection, solo }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rectangles drawn before a name: x, y, width, height in text pixels from the name line's top
|
||||
/// left corner (a pixel left of its background), and `0xAARRGGBB`.
|
||||
pub type Mark = &'static [(u8, u8, u8, u8, u32)];
|
||||
|
||||
/// What a crate draws by a player's name over their head ([`crate::Hooks::name_decoration`]).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct NameDecoration {
|
||||
/// Before the name.
|
||||
pub mark: Option<Mark>,
|
||||
/// After the name, and a line under it.
|
||||
pub badge: Option<Badge>,
|
||||
/// How high the crate's drawing on the body gets above the neck, in model pixels: the tag
|
||||
/// goes up over it.
|
||||
pub top: Option<f32>,
|
||||
}
|
||||
|
||||
impl NameDecoration {
|
||||
pub fn new(mark: Option<Mark>, badge: Option<Badge>, top: Option<f32>) -> Self {
|
||||
Self { mark, badge, top }
|
||||
}
|
||||
}
|
||||
|
||||
/// A badge after a name: 8 text pixels square, a pixel right of the background.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Badge {
|
||||
/// Its picture: a part of a [`Sprites`] sprite, in atlas coordinates (u0, v0, u1, v1).
|
||||
pub uv: [f32; 4],
|
||||
/// The line under the name, `0xRRGGBB`.
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
impl Badge {
|
||||
pub fn new(uv: [f32; 4], line: u32) -> Self {
|
||||
Self { uv, line }
|
||||
}
|
||||
}
|
||||
|
||||
/// A crate's pictures in the game's atlas, by ids of its own: 64×64 RGBA each, in the slots skins
|
||||
/// use (one no frame drew for a while may go). Read while drawing.
|
||||
pub trait Sprites {
|
||||
/// Where sprite `id` is (u0, v0, u1, v1), and it is kept this frame. None until
|
||||
/// [`SpriteStore::put`] put it there.
|
||||
fn get(&self, id: u64) -> Option<[f32; 4]>;
|
||||
}
|
||||
|
||||
/// Where a crate puts its [`Sprites`], in [`crate::Hooks::frame`].
|
||||
pub trait SpriteStore {
|
||||
/// Sprite `id` is in the atlas.
|
||||
fn has(&self, id: u64) -> bool;
|
||||
/// Puts sprite `id` in the atlas (64×64 RGBA), if a slot is free.
|
||||
fn put(&mut self, id: u64, rgba: Vec<u8>);
|
||||
}
|
||||
|
||||
/// The window's own model, as [`crate::Hooks::camera`] sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct CameraView {
|
||||
/// Its age in ticks, with the frame's part of a tick.
|
||||
pub age: f32,
|
||||
}
|
||||
|
||||
impl CameraView {
|
||||
pub fn new(age: f32) -> Self {
|
||||
Self { age }
|
||||
}
|
||||
}
|
||||
|
||||
/// How a crate moves the camera in third person.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct CameraOverride {
|
||||
/// How far it stands back, as a share of the usual distance.
|
||||
pub distance: f32,
|
||||
}
|
||||
|
||||
impl CameraOverride {
|
||||
pub fn new(distance: f32) -> Self {
|
||||
Self { distance }
|
||||
}
|
||||
}
|
||||
|
||||
/// A model a crate draws on players, as the game's own models are: a tree of parts, 16 units a
|
||||
/// block, y down. Build it once and keep the `Arc`: the engine prepares it for the GPU once, by
|
||||
/// the `Arc`'s address, while the crate holds it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct Mesh {
|
||||
/// What [`PartPoses::pose`] names it by.
|
||||
pub name: String,
|
||||
/// At rest: x, y, z, x/y/z rotation (radians), x/y/z scale.
|
||||
pub pose: [f32; 9],
|
||||
pub quads: Vec<Quad>,
|
||||
pub children: Vec<Mesh>,
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
pub fn new(name: String, pose: [f32; 9], quads: Vec<Quad>, children: Vec<Mesh>) -> Self {
|
||||
Self {
|
||||
name,
|
||||
pose,
|
||||
quads,
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One face of a [`Mesh`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Quad {
|
||||
pub normal: [f32; 3],
|
||||
/// Its corners: x, y, z, then u, v across the sprite (0 to 1).
|
||||
pub vertices: [[f32; 5]; 4],
|
||||
}
|
||||
|
||||
impl Quad {
|
||||
pub fn new(normal: [f32; 3], vertices: [[f32; 5]; 4]) -> Self {
|
||||
Self { normal, vertices }
|
||||
}
|
||||
}
|
||||
|
||||
/// A part of the player's body a crate draws on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Bone {
|
||||
Head,
|
||||
Body,
|
||||
RightArm,
|
||||
LeftArm,
|
||||
RightLeg,
|
||||
LeftLeg,
|
||||
}
|
||||
|
||||
impl Bone {
|
||||
/// Its name among [`BODY_PARTS`].
|
||||
pub fn part(self) -> &'static str {
|
||||
BODY_PARTS[self as usize]
|
||||
}
|
||||
}
|
||||
|
||||
/// Which picture of a player is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Pass {
|
||||
/// In the world, seen by the camera (the window's own model too, in third person).
|
||||
World,
|
||||
/// The inventory's picture of the window's own model.
|
||||
Picture,
|
||||
/// The window's own model while its body is out of view (first person): no bone, only what
|
||||
/// floats around it ([`crate::Hooks::first_person`]).
|
||||
FirstPerson,
|
||||
}
|
||||
|
||||
/// How a player model moves this frame ([`PlayerDraw::motion`]), with the frame's part of a tick.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct Motion {
|
||||
/// Where its feet are, in the world.
|
||||
pub feet: [f64; 3],
|
||||
/// Where its head turns (degrees, the game's yaw and pitch).
|
||||
pub head_yaw: f32,
|
||||
pub head_pitch: f32,
|
||||
pub crouching: bool,
|
||||
/// Lies in a bed.
|
||||
pub asleep: bool,
|
||||
/// Flies with an elytra.
|
||||
pub flying: bool,
|
||||
/// Swims (any part of the swimming pose).
|
||||
pub swimming: bool,
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
pub fn new(feet: [f64; 3], head_yaw: f32, head_pitch: f32) -> Self {
|
||||
Self {
|
||||
feet,
|
||||
head_yaw,
|
||||
head_pitch,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// With its poses: crouching, asleep, flying, swimming.
|
||||
pub fn poses(self, crouching: bool, asleep: bool, flying: bool, swimming: bool) -> Self {
|
||||
Self {
|
||||
crouching,
|
||||
asleep,
|
||||
flying,
|
||||
swimming,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera a player model is drawn for ([`PlayerDraw::view`]).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct View {
|
||||
/// Where it is, in the world: matrices and [`Particle`] corners are relative to it.
|
||||
pub camera: [f64; 3],
|
||||
/// Its right and up, for what faces it.
|
||||
pub right: [f32; 3],
|
||||
pub up: [f32; 3],
|
||||
/// Where the window's player looks, and their level left (first person).
|
||||
pub forward: [f32; 3],
|
||||
pub left: [f32; 3],
|
||||
}
|
||||
|
||||
impl View {
|
||||
pub fn new(camera: [f64; 3], right: [f32; 3], up: [f32; 3]) -> Self {
|
||||
Self {
|
||||
camera,
|
||||
right,
|
||||
up,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// With where the window's player looks and their level left.
|
||||
pub fn looking(self, forward: [f32; 3], left: [f32; 3]) -> Self {
|
||||
Self {
|
||||
forward,
|
||||
left,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a mesh or particles are lit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Light {
|
||||
/// As the player model is.
|
||||
Model,
|
||||
/// Fully lit: it glows.
|
||||
Full,
|
||||
/// By the world where the mesh's origin is (as particles: like [`Light::Model`]).
|
||||
AtPlace,
|
||||
}
|
||||
|
||||
/// How [`PlayerDraw::draw`] draws a mesh.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Look {
|
||||
/// The sprite part it is textured by (u0, v0, u1, v1).
|
||||
pub uv: [f32; 4],
|
||||
pub light: Light,
|
||||
/// Drawn see-through at this alpha, after every player, its back faces dropped.
|
||||
pub see_through: Option<f32>,
|
||||
}
|
||||
|
||||
impl Look {
|
||||
/// Opaque, lit as the model.
|
||||
pub fn new(uv: [f32; 4]) -> Self {
|
||||
Self {
|
||||
uv,
|
||||
light: Light::Model,
|
||||
see_through: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lit(self, light: Light) -> Self {
|
||||
Self { light, ..self }
|
||||
}
|
||||
|
||||
pub fn see_through(self, alpha: f32) -> Self {
|
||||
Self {
|
||||
see_through: Some(alpha),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A quad that faces the camera ([`PlayerDraw::particles`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Particle {
|
||||
/// Its corners, relative to the camera.
|
||||
pub corners: [[f32; 3]; 4],
|
||||
/// Each corner's place across the sprite part (0 to 1).
|
||||
pub uv: [[f32; 2]; 4],
|
||||
pub alpha: f32,
|
||||
}
|
||||
|
||||
impl Particle {
|
||||
pub fn new(corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], alpha: f32) -> Self {
|
||||
Self { corners, uv, alpha }
|
||||
}
|
||||
}
|
||||
|
||||
/// The poses of a [`Mesh`]'s parts for one [`PlayerDraw::draw`], each at rest to begin with.
|
||||
pub trait PartPoses {
|
||||
/// Part `name`'s pose, to change (see [`Mesh::pose`]); a name the mesh lacks gets a pose that
|
||||
/// is not used.
|
||||
fn pose(&mut self, name: &str) -> &mut [f32; 9];
|
||||
}
|
||||
|
||||
/// Where a crate draws on a player model ([`crate::Hooks::player_layers`]). Matrices are
|
||||
/// column-major, relative to the camera, in blocks.
|
||||
pub trait PlayerDraw {
|
||||
fn pass(&self) -> Pass;
|
||||
/// The model's age in ticks, with the frame's part of a tick.
|
||||
fn ticks(&self) -> f32;
|
||||
/// The model's own matrix, its origin at the neck (y down, 16 units a block).
|
||||
fn base(&self) -> [[f32; 4]; 4];
|
||||
fn motion(&self) -> Motion;
|
||||
fn view(&self) -> View;
|
||||
/// Where `bone` is as the body is drawn this frame; None if the model has no such part (or
|
||||
/// no body is drawn: [`Pass::FirstPerson`]).
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]>;
|
||||
/// The pose laid over the model's body ([`PlayerModels::set_body_pose`]), if any.
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>>;
|
||||
/// As [`Sprites::get`].
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]>;
|
||||
/// Draws `mesh` at `matrix` as `look` says; `pose` poses its parts first.
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
look: Look,
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
);
|
||||
/// Draws `particles`, textured by the sprite part `uv`, see-through after every player.
|
||||
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ horizon-game = { path = "../horizon-game" }
|
||||
horizon-platform = { path = "../horizon-platform" }
|
||||
horizon-heartbeat = { path = "../horizon-heartbeat" }
|
||||
horizon-friends = { path = "../horizon-friends", features = ["game"] }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["game"] }
|
||||
log = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
|
||||
/// Every Horizon part in the game, in the order its hooks run.
|
||||
const PARTS: [&dyn Hooks; 2] = [
|
||||
const PARTS: [&dyn Hooks; 3] = [
|
||||
// The anonymous "I'm playing" ping.
|
||||
&horizon_heartbeat::HOOKS,
|
||||
// Play with Friends: `friend:` addresses join through its tunnel.
|
||||
&horizon_friends::game::HOOKS,
|
||||
// Opt-in telemetry: the play session's summary and crash reports, kept for the launcher.
|
||||
&horizon_telemetry::game::HOOKS,
|
||||
];
|
||||
|
||||
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
|
||||
|
||||
@@ -46,15 +46,9 @@ pub use horizon_server_files as server_files;
|
||||
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
|
||||
/// in"). The engine's dispatch side is not part of the API.
|
||||
pub mod hooks {
|
||||
/// The players the game shows (`horizon_hooks::players`): the views [`Hooks::frame`] and
|
||||
/// [`Hooks::tick`] get, the pose a crate lays over a player's body, what goes by their names,
|
||||
/// the crate's sprites, the camera, and what a crate draws on them.
|
||||
pub use horizon_hooks::players::{
|
||||
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Light,
|
||||
Look, Mark, Mesh, Motion, NameDecoration, PartPoses, Particle, Pass, PlayerDraw,
|
||||
PlayerModel, PlayerModels, Quad, SpriteStore, Sprites, View,
|
||||
pub use horizon_hooks::{
|
||||
Crash, Display, Frame, Gpu, Hooks, Milestone, Started, TooLate, WorldLeft, register, tunnel,
|
||||
};
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
|
||||
}
|
||||
|
||||
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
|
||||
|
||||
@@ -18,12 +18,16 @@ ruzstd = "0.9"
|
||||
getrandom = "0.3"
|
||||
# Sending, for the launcher (and the game's "I'm playing" ping); the ingest server builds without it.
|
||||
ureq = { version = "2", default-features = false, features = ["tls"], optional = true }
|
||||
log = { version = "0.4", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["upload", "live"]
|
||||
upload = ["dep:ureq"]
|
||||
# The "I'm playing" ping and reading the live count (`live`), for the game and the launcher.
|
||||
live = ["dep:ureq"]
|
||||
# The game's part (`game`): the play session's summary and crash reports, kept through the
|
||||
# platform's hooks. The official executable turns it on.
|
||||
game = ["dep:log"]
|
||||
|
||||
[[test]]
|
||||
name = "consent_gating"
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
//! The game's part in opt-in data collection (`horizon_telemetry`, docs/telemetry.md). When the
|
||||
//! player allows it, the game keeps a scrubbed crash report, or a play session's performance
|
||||
//! The game's part in opt-in data collection (docs/telemetry.md), behind the `game` feature. When
|
||||
//! the player allows it, the game keeps a scrubbed crash report, or a play session's performance
|
||||
//! summary, in the outbox. It never sends those itself: the launcher does, at its next start (the
|
||||
//! game's only sending is the anonymous "I'm playing" ping, `heartbeat.rs`).
|
||||
//! Without the player's yes for that kind nothing is kept, and crash-reports/ stays local.
|
||||
//! game's only sending is the anonymous "I'm playing" ping, `horizon-heartbeat`). Without the
|
||||
//! player's yes for that kind nothing is kept, and crash-reports/ stays local.
|
||||
//!
|
||||
//! It hears the game only through the platform's hooks ([`HOOKS`], `horizon_platform::hooks`);
|
||||
//! the official executable registers it (`horizon_official::PARTS`).
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
|
||||
use horizon_telemetry::scrub::Scrubber;
|
||||
use horizon_telemetry::{Item, Payload, Source, outbox};
|
||||
use horizon_platform::hooks::{self, Frame, Hooks, Milestone, WorldLeft};
|
||||
|
||||
use crate::payload::{Crash, Display, Perf, Settings, System, Timings};
|
||||
use crate::scrub::Scrubber;
|
||||
use crate::{Item, Payload, Source, outbox};
|
||||
|
||||
/// A session shorter than this says too little to keep.
|
||||
const MIN_SECONDS: f32 = 60.0;
|
||||
@@ -19,85 +24,115 @@ const MIN_SECONDS: f32 = 60.0;
|
||||
/// world, the server or the resource packs.
|
||||
const CRASH_DETAILS: [&str; 3] = ["Horizon Version", "Graphics Adapter", "Render Settings"];
|
||||
|
||||
static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
static CORE_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
/// The screen the window opened on.
|
||||
static DISPLAY: Mutex<Option<Display>> = Mutex::new(None);
|
||||
|
||||
/// When the game started, for the time to the title screen. Call first thing in `main`.
|
||||
pub fn mark_start() {
|
||||
STARTED.get_or_init(Instant::now);
|
||||
/// The game's hooks, for `horizon_platform::hooks::register`.
|
||||
pub static HOOKS: Telemetry = Telemetry {
|
||||
session: LazyLock::new(|| Mutex::new(Session::default())),
|
||||
};
|
||||
|
||||
/// The play session's performance, as the game's hooks tell it.
|
||||
pub struct Telemetry {
|
||||
session: LazyLock<Mutex<Session>>,
|
||||
}
|
||||
|
||||
pub fn mark_core_ready() {
|
||||
mark_ready(&CORE_READY_MS);
|
||||
}
|
||||
|
||||
pub fn mark_rest_ready() {
|
||||
mark_ready(&REST_READY_MS);
|
||||
}
|
||||
|
||||
fn mark_ready(mark: &AtomicU32) {
|
||||
if let Some(started) = STARTED.get() {
|
||||
let ms = started.elapsed().as_millis().min(u128::from(u32::MAX - 1)) as u32;
|
||||
let _ = mark.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
|
||||
impl Telemetry {
|
||||
fn session(&self) -> std::sync::MutexGuard<'_, Session> {
|
||||
self.session.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
impl Hooks for Telemetry {
|
||||
fn startup(&self, milestone: Milestone, since_start: Duration) {
|
||||
match milestone {
|
||||
Milestone::CoreReady => mark_ready(&CORE_READY_MS, since_start),
|
||||
Milestone::RestReady => mark_ready(&REST_READY_MS, since_start),
|
||||
Milestone::TitleShown => self.session().title_shown(since_start),
|
||||
}
|
||||
}
|
||||
|
||||
/// A player who allows performance statistics also says what their slow frames spent their
|
||||
/// time in.
|
||||
fn wants_frame_steps(&self, home: &Path) -> bool {
|
||||
crate::Consent::load(home).allows(crate::Kind::Perf)
|
||||
}
|
||||
|
||||
fn world_shown(&self, since_opening: Duration) {
|
||||
self.session().world_shown(since_opening);
|
||||
}
|
||||
|
||||
fn frame(&self, frame: &Frame<'_>) {
|
||||
self.session().frame(frame.ms, || frame.slowest_step());
|
||||
}
|
||||
|
||||
fn world_left(&self, left: &WorldLeft<'_>) {
|
||||
let settings = Settings {
|
||||
render_distance: left.render_distance.clamp(0, 255) as u8,
|
||||
simulation_distance: left.simulation_distance.clamp(0, 255) as u8,
|
||||
max_fps: left
|
||||
.max_fps
|
||||
.map_or(0, |f| f.min(u32::from(u16::MAX)) as u16),
|
||||
vsync: left.vsync,
|
||||
fullscreen: left.fullscreen,
|
||||
graphics: left.graphics.to_owned(),
|
||||
};
|
||||
self.session()
|
||||
.finish(left.home, left.version, left.gpu, settings);
|
||||
}
|
||||
|
||||
fn display(&self, display: &hooks::Display) {
|
||||
*DISPLAY.lock().unwrap_or_else(|e| e.into_inner()) = Some(Display {
|
||||
width: display.width,
|
||||
height: display.height,
|
||||
refresh_hz: display.refresh_hz.unwrap_or(0),
|
||||
scale: display.scale as f32,
|
||||
});
|
||||
}
|
||||
|
||||
fn crashed(&self, crash: &hooks::Crash<'_>) {
|
||||
keep_crash(crash);
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_ready(mark: &AtomicU32, since_start: Duration) {
|
||||
let ms = since_start.as_millis().min(u128::from(u32::MAX - 1)) as u32;
|
||||
let _ = mark.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn readiness_ms(mark: &AtomicU32) -> Option<u32> {
|
||||
let ms = mark.load(Ordering::Relaxed);
|
||||
(ms != u32::MAX).then_some(ms)
|
||||
}
|
||||
|
||||
/// The screen the window is on: size in physical pixels, refresh rate, scale.
|
||||
pub fn set_display(width: u32, height: u32, refresh_hz: u32, scale: f64) {
|
||||
*DISPLAY.lock().unwrap_or_else(|e| e.into_inner()) = Some(Display {
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
scale: scale as f32,
|
||||
});
|
||||
}
|
||||
|
||||
/// Keeps the screen `window` is on ([`crate::window::display`]) for the items' `system.display`.
|
||||
pub fn note_display(window: &winit::window::Window) {
|
||||
if let Some(d) = crate::window::display(window) {
|
||||
set_display(d.width, d.height, d.refresh_hz.unwrap_or(0), d.scale);
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine, with the GPU and the screen once the renderer knows them. The drive is the one
|
||||
/// This machine, with the GPU once the renderer has one and the screen. The drive is the one
|
||||
/// under the game folder (the current folder).
|
||||
pub fn system() -> System {
|
||||
pub fn system(gpu: Option<&hooks::Gpu>) -> System {
|
||||
let home = std::env::current_dir().ok();
|
||||
let mut system = System::detect_in(home.as_deref());
|
||||
if let Some(gpu) = crate::gfx::adapter() {
|
||||
if let Some(gpu) = gpu {
|
||||
system.vram_mb = match gpu.kind.as_str() {
|
||||
"discrete" => horizon_telemetry::system::vram_mb(&gpu.vendor).unwrap_or(0),
|
||||
"discrete" => crate::system::vram_mb(&gpu.vendor).unwrap_or(0),
|
||||
_ => 0,
|
||||
};
|
||||
system.gpu = gpu.name;
|
||||
system.gpu_vendor = gpu.vendor;
|
||||
system.gpu_kind = gpu.kind;
|
||||
system.driver = gpu.driver;
|
||||
system.backend = gpu.backend;
|
||||
system.gpu = gpu.name.clone();
|
||||
system.gpu_vendor = gpu.vendor.clone();
|
||||
system.gpu_kind = gpu.kind.clone();
|
||||
system.driver = gpu.driver.clone();
|
||||
system.backend = gpu.backend.clone();
|
||||
}
|
||||
system.display = DISPLAY.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
system
|
||||
}
|
||||
|
||||
/// The release (`v0.2.3`), or the client's crate version for a build from source.
|
||||
pub fn version() -> String {
|
||||
crate::crash::release_tag().unwrap_or_else(|| format!("dev {}", env!("CARGO_PKG_VERSION")))
|
||||
}
|
||||
|
||||
/// What a crash report becomes when it may leave: the error and stack, the thread, and the
|
||||
/// details in [`CRASH_DETAILS`], with the world's name and folder and the server's address
|
||||
/// scrubbed wherever they appear. No log lines.
|
||||
pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
pub fn crash_item(report: &hooks::Crash<'_>) -> Item {
|
||||
let mut scrubber = Scrubber::new();
|
||||
for (key, value) in &report.details {
|
||||
for (key, value) in report.details {
|
||||
match key.as_str() {
|
||||
// `Name (saves/folder)`, as game.rs sets it.
|
||||
"World" => {
|
||||
@@ -112,10 +147,10 @@ pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
}
|
||||
}
|
||||
let crash = Crash {
|
||||
system: system(),
|
||||
error: scrubber.scrub(&report.error),
|
||||
stack: scrubber.scrub(&report.stack),
|
||||
thread: report.thread.clone(),
|
||||
system: system(report.gpu),
|
||||
error: scrubber.scrub(report.error),
|
||||
stack: scrubber.scrub(report.stack),
|
||||
thread: report.thread.to_owned(),
|
||||
details: report
|
||||
.details
|
||||
.iter()
|
||||
@@ -123,7 +158,7 @@ pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
.map(|(k, v)| (k.clone(), scrubber.scrub(v)))
|
||||
.collect(),
|
||||
};
|
||||
let mut item = Item::new(Source::Game, version(), Payload::Crash(crash));
|
||||
let mut item = Item::new(Source::Game, report.version, Payload::Crash(crash));
|
||||
// A huge stack keeps its top, where the error is.
|
||||
while item.validate().is_err() {
|
||||
let Payload::Crash(crash) = &mut item.payload else {
|
||||
@@ -145,26 +180,12 @@ pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
}
|
||||
|
||||
/// Keeps the crash for the launcher to send, if the player allows crash reports.
|
||||
pub fn keep_crash(home: &Path, report: &crate::crash::Report) {
|
||||
if let Err(e) = outbox::push(home, &crash_item(report)) {
|
||||
pub fn keep_crash(report: &hooks::Crash<'_>) {
|
||||
if let Err(e) = outbox::push(report.home, &crash_item(report)) {
|
||||
log::warn!("Horizon: could not keep the crash report to send: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps a benchmark report (`--benchmark`) for the launcher to send, if the player allows
|
||||
/// benchmark uploads: the report as docs/benchmark-report.md defines it, dated to the day.
|
||||
pub fn keep_benchmark(home: &Path, report: &horizon_bench::Report) {
|
||||
let Ok(data) = serde_json::to_value(report.for_upload()) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = outbox::push(
|
||||
home,
|
||||
&Item::new(Source::Game, version(), Payload::Benchmark(data)),
|
||||
) {
|
||||
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One play session's performance, from entering a world to leaving it: frame and tick
|
||||
/// times as [`Timings`] (fixed size whatever the length), never the frames themselves.
|
||||
#[derive(Default)]
|
||||
@@ -176,42 +197,33 @@ pub struct Session {
|
||||
/// The start was measured: only the first session carries it.
|
||||
title_measured: bool,
|
||||
to_world_ms: Option<u32>,
|
||||
opening: Option<Instant>,
|
||||
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
|
||||
slow_frames: Vec<crate::payload::SlowFrames>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The title screen drew a frame: the first time, how long the start took.
|
||||
pub fn title_shown(&mut self) {
|
||||
/// The title screen drew its first frame, `since_start` after the game started.
|
||||
pub fn title_shown(&mut self, since_start: Duration) {
|
||||
if !self.title_measured {
|
||||
self.title_measured = true;
|
||||
crate::spans::startup::mark("title.shown");
|
||||
// The frame itself: the menu bundle can be ready seconds before
|
||||
// the renderer that draws it (pipelines compiling on a slow driver).
|
||||
self.to_title_ms = STARTED.get().map(|s| s.elapsed().as_millis() as u32);
|
||||
self.to_title_ms = Some(since_start.as_millis().min(u128::from(u32::MAX)) as u32);
|
||||
}
|
||||
}
|
||||
|
||||
/// A world starts loading.
|
||||
pub fn world_opening(&mut self) {
|
||||
if self.opening.is_none() {
|
||||
crate::spans::startup::arm("world.opening");
|
||||
/// A world's first frame, `since_opening` after it started opening: the session's first
|
||||
/// world counts.
|
||||
pub fn world_shown(&mut self, since_opening: Duration) {
|
||||
if self.to_world_ms.is_none() {
|
||||
self.to_world_ms = Some(since_opening.as_millis().min(u128::from(u32::MAX)) as u32);
|
||||
}
|
||||
self.opening.get_or_insert_with(Instant::now);
|
||||
}
|
||||
|
||||
/// An entry queued during startup was cancelled before opening a world.
|
||||
pub fn cancel_world_opening(&mut self) {
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
|
||||
/// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts
|
||||
/// under the step it spent most of its time in ([`crate::spans::culprit`]).
|
||||
pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) {
|
||||
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
/// A frame drawn while playing (not in menus or loading): a frame of `SLOW_FRAME_MS` or
|
||||
/// more counts under the step it spent most of its time in (`slowest_step`, the game's
|
||||
/// `spans::culprit`).
|
||||
pub fn frame(&mut self, ms: f32, slowest_step: impl FnOnce() -> Option<(String, f32)>) {
|
||||
use crate::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
if ms >= SLOW_FRAME_MS {
|
||||
let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default();
|
||||
let (mut path, span_ms) = slowest_step().unwrap_or_default();
|
||||
// A path too long for the item keeps its last steps.
|
||||
while path.len() > SlowFrames::MAX_NAME
|
||||
&& let Some((_, rest)) = path.split_once('/')
|
||||
@@ -220,12 +232,6 @@ impl Session {
|
||||
}
|
||||
SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms);
|
||||
}
|
||||
if let Some(opening) = self.opening.take() {
|
||||
crate::spans::startup::mark("world.first_frame");
|
||||
if self.to_world_ms.is_none() {
|
||||
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
|
||||
}
|
||||
}
|
||||
self.frames.record(ms);
|
||||
self.played_ms += ms;
|
||||
}
|
||||
@@ -236,10 +242,10 @@ impl Session {
|
||||
}
|
||||
|
||||
/// The session's summary, if it was long enough to say something.
|
||||
pub fn summary(&self, settings: Settings) -> Option<Perf> {
|
||||
pub fn summary(&self, gpu: Option<&hooks::Gpu>, settings: Settings) -> Option<Perf> {
|
||||
let seconds = self.played_ms / 1000.0;
|
||||
(seconds >= MIN_SECONDS).then(|| Perf {
|
||||
system: system(),
|
||||
system: system(gpu),
|
||||
settings,
|
||||
seconds: seconds as u32,
|
||||
frames: self.frames.summary(),
|
||||
@@ -255,11 +261,17 @@ impl Session {
|
||||
|
||||
/// Keeps the session's summary for the launcher to send, if the player allows performance
|
||||
/// statistics, and starts the next session (the time to the title goes with the first).
|
||||
pub fn finish(&mut self, home: &Path, settings: Settings) {
|
||||
if horizon_telemetry::Consent::load(home).allows(horizon_telemetry::Kind::Perf)
|
||||
&& let Some(perf) = self.summary(settings)
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
home: &Path,
|
||||
version: &str,
|
||||
gpu: Option<&hooks::Gpu>,
|
||||
settings: Settings,
|
||||
) {
|
||||
if crate::Consent::load(home).allows(crate::Kind::Perf)
|
||||
&& let Some(perf) = self.summary(gpu, settings)
|
||||
{
|
||||
let item = Item::new(Source::Game, version(), Payload::Perf(perf));
|
||||
let item = Item::new(Source::Game, version, Payload::Perf(perf));
|
||||
if let Err(e) = outbox::push(home, &item) {
|
||||
log::warn!("Horizon: could not keep the session's performance summary: {e}");
|
||||
}
|
||||
@@ -304,7 +316,7 @@ fn peak_ram_mb() -> u32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_telemetry::{Choices, Consent, Kind};
|
||||
use crate::{Choices, Consent, Kind};
|
||||
|
||||
fn home(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
@@ -322,16 +334,33 @@ mod tests {
|
||||
consent.save(home).unwrap();
|
||||
}
|
||||
|
||||
fn report() -> crate::crash::Report {
|
||||
crate::crash::Report {
|
||||
time: chrono::Local::now(),
|
||||
description: "Unexpected error".into(),
|
||||
/// A crash report's parts, as `crash.rs` gives them to the hooks.
|
||||
struct Report {
|
||||
error: String,
|
||||
stack: String,
|
||||
details: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn crash<'a>(&'a self, home: &'a Path) -> hooks::Crash<'a> {
|
||||
hooks::Crash {
|
||||
home,
|
||||
version: "v2026.10.15",
|
||||
error: &self.error,
|
||||
stack: &self.stack,
|
||||
thread: "Render thread",
|
||||
details: &self.details,
|
||||
gpu: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report() -> Report {
|
||||
Report {
|
||||
error:
|
||||
"panicked at src/save.rs:10:5:\nno level.dat in /Users/ana/horizon/saves/Ana's Base"
|
||||
.into(),
|
||||
stack: "horizon_client::save::load (crates/horizon-client/src/save.rs:10:5)\n".into(),
|
||||
thread: "Render thread".into(),
|
||||
side: "client",
|
||||
details: vec![
|
||||
(
|
||||
"Horizon Version".into(),
|
||||
@@ -345,13 +374,13 @@ mod tests {
|
||||
("Server".into(), "play.example.net:25565".into()),
|
||||
("Resource Packs".into(), "Ana's pack".into()),
|
||||
],
|
||||
log: vec!["[CHAT] <Ana> hi".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crash_keeps_only_what_says_what_happened() {
|
||||
let item = crash_item(&report());
|
||||
let report = report();
|
||||
let item = crash_item(&report.crash(Path::new("home")));
|
||||
let Payload::Crash(crash) = &item.payload else {
|
||||
panic!("a crash item");
|
||||
};
|
||||
@@ -367,13 +396,7 @@ mod tests {
|
||||
)]
|
||||
);
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
for private in [
|
||||
"Castle Ana",
|
||||
"Ana's Base",
|
||||
"example.net",
|
||||
"Ana's pack",
|
||||
"CHAT",
|
||||
] {
|
||||
for private in ["Castle Ana", "Ana's Base", "example.net", "Ana's pack"] {
|
||||
assert!(!json.contains(private), "{private} in {json}");
|
||||
}
|
||||
assert!(item.validate().is_ok());
|
||||
@@ -384,7 +407,7 @@ mod tests {
|
||||
let mut report = report();
|
||||
report.stack =
|
||||
"horizon_client::game::deep (crates/horizon-client/src/game.rs:1:1)\n".repeat(2000);
|
||||
let item = crash_item(&report);
|
||||
let item = crash_item(&report.crash(Path::new("home")));
|
||||
assert!(item.validate().is_ok());
|
||||
let Payload::Crash(crash) = item.payload else {
|
||||
panic!("a crash item");
|
||||
@@ -392,43 +415,11 @@ mod tests {
|
||||
assert!(crash.stack.lines().count() > 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benchmark_reports_need_their_own_consent_and_go_dated_to_the_day() {
|
||||
let home = home("benchmark");
|
||||
let mut report = horizon_bench::Report::new(horizon_bench::Kind::Game, Default::default());
|
||||
report.created = "2026-10-15T14:03:27Z".into();
|
||||
report.label = Some("my machine".into());
|
||||
keep_benchmark(&home, &report);
|
||||
allow(
|
||||
&home,
|
||||
Choices {
|
||||
performance: true,
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_benchmark(&home, &report);
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(
|
||||
&home,
|
||||
Choices {
|
||||
benchmarks: true,
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_benchmark(&home, &report);
|
||||
let pending = outbox::pending(&home);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let text = serde_json::to_string(&pending[0].1).unwrap();
|
||||
assert!(
|
||||
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashes_stay_local_without_consent() {
|
||||
let home = home("crash");
|
||||
keep_crash(&home, &report());
|
||||
let report = report();
|
||||
keep_crash(&report.crash(&home));
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(
|
||||
&home,
|
||||
@@ -437,7 +428,7 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_crash(&home, &report());
|
||||
keep_crash(&report.crash(&home));
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(
|
||||
&home,
|
||||
@@ -446,33 +437,38 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_crash(&home, &report());
|
||||
keep_crash(&report.crash(&home));
|
||||
let pending = outbox::pending(&home);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].1.kind(), Kind::Crash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_startup_entry_does_not_time_menu_browsing() {
|
||||
fn the_time_to_a_world_is_the_sessions_first() {
|
||||
let mut session = Session::default();
|
||||
session.world_opening();
|
||||
assert!(session.opening.is_some());
|
||||
session.cancel_world_opening();
|
||||
assert!(session.opening.is_none());
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_none());
|
||||
session.world_opening();
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_some());
|
||||
session.world_shown(Duration::from_millis(4200));
|
||||
session.world_shown(Duration::from_millis(900));
|
||||
assert_eq!(session.to_world_ms, Some(4200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_time_to_the_title_goes_with_the_first_session_only() {
|
||||
let home = home("title");
|
||||
let mut session = Session::default();
|
||||
session.title_shown(Duration::from_millis(3100));
|
||||
assert_eq!(session.to_title_ms, Some(3100));
|
||||
session.finish(&home, "dev", None, Settings::default());
|
||||
session.title_shown(Duration::from_millis(9000));
|
||||
assert_eq!(session.to_title_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_is_kept_only_with_consent_and_once_long_enough() {
|
||||
let home = home("session");
|
||||
let mut session = Session::default();
|
||||
session.world_opening();
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
session.world_shown(Duration::from_millis(1500));
|
||||
(0..2000).for_each(|_| session.frame(16.6, || None));
|
||||
session.finish(&home, "dev", None, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
|
||||
allow(
|
||||
&home,
|
||||
@@ -481,14 +477,16 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
(0..2000).for_each(|_| session.frame(16.6, || None));
|
||||
session.finish(&home, "dev", None, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
|
||||
session.world_opening();
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.world_shown(Duration::from_millis(1500));
|
||||
(0..4000).for_each(|_| session.frame(16.6, || None));
|
||||
(0..1200).for_each(|_| session.tick(4.0));
|
||||
session.finish(
|
||||
&home,
|
||||
"dev",
|
||||
None,
|
||||
Settings {
|
||||
render_distance: 12,
|
||||
..Settings::default()
|
||||
@@ -511,21 +509,15 @@ mod tests {
|
||||
/// span counts under none. Faster frames leave no entry.
|
||||
#[test]
|
||||
fn slow_frames_are_put_down_to_their_step() {
|
||||
use crate::spans::Span;
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
// What the game's `spans::culprit` names for each frame.
|
||||
let step = |path: &'static str, ms: f32| move || Some((path.to_owned(), ms));
|
||||
let mut session = Session::default();
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
|
||||
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
|
||||
session.frame(320.0, &frozen);
|
||||
session.frame(900.0, &frozen);
|
||||
session.frame(260.0, &[]);
|
||||
session.frame(249.0, &frozen);
|
||||
let perf = session.summary(Settings::default()).unwrap();
|
||||
(0..4000).for_each(|_| session.frame(16.6, step("tick", 10.0)));
|
||||
session.frame(320.0, step("renderer/r_uploads", 280.0));
|
||||
session.frame(900.0, step("renderer/r_uploads", 280.0));
|
||||
session.frame(260.0, || None);
|
||||
session.frame(249.0, step("renderer/r_uploads", 280.0));
|
||||
let perf = session.summary(None, Settings::default()).unwrap();
|
||||
let seen: Vec<_> = perf
|
||||
.slow_frames
|
||||
.iter()
|
||||
@@ -15,6 +15,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub mod batch;
|
||||
pub mod consent;
|
||||
#[cfg(feature = "game")]
|
||||
pub mod game;
|
||||
pub mod live;
|
||||
pub mod outbox;
|
||||
pub mod payload;
|
||||
|
||||
@@ -65,7 +65,7 @@ command above to inspect the selection rather than relying on historical counts.
|
||||
| 36 | Audio and music (38) | `R/audio.rs`, `R/sound_engine.rs`, `R/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle |
|
||||
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) |
|
||||
| 38 | Command parsing (34) | `C/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); `cargo test -p horizon-command` | command_parse (corpus `tools/command_parse_corpus.py`) |
|
||||
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `L/telemetry.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
|
||||
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `crates/horizon-telemetry/src/game.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
|
||||
| 40 | Explosions and TNT (31) | `G/explosion.rs`, `G/tnt.rs` | lan_e2e blast; no dedicated harness yet |
|
||||
|
||||
Controller glyphs (`R/pad_glyph.rs`, drawn by the gameplay guide, notices, splitscreen prompts and declared
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
|
||||
|
||||
**Translucent batching** (Advanced, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
|
||||
|
||||
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' luminance readings often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Line truncated
|
||||
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' SDR white is the player's own slider, but its peak is mostly the display's EDID figure, which often runs high: Auto still uses it, over the 1000-nit default, but the HDR Line truncated
|
||||
|
||||
With `HORIZON_HDR_FRAME=float`, HDR on and no shader pack or a 0.2 one, devices with independent blending draw the frame in layers ([hdr-linear-light.md](design/hdr-linear-light.md), The HUD and menus). The world's shaders write their own light into the frame instead of a mark (`hdr_written`, `hdr_light.wgsl`), a whole frame at a time, so glass, water, clouds and particles each lay their own light over the light behind them. Improved Transparency's layers write their own light too, before they are weighted and composited. No float frame holds marks: under a 0.2 pack Horizon's own draws keep their colour, alpha 1 where opaque and coverage where blended, HDR output lifts nothing, and the pack's `[tonemap]` takes the tone curve's place below (one or the other runs, never both); without the HUD layer (HDR off, or a device without independent blending) its float frame finishes on the swapchain's 8 bits before the camera overlays, which then draw with the HUD as in SDR, and HDR output stays off. The camera overlays (vignette, pumpkin, powder snow, portal, nausea), post effects and entity outlines draw on light. After the post effects and before the camera overlays, the tone curve eases that light into the peak (`Hdr::tonemap`) in place of HDR lighting's own easing and the light sources' cap at the peak: up to 60% of the peak light stays as it is, and above it ITU-R BT.2390's EETF in PQ rolls it off, on its brightest channel so it keeps its hue. The HUD and menus then draw on an 8-bit target of their own, the world clipped to white, as they found the frame before (each draw clips at white as there), and write how much of the world they hide into an R8 coverage target. The last pass lays the HUD over the world's light by that coverage, in gamma as the HUD blends: light behind see-through HUD shows as vanilla weights the HUD over it, and none shows through what covers it. The menu blur blurs that picture, light above white included, and the menu draws over the result. UI Brightness scales the HUD's own part, not the world under it. A screenshot maps the world's light into SDR, unchanged up to 0.6 of paper white in linear light and smoothly rolled towards white above it, with one scale for all RGB channels to keep their hue (where Horizon lifts nothing, with HDR Lighting off or under a 0.2 pack, the world is clipped at white as SDR draws it); the HUD's own part stays as drawn, whatever UI Brightness is, over the mapped world by its coverage. The world's icon uses the same mapping without the HUD; the `-hdr.png` beside a screenshot keeps the whole HDR picture. `HORIZON_HDR_DEBUG=coverage` tints the coverage magenta for inspection.
|
||||
|
||||
|
||||
+11
-6
@@ -288,12 +288,17 @@ build pays nothing.
|
||||
|
||||
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
|
||||
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
|
||||
world saved) and `tunnel` (the resolver of `friend:` addresses, Play with Friends'). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
|
||||
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
|
||||
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
|
||||
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
|
||||
that belongs to one subsystem (an input source, the compositor) lives in the engine crate that owns it, with the
|
||||
same shape: registered before the game runs, read-only after.
|
||||
world saved), and the play session's: `startup` (core-ready, rest-ready, the title's first frame, once each),
|
||||
`wants_frame_steps` (asked once: time the render thread's steps), `world_shown` (a world's first frame), `frame`
|
||||
(every frame played), `world_left` (the session's end, with its settings), `display` (the screen the window is on)
|
||||
and `crashed` (each crash, before its report is saved); and `tunnel` (the resolver of `friend:` addresses, Play
|
||||
with Friends'). Opt-in telemetry (`horizon_telemetry::game`, registered by `horizon-official`) hears the game
|
||||
through these only. **A hook you need and don't find:** add a method with an empty default to `Hooks` and a
|
||||
function in `horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data
|
||||
or a type the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a
|
||||
draw context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension
|
||||
point that belongs to one subsystem (an input source, the compositor) lives in the engine crate that owns it, with
|
||||
the same shape: registered before the game runs, read-only after.
|
||||
|
||||
**What players get.** The release builds (`game-build.yml`, so the nightly and the release; `tools/ci/build-musl.sh`)
|
||||
build `horizon-official` and ship it as `horizon-client`, the name the launcher and players know. The launcher's
|
||||
|
||||
@@ -462,17 +462,7 @@ impl Launcher {
|
||||
.into_any_element()
|
||||
};
|
||||
let about = card()
|
||||
.child(index_row(
|
||||
0,
|
||||
tr("Data folder"),
|
||||
Some(mono(short(&backend::data_dir()))),
|
||||
Some(
|
||||
button("about-open-data", "Open", Kind::Secondary, &t)
|
||||
.on_click(|_, _, cx| crate::glass_toast::open(cx, &backend::data_dir()))
|
||||
.into_any_element(),
|
||||
),
|
||||
&t,
|
||||
))
|
||||
.child(self.location_row(0, cx).mb(px(0.)))
|
||||
.child(index_row(
|
||||
0,
|
||||
tr("Logs"),
|
||||
|
||||
@@ -209,7 +209,18 @@ impl Launcher {
|
||||
n,
|
||||
tr("Data folder"),
|
||||
Some(div().font_family(theme::MONO).child(short(&backend::data_dir())).into_any_element()),
|
||||
Some(move_button.into_any_element()),
|
||||
Some(
|
||||
div()
|
||||
.flex()
|
||||
.gap(px(8.))
|
||||
.child(
|
||||
button("about-open-data", "Open", Kind::Secondary, &t).on_click(
|
||||
|_, _, cx| crate::glass_toast::open(cx, &backend::data_dir()),
|
||||
),
|
||||
)
|
||||
.child(move_button)
|
||||
.into_any_element(),
|
||||
),
|
||||
&t,
|
||||
))
|
||||
.child(text(theme::CAPTION).text_color(t.muted).child(tr(
|
||||
|
||||
@@ -46,7 +46,7 @@ Entity.dropAllLeashConnections port crates/horizon-client/src/game/leads.rs::Gam
|
||||
Entity.canCollideWith inline crates/horizon-client/src/game/ghasts.rs::Game::player_entity_collisions gap: player: the entities a player cannot walk through are boats and happy ghasts only; a live shulker (canBeCollidedWith) is not solid, so the player walks through it
|
||||
Entity.getDefaultPassengerAttachmentPoint inline crates/horizon-client/src/mobs/riders.rs::passenger_attachment one point per vehicle, so the passenger index is always 0; default is the top of the box
|
||||
Entity.ejectPassengers port crates/horizon-client/src/game/boat_passengers.rs::Game::eject_boat_mobs gap: minor: boats place riders at the dismount spot; when a mob vehicle dies sync_riders only lets go and the rider stays where it was seated
|
||||
Entity.removeVehicle inline crates/horizon-client/src/game/commands/world_cmds.rs::Game::dismount_mob gap: minor: no entity_dismount game event, so sculk sensors and wardens never hear a dismount
|
||||
Entity.removeVehicle port crates/horizon-client/src/game/sculk.rs::Game::mount_events `startRiding`/`removeVehicle`: `entity_mount` at the new vehicle and `entity_dismount` at the old one, derived from the vehicle the rider had last tick, for players and mobs
|
||||
Entity.moveOrInterpolateTo port crates/horizon-world/src/stepped_interpolation.rs::SteppedInterpolation::interpolate_to living entities from a server; boats and other objects use a three tick Lerp
|
||||
Entity.getHandHoldingItemAngle port crates/horizon-client/src/game/fireworks.rs::Game::rocket_hand_angle ticked in #3514; `Entity.getHandHoldingItemAngle` is cited at crates/horizon-client/src/game/fireworks.rs:241
|
||||
Entity.teleportToPortalDestination inline crates/horizon-client/src/game/nether_portal.rs::Game::send_through_portal may_enter checks isAllowedToEnterPortal; the entity is saved and waits for its dimension
|
||||
|
||||
|
@@ -62,7 +62,7 @@ LivingEntity.dropFromShearingLootTable port crates/horizon-client/src/game/creat
|
||||
LivingEntity.getFallDamageSound inline crates/horizon-client/src/mobs/landing.rs::MobLanding::living_fall_sound a monster gets entity.hostile.big_fall and entity.hostile.small_fall, anything else the generic pair; a player's comes from landing.rs
|
||||
LivingEntity.getHitbox missing minor: a rider's hitbox is not cut at its seat height; spear and melee tests use the full box
|
||||
LivingEntity.postPiercingAttack port crates/horizon-client/src/game/enchant_effects.rs::Game::post_piercing_attack Lunge's dash after a jab
|
||||
LivingEntity.trapdoorUsableAsLadder port crates/horizon-world/src/climbing.rs::on_climbable gap: minor: the player only; a mob's ai.rs::on_climbable checks the climbable tag at its feet, no open trapdoor over a ladder
|
||||
LivingEntity.trapdoorUsableAsLadder port crates/horizon-world/src/climbing.rs::on_climbable the climbable tag, or an open trapdoor directly over a ladder with the same facing: `climbing::on_climbable` for the player and `Mob::on_climbable` for mobs
|
||||
LivingEntity.isAlive port crates/horizon-client/src/mobs.rs::Mob::alive not removed and health above zero
|
||||
LivingEntity.setIgnoreFallDamageFromCurrentImpulse port crates/horizon-world/src/living_fall.rs::ImpulseContext::set_ignore_fall_damage 40 ticks of grace
|
||||
LivingEntity.getDamageAfterArmorAbsorb inline crates/horizon-client/src/mobs.rs::Mob::damage_with the player's armor wear is health.rs hurt_armor; the mob path also scales armor effectiveness (breach)
|
||||
|
||||
|
@@ -116,7 +116,7 @@ Ravager.strongKnockback inline crates/horizon-client/src/mobs/ravager.rs::roar t
|
||||
Ravager.addRoarParticleEffects inline crates/horizon-client/src/mobs/ravager.rs::roar the poof particles are emitted in roar
|
||||
Shulker.registerGoals port crates/horizon-client/src/mobs/shulker.rs::register gap: minor: no defense target goal (priority 3)
|
||||
Shulker.defineSynchedData skip:other attach face, peek and colour are Mob extra fields
|
||||
Shulker.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt AttachFace, Peek and Color are read there
|
||||
Shulker.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt AttachFace wraps through Direction.from3DDataValue, Peek and Color are raw getByteOr bytes
|
||||
Shulker.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt AttachFace, Peek and Color are written there
|
||||
Shulker.findNewAttachment inline crates/horizon-client/src/mobs/shulker.rs::tick keeps its face or teleports in tick, through attachable and teleport
|
||||
Shulker.makeBoundingBox port crates/horizon-client/src/mobs/shulker.rs::progress_aabb
|
||||
|
||||
|
@@ -31,7 +31,7 @@ Projectile.onAboveBubbleColumn port crates/horizon-client/src/projectiles.rs::Pr
|
||||
Projectile.onInsideBubbleColumn port crates/horizon-client/src/projectiles.rs::Projectile::bubble_column_push unclamped 0.06 up (0.03 down) inside a column; arrows in arrows.rs::Ticker::bubble_column
|
||||
Projectile.canBreakBlockInAdventureMode port crates/horizon-client/src/game/creatures.rs::Game::projectile_breaks_block gap: minor: the shooter's gamemode and kind decide it; a stack's `can_break` predicate is not consulted
|
||||
Projectile.spawnProjectileFromRotation port crates/horizon-client/src/projectiles.rs::Projectile::shoot_from_rotation gap: minor: the aim and inherited velocity only; the add-to-world step is the caller's
|
||||
Projectile.spawnProjectile inline crates/horizon-client/src/game/creatures.rs::Game::throw_item each shooter adds its own projectile (throw_item, shoot_crossbow, shoot_projectile_item, the bow in game.rs); throw_item is the player throw path
|
||||
Projectile.spawnProjectile inline crates/horizon-client/src/game/creatures.rs::Game::throw_item each shooter adds its own projectile (throw_item, shoot_crossbow, shoot_projectile_item, the bow in game/use_hand.rs::release_bow); throw_item is the player throw path
|
||||
Projectile.applyOnProjectileSpawned port crates/horizon-client/src/game/enchant_effects.rs::Game::ignite_spawned_arrow gap: minor: Flame ignite only; Piercing and Punch go through Projectile::set_bonus
|
||||
Projectile.hitTargetOrDeflectSelf port crates/horizon-client/src/game/creatures.rs::Game::tick_projectiles gap: minor: deflection is checked for breezes only; the onHit dispatch is creatures.rs projectile_hit
|
||||
Projectile.onRedirectProjectile port crates/horizon-client/src/game/deflect.rs::Game::deflect gap: minor: the player's attack turns the projectile; a projectile hitting another redirectable one is not modelled
|
||||
|
||||
|
Reference in new issue
Block a user