Merge pull request #2307 from socksprox/fix/server-pack-reload-progress
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head (checked against the head by contrib-gate-20). Re-review was asked about 07:30Z with no new objection. Proven in wave 5's union run (main + 54 PRs, 332/332 selected harnesses; lan_e2e 18/18 in-frame and on the server thread). Thank you!
This commit is contained in:
@@ -11031,6 +11031,21 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
// A server pack's reload covers the frame with its progress
|
||||
// (`LoadingOverlay`) until `tick_server_packs` applies it.
|
||||
if let Some(reload) = &self.server_packs.reload {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
horizon_client::level_loading::draw_progress(
|
||||
&mut hud,
|
||||
&self.gui.atlas,
|
||||
reload.progress.line(),
|
||||
reload.progress.fraction(),
|
||||
);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -12118,6 +12133,19 @@ impl ApplicationHandler for Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The pack reload's progress overlay takes no input
|
||||
// (`LoadingOverlay`): `start_pack_reload` released what was held.
|
||||
if self.server_packs.reload.is_some()
|
||||
&& !matches!(
|
||||
&event,
|
||||
WindowEvent::CloseRequested
|
||||
| WindowEvent::Focused(_)
|
||||
| WindowEvent::Resized(_)
|
||||
| WindowEvent::RedrawRequested
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.pending_save.is_some() {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
@@ -13625,7 +13653,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [
|
||||
KeyCode::Numpad9,
|
||||
];
|
||||
|
||||
const STAGES: u32 = 10;
|
||||
const STAGE_LINES: [&str; 10] = [
|
||||
"Loading menu",
|
||||
"Loading assets",
|
||||
@@ -13641,19 +13668,28 @@ const STAGE_LINES: [&str; 10] = [
|
||||
|
||||
/// The background loader's checkpoint, read by the loading screen each frame.
|
||||
#[derive(Clone)]
|
||||
struct Progress(Arc<AtomicU32>);
|
||||
struct Progress {
|
||||
value: Arc<AtomicU32>,
|
||||
lines: &'static [&'static str],
|
||||
}
|
||||
impl Progress {
|
||||
fn new() -> Self {
|
||||
Self(Arc::new(AtomicU32::new(0)))
|
||||
Self::with(&STAGE_LINES)
|
||||
}
|
||||
fn with(lines: &'static [&'static str]) -> Self {
|
||||
Self {
|
||||
value: Arc::new(AtomicU32::new(0)),
|
||||
lines,
|
||||
}
|
||||
}
|
||||
fn set(&self, stage: u32) {
|
||||
self.0.store(stage, Ordering::Relaxed);
|
||||
self.value.store(stage, Ordering::Relaxed);
|
||||
}
|
||||
fn fraction(&self) -> f32 {
|
||||
self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32
|
||||
self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32
|
||||
}
|
||||
fn line(&self) -> &'static str {
|
||||
STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)]
|
||||
self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
//! Resource pack selection bound to the game: the pack screen lists, and a
|
||||
//! full resource reload after a change, while playing.
|
||||
use super::Game;
|
||||
use super::{
|
||||
Game, PackResources, Resources, UiResources, build_renderer, prepare_renderer_textures,
|
||||
};
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
gfx::PreparedTextures,
|
||||
hud::HudAtlas,
|
||||
lang,
|
||||
pack_screen::{self, Compat, PackInfo, Source as PackSource},
|
||||
packs::{self, Pack, Position},
|
||||
streaming::WorkerContext,
|
||||
text::{Style, Text},
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// Where overlay roots are built (beside options.txt).
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
@@ -98,7 +107,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
|
||||
infos
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn vanilla_root(&self) -> PathBuf {
|
||||
pub(super) fn vanilla_root(&self) -> PathBuf {
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.options.resources.clone()
|
||||
} else {
|
||||
@@ -138,35 +147,36 @@ impl Game<'_> {
|
||||
self.ui.options.resource_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
/// Reload from the selected packs and the server's.
|
||||
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
|
||||
/// The CPU half of the reload, safe on a worker (`PackReload` runs it):
|
||||
/// `apply_staged_pack` finishes the GPU half and the swap.
|
||||
pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result<StagedPack> {
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let server = &self.server_packs.active;
|
||||
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
|
||||
let root =
|
||||
super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods);
|
||||
self.reload_resources(&root)
|
||||
stage_pack(
|
||||
&vanilla,
|
||||
&self.settings.resource_packs,
|
||||
&self.server_packs.active,
|
||||
&self.settings.mods.enabled,
|
||||
&self.options.dev_mods,
|
||||
&self.pack.assets,
|
||||
&self.res,
|
||||
&self.pack.context,
|
||||
self.settings.force_unicode,
|
||||
self.settings.japanese_glyphs,
|
||||
&super::Progress::new(),
|
||||
)
|
||||
}
|
||||
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
|
||||
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
|
||||
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
|
||||
atlas.font.uniform = self.settings.force_unicode;
|
||||
atlas.font.japanese = self.settings.japanese_glyphs;
|
||||
let gui = super::UiResources {
|
||||
atlas,
|
||||
panorama: std::sync::Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
|
||||
};
|
||||
let pack = super::PackResources::load(
|
||||
/// The render-thread half: the renderer on the new pack's textures, then
|
||||
/// the swap (`ReloadInstance`'s application listeners).
|
||||
pub(super) fn apply_staged_pack(&mut self, staged: StagedPack) -> horizon_client::Result<()> {
|
||||
let StagedPack {
|
||||
root,
|
||||
self.pack.assets.clone(),
|
||||
render,
|
||||
self.res.facts(),
|
||||
self.pack.context.biomes.clone(),
|
||||
self.pack.context.light.clone(),
|
||||
)?;
|
||||
gui,
|
||||
pack,
|
||||
textures,
|
||||
} = staged;
|
||||
// Keep the working GPU resources, and hand its window surface over.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
renderer.release_surface();
|
||||
@@ -174,7 +184,17 @@ impl Game<'_> {
|
||||
let renderer = match self
|
||||
.window
|
||||
.clone()
|
||||
.map(|window| self.renderer_for(window, &pack, &gui))
|
||||
.map(|window| {
|
||||
build_renderer(
|
||||
window,
|
||||
self.options.offscreen,
|
||||
&pack,
|
||||
&gui,
|
||||
self.renderer.as_ref(),
|
||||
Some(textures),
|
||||
self.settings.horizon.api,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
{
|
||||
Ok(renderer) => renderer,
|
||||
@@ -190,18 +210,18 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
// Everything loaded: switch over.
|
||||
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root);
|
||||
self.armor_stand_assets.reload_trims(root);
|
||||
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(&root);
|
||||
self.armor_stand_assets.reload_trims(&root);
|
||||
self.random_tick_sections.clear();
|
||||
self.portals.sections.clear();
|
||||
self.options.resources = root.to_path_buf();
|
||||
self.options.resources = root.clone();
|
||||
let context = pack.context.clone();
|
||||
self.pack = pack;
|
||||
self.gui = gui;
|
||||
// The atlas's sprites moved: the particle engine re-reads every rect
|
||||
// and the packs' sprite sets, or every particle draws a stale region.
|
||||
self.particles
|
||||
.reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
|
||||
.reload(&root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
// Villager hats come from the pack's texture metadata (shared data, swapped in place).
|
||||
*self
|
||||
@@ -210,11 +230,11 @@ impl Game<'_> {
|
||||
.villager_hats
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
horizon_client::mobs::villager_hats::VillagerHats::load(root);
|
||||
horizon_client::ui::load_metadata(root);
|
||||
lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code()));
|
||||
horizon_client::mobs::villager_hats::VillagerHats::load(&root);
|
||||
horizon_client::ui::load_metadata(&root);
|
||||
lang::set(lang::Language::load(&[&root], &lang::code()));
|
||||
self.refresh_remote_language();
|
||||
self.audio.reload(root);
|
||||
self.audio.reload(&root);
|
||||
self.clear_lod();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.pending_uploads.clear();
|
||||
@@ -229,4 +249,66 @@ impl Game<'_> {
|
||||
self.creative_screen.forget_items();
|
||||
Ok(())
|
||||
}
|
||||
/// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then
|
||||
/// applied (`PackReload` runs the same two phases across frames).
|
||||
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
|
||||
let staged = self.stage_selected_packs()?;
|
||||
self.apply_staged_pack(staged)
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a
|
||||
/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay
|
||||
/// root, the models and sprites, the menu bundle, the pack's tables and its
|
||||
/// CPU mip chains.
|
||||
pub(super) fn stage_pack(
|
||||
vanilla_root: &Path,
|
||||
selected: &[String],
|
||||
server: &[Pack],
|
||||
mods_enabled: &BTreeSet<String>,
|
||||
dev_mods: &[PathBuf],
|
||||
assets: &Arc<Assets>,
|
||||
res: &Resources,
|
||||
context: &WorkerContext,
|
||||
force_unicode: bool,
|
||||
japanese: bool,
|
||||
progress: &super::Progress,
|
||||
) -> horizon_client::Result<StagedPack> {
|
||||
let root = startup_root(vanilla_root, selected, server)?;
|
||||
let root = super::mods::content_root(&root, mods_enabled, dev_mods);
|
||||
progress.set(1);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let mut atlas = HudAtlas::build_over(&root, vanilla_root)?;
|
||||
atlas.font.uniform = force_unicode;
|
||||
atlas.font.japanese = japanese;
|
||||
let gui = UiResources {
|
||||
atlas,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)),
|
||||
};
|
||||
progress.set(2);
|
||||
let pack = PackResources::load(
|
||||
&root,
|
||||
assets.clone(),
|
||||
render,
|
||||
res.facts(),
|
||||
context.biomes.clone(),
|
||||
context.light.clone(),
|
||||
)?;
|
||||
let textures = prepare_renderer_textures(&pack);
|
||||
progress.set(3);
|
||||
Ok(StagedPack {
|
||||
root,
|
||||
gui,
|
||||
pack,
|
||||
textures,
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything a pack reload builds on the CPU (`apply_staged_pack` finishes
|
||||
/// it on the render thread).
|
||||
pub(super) struct StagedPack {
|
||||
pub root: PathBuf,
|
||||
pub gui: UiResources,
|
||||
pub pack: PackResources,
|
||||
pub textures: PreparedTextures,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
|
||||
//! server list's setting, downloads into `downloads/`, and a resource reload
|
||||
//! with the server's packs on top of the player's own.
|
||||
use super::Game;
|
||||
use super::{Game, Progress};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
confirm_screen::Confirm,
|
||||
@@ -82,6 +82,9 @@ pub(super) struct ServerPackState {
|
||||
pub(super) active: Vec<packs::Pack>,
|
||||
/// The download batches' notifiers, oldest first.
|
||||
notifiers: Vec<PackNotifier>,
|
||||
/// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the
|
||||
/// frame draws its progress until `tick_server_packs` applies it.
|
||||
pub(super) reload: Option<PackReload>,
|
||||
}
|
||||
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
|
||||
/// own `SystemToast`.
|
||||
@@ -191,6 +194,21 @@ fn saves() -> PathBuf {
|
||||
std::env::current_dir().unwrap_or_default().join("saves")
|
||||
}
|
||||
|
||||
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
|
||||
const RELOAD_LINES: [&str; 4] = [
|
||||
"Preparing resources",
|
||||
"Loading assets",
|
||||
"Loading pack tables",
|
||||
"Applying resources",
|
||||
];
|
||||
/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a
|
||||
/// worker while `progress` feeds the loading screen, then the apply lands
|
||||
/// on the render thread.
|
||||
pub(super) struct PackReload {
|
||||
pub(super) progress: Progress,
|
||||
handle: std::thread::JoinHandle<std::result::Result<super::packs::StagedPack, String>>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ConnectScreen`: the server's setting decides what its packs do
|
||||
/// (`configureForServerControl`); single player's own server takes them
|
||||
@@ -314,6 +332,7 @@ impl Game<'_> {
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
return;
|
||||
}
|
||||
self.drive_pack_reload();
|
||||
let finished = self
|
||||
.server_packs
|
||||
.queue
|
||||
@@ -328,13 +347,8 @@ impl Game<'_> {
|
||||
if !tick.download.is_empty() {
|
||||
self.start_pack_downloads(tick.download);
|
||||
}
|
||||
if let Some(mut load) = tick.reload {
|
||||
while let Some(recovery) = {
|
||||
let ok = self.reload_server_packs(&load);
|
||||
self.server_packs.packs.reloaded(ok)
|
||||
} {
|
||||
load = recovery;
|
||||
}
|
||||
if let Some(load) = tick.reload {
|
||||
self.start_pack_reload(&load);
|
||||
}
|
||||
}
|
||||
for (id, status) in self.server_packs.packs.take_replies() {
|
||||
@@ -379,24 +393,90 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `DownloadedPackSource.startReload`: the packs on top of the player's
|
||||
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
|
||||
/// readable `pack.mcmeta` or the reload failed.
|
||||
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
|
||||
let packs: Option<Vec<_>> = load
|
||||
/// own, staged on a worker (`Minecraft.reloadResourcePacks` under a
|
||||
/// `LoadingOverlay`); `drive_pack_reload` applies it. A pack with no
|
||||
/// readable `pack.mcmeta` fails the reload at once, as `onFailure`.
|
||||
fn start_pack_reload(&mut self, load: &[(u128, PathBuf)]) {
|
||||
let server: Option<Vec<_>> = load
|
||||
.iter()
|
||||
.map(|(id, path)| packs::server_pack(*id, path))
|
||||
.collect();
|
||||
let Some(packs) = packs else {
|
||||
let Some(server) = server else {
|
||||
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
|
||||
return false;
|
||||
self.finish_pack_reload(false);
|
||||
return;
|
||||
};
|
||||
self.server_packs.active = packs;
|
||||
match self.reload_selected_packs() {
|
||||
self.server_packs.active = server.clone();
|
||||
// The overlay swallows input until the apply (`LoadingOverlay`):
|
||||
// release what is held so nothing keeps acting while it covers the
|
||||
// frame (`KeyMapping.releaseAll` when a screen opens).
|
||||
self.keys.clear();
|
||||
self.physical_keys.clear();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
let vanilla = self.vanilla_root();
|
||||
if self.ui.vanilla_root.as_os_str().is_empty() {
|
||||
self.ui.vanilla_root = vanilla.clone();
|
||||
}
|
||||
let (selected, mods, dev_mods) = (
|
||||
self.settings.resource_packs.clone(),
|
||||
self.settings.mods.enabled.clone(),
|
||||
self.options.dev_mods.clone(),
|
||||
);
|
||||
let (assets, res, context) = (
|
||||
self.pack.assets.clone(),
|
||||
self.res.clone(),
|
||||
self.pack.context.clone(),
|
||||
);
|
||||
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
|
||||
let progress = Progress::with(&RELOAD_LINES);
|
||||
let worker = progress.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("server-pack-reload".into())
|
||||
.spawn(move || {
|
||||
super::packs::stage_pack(
|
||||
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context,
|
||||
unicode, japanese, &worker,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
match handle {
|
||||
Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }),
|
||||
Err(e) => {
|
||||
log::error!("Horizon: could not start the pack reload worker: {e}");
|
||||
self.finish_pack_reload(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PackReloadConfig.Callbacks`: applies the staged reload once its
|
||||
/// worker is done; on failure the packs active before reload again.
|
||||
fn drive_pack_reload(&mut self) {
|
||||
let Some(reload) = &self.server_packs.reload else {
|
||||
return;
|
||||
};
|
||||
if !reload.handle.is_finished() {
|
||||
return;
|
||||
}
|
||||
let reload = self.server_packs.reload.take().unwrap();
|
||||
let staged = reload
|
||||
.handle
|
||||
.join()
|
||||
.unwrap_or_else(|_| Err("the pack reload worker panicked".into()));
|
||||
let ok = match staged
|
||||
.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: server resource pack reload failed: {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
self.finish_pack_reload(ok);
|
||||
}
|
||||
/// `ServerPacks::reloaded` and the recovery reload it can ask for.
|
||||
fn finish_pack_reload(&mut self, ok: bool) {
|
||||
if let Some(recovery) = self.server_packs.packs.reloaded(ok) {
|
||||
self.start_pack_reload(&recovery);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ harness named checks that loader against vanilla).
|
||||
| Overlays per format | `pack.mcmeta` `overlays` | works | `test_resource_pack_parity` (an overlay for 26.3 applied, an older one not) |
|
||||
| Filters | `pack.mcmeta` `filter` | works | `test_resource_pack_parity` (`packs/pattern.rs` regex subset) |
|
||||
| Folder and zip packs, order, built-in packs | `resourcepacks/` | works | `test_resource_pack_parity`, `test_pack_selection_parity` |
|
||||
| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works, no download toasts | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server |
|
||||
| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), download toasts, a progress screen while the packs reload, applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server |
|
||||
| Other namespaces | `assets/<namespace>/` | works | `test_resource_pack_parity` |
|
||||
| Block states: variants, multipart, weights, rotations, uvlock | `blockstates/` | works | `test_blockstate_model_parity`, `test_model_choice_parity`, `test_blockstate_transform_parity` |
|
||||
| Block models: parents, elements, rotations, faces, cullface, tint, shade, light emission, ambient occlusion | `models/block/` | works | `test_model_resolution_parity`, `test_model_baking_parity`, `test_section_mesh_parity` |
|
||||
@@ -84,7 +84,6 @@ shaders, the missing pieces are missing vanilla features too, and each vanilla p
|
||||
| Gap | Size | Where |
|
||||
| --- | --- | --- |
|
||||
| Animation `interpolate` | small | fix PR (`gfx.rs` `animate_sprites`) |
|
||||
| Server pack download toasts (`download.pack.*`) | small | `game/server_packs.rs` |
|
||||
| GUI scaling `width`/`height`, `stretch_inner`, `tile`, `stretch` | small | next fix PR (`ui.rs`) |
|
||||
| Villager hats | small | [#334](https://github.com/ludovic111/horizon/issues/334) |
|
||||
| `blur`/`clamp` from packs | small | [#335](https://github.com/ludovic111/horizon/issues/335) |
|
||||
|
||||
Reference in New Issue
Block a user