Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 1ff33fd853 Merge origin/main into split/cosmetics-settings
extras.rs: main dropped DiscordPresence, this branch dropped the cosmetics
rows: both removals kept. layers.json: main dropped client-lib -> theme,
this branch dropped client-render -> cosmetics: both removals kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:51:45 +01:00
Horizon agent 536c0f938b Merge branch 'split/cosmetics-fx' into split/cosmetics-settings 2026-11-09 21:13:54 +01:00
Horizon agentandClaude Opus 5.5 398bec3d6e Move the cosmetics settings out of the engine's Extras (spec #14091 step 5a)
The six cosmetics settings (my cosmetics, in single player, other players'
cosmetics, hide others' trails and auras, Horizon logos, show me as Horizon)
leave horizon-client-render's Extras for game/cosmetics/settings.rs:

- their horizon-extras.txt lines go through a settings Store (same keys, so
  players and the launcher's Wardrobe keep their choice; others' line still
  written only once chosen), always registered so a game without cosmetics
  keeps the Wardrobe's lines;
- their rows are a Place::Extras page section, shown while cosmetics are
  offered, in the same Cosmetics group at the end of the Extras page;
- the values live in the per-seat Seat, and the owner flag is read there
  instead of being copied into Extras each frame.

horizon-client-render no longer depends on horizon-cosmetics: the allowed
entry "horizon-client-render -> horizon-cosmetics" is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:37:10 +01:00
11 changed files with 391 additions and 502 deletions

No files matched your search

Generated
-1
View File
@@ -1584,7 +1584,6 @@ dependencies = [
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
-1
View File
@@ -9,7 +9,6 @@ description = "The client's world data, rendering and screens: chunks, meshing,
[dependencies]
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
+9 -215
View File
@@ -166,24 +166,6 @@ pub struct Extras {
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (no line until chosen:
/// `effective_others`), and whether their trails and auras are hidden (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
pub cosmetics_hide_fx: bool,
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
/// names the player by their offline profile, which the service doesn't know, so their own
/// cosmetics and name tag style show only with this on.
pub cosmetics_solo: bool,
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
/// sets it): with no choice made, others' cosmetics show only then.
pub cosmetics_owner: bool,
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
/// whether the service may say this player plays Horizon: both on by default.
pub horizon_marks: bool,
pub horizon_listed: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
@@ -238,13 +220,6 @@ impl Default for Extras {
wheel_cycles_settings: false,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: None,
cosmetics_hide_fx: false,
cosmetics_solo: false,
cosmetics_owner: false,
horizon_marks: true,
horizon_listed: true,
three_d_skin_layers: false,
pvp: Default::default(),
}
@@ -254,11 +229,6 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl Extras {
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
/// account that owns a cosmetic and none for any other (a vanilla game).
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -353,15 +323,6 @@ impl Extras {
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsHideFx" => e.cosmetics_hide_fx = flag(e.cosmetics_hide_fx),
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
key => {
e.pvp.parse_line(key, value);
@@ -372,7 +333,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -421,16 +382,6 @@ impl Extras {
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
// Written once the player chose.
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_hide_fx),
bool_text(self.cosmetics_solo),
horizon_cosmetics::prefs::MARKS,
bool_text(self.horizon_marks),
horizon_cosmetics::prefs::LISTED,
bool_text(self.horizon_listed),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
@@ -616,13 +567,6 @@ pub enum Item {
/// The Select Resource Packs screen's Add button (Modrinth).
PackBrowser,
Pregen,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsHideFx,
CosmeticsSolo,
HorizonMarks,
HorizonMe,
/// On the PvP & HUD page (`pvp::rows`).
Pvp(crate::pvp::Opt),
}
@@ -630,8 +574,10 @@ pub enum Item {
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
// Fullbright, Dynamic lights and Hide block-break particles change the picture: they
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here.
let mut rows = vec![
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here. The Cosmetics
// group comes last, from the cosmetics' own section (`pages::Place::Extras`) while they are
// offered.
vec![
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("horizon.extras.header.inventory"),
@@ -652,19 +598,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
Ok(PackBrowser),
];
if horizon_cosmetics::enabled() {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsSolo),
Ok(CosmeticsOthers),
Ok(CosmeticsHideFx),
Ok(HorizonMarks),
Ok(HorizonMe),
]);
}
rows
]
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
@@ -690,12 +624,6 @@ impl Item {
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::PackBrowser => "horizon.extras.pack_browser",
Item::Pregen => "horizon.extras.pregen",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
Item::HorizonMarks => "horizon.extras.horizon_marks",
Item::HorizonMe => "horizon.extras.horizon_me",
Item::Pvp(o) => o.caption_key(),
}
}
@@ -722,12 +650,6 @@ impl Item {
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_list_settings.tooltip",
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
Item::Pregen => "horizon.extras.pregen.tooltip",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx.tooltip",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
Item::Pvp(o) => o.description_key(),
}
}
@@ -763,12 +685,6 @@ impl Item {
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
Item::Pregen => "Chunk pregeneration",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsHideFx => "Hide other players' trails and auras",
Item::CosmeticsSolo => "In single player",
Item::HorizonMarks => "Horizon logos",
Item::HorizonMe => "Show me as Horizon",
Item::Pvp(o) => o.caption(),
}
}
@@ -783,8 +699,7 @@ impl Item {
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
| WheelCyclesSettings | MusicPage | PackBrowser | Pregen => {
return None;
}
})
@@ -842,24 +757,6 @@ impl Item {
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::CosmeticsMine => {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
}
Item::CosmeticsHideFx => {
"Other players' trails and auras stay hidden; their other cosmetics still show. Off by default."
}
Item::CosmeticsSolo => {
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
}
Item::HorizonMarks => {
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
}
Item::HorizonMe => {
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
}
Item::Pvp(o) => o.description(),
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
@@ -886,12 +783,6 @@ impl Item {
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine
| Item::CosmeticsOthers
| Item::CosmeticsHideFx
| Item::CosmeticsSolo
| Item::HorizonMarks
| Item::HorizonMe => Some(Cost::Low),
_ => None,
}
}
@@ -917,10 +808,6 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::Pregen => e.pregen,
Item::CosmeticsHideFx => e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks,
Item::HorizonMe => e.horizon_listed,
Item::Pvp(o) => return o.switch(&e.pvp),
_ => return None,
})
@@ -930,30 +817,14 @@ impl Item {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.effective_others() {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
}
Others::Hidden => {
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
}
}
}
other => match other.switch(e) {
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
/// A click (`back` for the right button: a PvP & HUD choice steps back; a switch flips
/// either way). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
@@ -975,16 +846,6 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::Pregen => e.pregen = !e.pregen,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
// From what the button shows; the result is the player's choice from now on.
let o = e.effective_others();
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsHideFx => e.cosmetics_hide_fx = !e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
Item::SortKey => {}
}
@@ -1003,73 +864,6 @@ impl Item {
mod tests {
use super::*;
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras {
cosmetics_owner: true,
..Extras::default()
};
assert_eq!(
(e.effective_others(), e.cosmetics_hide_fx, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
// Ownership is the service's, never written.
assert_eq!(
Extras::parse(&e.serialize()),
Extras {
cosmetics_owner: false,
..e.clone()
}
);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Some(Others::NameTags)
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Some(Others::Hidden)
);
}
#[test]
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
assert_eq!(e.effective_others(), Others::Hidden);
assert_eq!(
Item::CosmeticsOthers.value(&e),
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
);
assert!(!e.serialize().contains("cosmeticsOthers"));
// The service says the account owns one: shown, still not a choice.
e.cosmetics_owner = true;
assert_eq!(e.effective_others(), Others::Shown);
assert!(!e.serialize().contains("cosmeticsOthers"));
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
e.cosmetics_owner = false;
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Shown));
let saved = Extras::parse(&e.serialize());
assert_eq!(saved.effective_others(), Others::Shown);
// An owner who chose Hidden keeps it.
let hidden = Extras {
cosmetics_owner: true,
..Extras::parse("cosmeticsOthers:hidden\n")
};
assert_eq!(hidden.effective_others(), Others::Hidden);
}
#[test]
fn a_saved_wheel_setting_from_before_the_default_changed_is_dropped() {
assert!(!Extras::parse("wheelCyclesSettings:true\n").wheel_cycles_settings);
+2 -19
View File
@@ -206,14 +206,13 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
/// The rows read the extras, the mods and the sections' rows.
fn stamp(&self, s: &Settings) -> u64 {
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
shell::stamp_of(&(&s.extras, &s.mods, added))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -321,22 +320,6 @@ mod tests {
assert_ne!(s.extras.mouse_tweaks_wheel, before);
}
#[test]
fn the_three_way_cosmetics_row_turns_both_ways() {
use horizon_cosmetics::prefs::Others;
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::CosmeticsOthers);
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
assert_eq!(s.extras.effective_others(), Others::Hidden);
screen.press(&mut s, row, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
}
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
+93 -127
View File
@@ -10,6 +10,7 @@
pub(super) mod emote_wheel;
pub(super) mod emotes;
mod fx;
mod settings;
use super::Game;
use horizon_client::fast_hash::FxHashMap;
@@ -244,16 +245,13 @@ impl Cosmetics {
}
}
/// What the player whose UUID `uuid` gives wears, if the game shows it: the player's own model
/// (`own`) while Show my cosmetics is on, another player unless others' cosmetics are hidden (by
/// choice, or by owning none). Hidden, the service is never asked about them.
/// What the player whose UUID `uuid` gives wears, if the game shows it (`shown`:
/// [`Seat::shows`]). Hidden, the service is never asked about them.
fn worn_of(
client: &horizon_cosmetics::Cosmetics,
said: &Said,
own: bool,
shown: bool,
uuid: impl FnOnce() -> Option<u128>,
) -> Option<horizon_cosmetics::Worn> {
let shown = if own { said.mine } else { !said.others_hidden };
if !shown {
return None;
}
@@ -363,6 +361,8 @@ struct Seat {
wanted: Vec<u64>,
/// Companions, trails and auras (`cosmetics/fx.rs`).
fx: fx::Fx,
/// The player's settings (`cosmetics/settings.rs`).
settings: settings::Settings,
}
/// An item the hooks show: its sprite's id (never reused) and a mesh per bone it rides, held
@@ -397,22 +397,10 @@ fn bone(b: horizon_cosmetics::Bone) -> Bone {
}
}
/// What the game tells the hooks before each call, until the settings move here (spec #14091
/// step 5a) and the player's emote with them (step 4).
/// What the game tells the hooks before each call: the player's emote, until it moves here
/// (spec #14091 step 4).
#[derive(Default)]
struct Said {
mine: bool,
listed: bool,
/// Other players' cosmetics are shown (not "name tags only", not hidden).
others_shown: bool,
/// Other players' cosmetics are hidden (not even name tag styles).
others_hidden: bool,
/// Horizon's mark by Horizon players' names.
marks: bool,
/// Other players' trails and auras are hidden.
hide_fx: bool,
/// Cosmetics in single player: the signed-in account's on the player's own model.
solo: bool,
/// My emote pulled the camera back: when it started (the own model's age).
pull: Option<f32>,
/// My emote while it plays: its id and whether it loops.
@@ -421,19 +409,6 @@ struct Said {
first: Option<Arc<Item>>,
}
impl Said {
/// What the game's settings say.
fn settings(&mut self, extras: &horizon_client::extras::Extras) {
self.mine = extras.cosmetics_mine;
self.listed = extras.horizon_listed;
self.others_shown = extras.effective_others() == Others::Shown;
self.others_hidden = extras.effective_others() == Others::Hidden;
self.marks = extras.horizon_marks;
self.hide_fx = extras.cosmetics_hide_fx;
self.solo = extras.cosmetics_solo;
}
}
/// One [`Seat`] per splitscreen player.
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
@@ -448,8 +423,19 @@ fn service<R>(f: impl FnOnce(&mut Seat) -> R) -> R {
}
/// The cosmetics' hooks, registered when [`enabled`].
pub(super) struct Hooks;
pub(super) static HOOKS: Hooks = Hooks;
struct Hooks;
static HOOKS: Hooks = Hooks;
/// Registers the cosmetics' settings lines, and while they are [`enabled`] their hooks and their
/// rows on the Extras page. The lines always: a game without cosmetics keeps the Wardrobe's.
pub(super) fn register() -> Result<(), horizon_hooks::TooLate> {
horizon_hooks::settings::register(&settings::STORE)?;
if enabled() {
horizon_hooks::register(&HOOKS)?;
horizon_hooks::pages::register(&settings::SECTION)?;
}
Ok(())
}
impl horizon_hooks::Hooks for Hooks {
fn frame(&self, frame: &Frame, sprites: &mut dyn SpriteStore) {
@@ -475,7 +461,7 @@ impl horizon_hooks::Hooks for Hooks {
fn player_mark(&self, uuid: u128) -> Option<Mark> {
service(|s| {
let client = s.client.as_ref().filter(|_| s.said.marks)?;
let client = s.client.as_ref().filter(|_| s.settings.marks)?;
client.frame().horizon(uuid).then_some(MARK)
})
}
@@ -498,6 +484,22 @@ impl horizon_hooks::Hooks for Hooks {
}
impl Seat {
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
/// account that owns a cosmetic and none for any other (a vanilla game).
fn others(&self) -> Others {
Others::effective(self.settings.others, self.owner.owns)
}
/// Whether the cosmetics of the player's own model (`own`) or of another player show at all:
/// mine with Show my cosmetics on, others' unless hidden.
fn shows(&self, own: bool) -> bool {
if own {
self.settings.mine
} else {
self.others() != Others::Hidden
}
}
/// Once a frame: starts the service's client on first use, asks whether the account owns a
/// cosmetic, and tells the service what the account shows.
fn frame(&mut self) {
@@ -570,7 +572,9 @@ impl Seat {
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = said.playing.as_ref();
let looping = playing.is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id.as_str()).filter(|_| said.listed);
let wish = playing
.map(|(id, _)| id.as_str())
.filter(|_| self.settings.listed);
let o = &mut self.emote_out;
let changed = o.sent.as_ref().map(Option::as_deref) != Some(wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
@@ -594,10 +598,10 @@ impl Seat {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if self.mine.due(said.mine) {
self.mine.start(said.mine, send_mine);
} else if self.listed.due(said.listed) {
self.listed.start(said.listed, send_listed);
} else if self.mine.due(self.settings.mine) {
self.mine.start(self.settings.mine, send_mine);
} else if self.listed.due(self.settings.listed) {
self.listed.start(self.settings.listed, send_listed);
}
}
@@ -609,18 +613,17 @@ impl Seat {
m: &PlayerModel,
sprites: &dyn Sprites,
) -> Option<NameDecoration> {
let said = &self.said;
let own = m.kind == Kind::Own;
let styles = if own { said.mine } else { !said.others_hidden };
if !(styles || said.marks) {
let (styles, marks) = (self.shows(own), self.settings.marks);
if !(styles || marks) {
return None;
}
let uuid = self.uuid_of(m)?;
let mut frame = self.client.as_ref()?.frame();
let mark = (said.marks && frame.horizon(uuid)).then_some(MARK);
let mark = (marks && frame.horizon(uuid)).then_some(MARK);
let worn = if styles { frame.worn(uuid) } else { None };
drop(frame);
let others = if said.others_shown {
let others = if self.others() == Others::Shown {
Others::Shown
} else {
Others::NameTags
@@ -634,7 +637,7 @@ impl Seat {
/// The UUID the service knows `m` by: a player's, or a mannequin's id with local items.
fn uuid_of(&self, m: &PlayerModel) -> Option<u128> {
match m.kind {
Kind::Own => own_uuid(m.uuid, self.solo, self.said.solo, || {
Kind::Own => own_uuid(m.uuid, self.solo, self.settings.solo, || {
horizon_client::auth::current().map(|s| s.uuid)
}),
Kind::Mannequin if self.local => Some(u128::from(m.id)),
@@ -704,10 +707,9 @@ impl Seat {
if m.invisible {
return;
}
let said = &self.said;
let own = m.kind == Kind::Own;
// "Name tags only": other players' styles beside their names, nothing on their bodies.
let bodies = own || said.others_shown;
let bodies = own || self.others() == Others::Shown;
let ticks = draw.ticks();
// An emote's props: its time and weight while it plays.
let emote = emotes::pose_playing(draw.body_pose()).and_then(|p| {
@@ -715,7 +717,7 @@ impl Seat {
(p.item.vertices > 0).then(|| (p.item.clone(), t, w))
});
let client = self.client.as_ref().filter(|_| bodies);
let worn = client.and_then(|c| worn_of(c, &self.said, own, || self.uuid_of(m)));
let worn = client.and_then(|c| worn_of(c, self.shows(own), || self.uuid_of(m)));
// Items on one bone (a back item, a shoulder pet and a shirt ride the body) share its
// matrix: the head, the body and the two arms at most.
let mut bones: [Option<(Bone, [[f32; 4]; 4])>; 4] = [None; 4];
@@ -771,7 +773,7 @@ impl Seat {
let Some(client) = self.client.as_ref().filter(|_| !m.invisible) else {
return;
};
let Some(worn) = worn_of(client, &self.said, true, || self.uuid_of(m)) else {
let Some(worn) = worn_of(client, self.shows(true), || self.uuid_of(m)) else {
return;
};
if let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) {
@@ -795,7 +797,7 @@ impl Seat {
}
fn play_others(&self, models: &mut dyn PlayerModels, view: &mut Vec<PlayerModel>) {
let shown = self.said.others_shown;
let shown = self.others() == Others::Shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.local && shown {
@@ -860,8 +862,7 @@ impl Seat {
}
impl Game<'_> {
/// Hands the hooks what the game's settings and the player's emote say (allocating only when
/// the emote changes).
/// Hands the hooks what the player's emote says (allocating only when it changes).
pub(super) fn cosmetics_say(&mut self) {
if !enabled() {
return;
@@ -873,12 +874,10 @@ impl Game<'_> {
} else {
None
};
let extras = &self.settings.extras;
let playing = self.cosmetics.emotes.sending();
let pull = self.cosmetics.emotes.pull();
service(|s| {
let said = &mut s.said;
said.settings(extras);
said.pull = pull;
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
@@ -889,16 +888,13 @@ impl Game<'_> {
});
}
/// Once a frame, after the hooks: what the game reads of them (the settings' owner flag,
/// whether items come from a folder).
/// Once a frame, after the hooks: what the game reads of them (whether items come from a
/// folder).
pub(super) fn cosmetics_frame(&mut self) {
if !enabled() {
return;
}
let (owns, local) = service(|s| (s.owner.owns, s.local));
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = owns;
self.cosmetics.local = local;
self.cosmetics.local = service(|s| s.local);
}
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
@@ -929,7 +925,6 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::extras::Extras;
use horizon_client::mobs::anim::BodyPose;
/// A name tag style with a 1×1 mark.
@@ -990,31 +985,32 @@ mod tests {
const OTHER: u128 = 0x1234;
/// What `extras` tell the hooks.
fn said_of(extras: &Extras) -> Said {
let mut said = Said::default();
said.settings(extras);
said
/// A seat whose player chose `others` (None: no choice) and whose account `owns` a cosmetic.
fn seat_of(others: Option<Others>, owns: bool) -> Seat {
let mut seat = Seat::default();
seat.settings.others = others;
seat.owner.owns = owns;
seat
}
/// What another player wears as `extras` shows it, drawn frame after frame for up to
/// What another player wears as `seat` shows it, drawn frame after frame for up to
/// `patience` (the service answers on its own thread), and what was asked of the service.
fn other_player(
extras: &Extras,
seat: &Seat,
patience: Duration,
) -> (Option<horizon_cosmetics::Worn>, Vec<String>) {
other_player_from(Service::default(), extras, patience)
other_player_from(Service::default(), seat, patience)
}
fn other_player_from(
service: Service,
extras: &Extras,
seat: &Seat,
patience: Duration,
) -> (Option<horizon_cosmetics::Worn>, Vec<String>) {
let client = horizon_cosmetics::Cosmetics::new(service.clone(), None);
let deadline = Instant::now() + patience;
let worn = loop {
let worn = worn_of(&client, &said_of(extras), false, || Some(OTHER));
let worn = worn_of(&client, seat.shows(false), || Some(OTHER));
if worn.is_some() || Instant::now() >= deadline {
break worn;
}
@@ -1026,18 +1022,13 @@ mod tests {
#[test]
fn a_player_who_owns_none_sees_and_asks_nothing_until_they_choose() {
use horizon_cosmetics::prefs::Others;
// No line, owns nothing: a vanilla game, and no request about the other player.
let (short, long) = (Duration::from_millis(300), Duration::from_secs(10));
let (worn, asked) = other_player(&Extras::default(), short);
let (worn, asked) = other_player(&seat_of(None, false), short);
assert!(worn.is_none());
assert_eq!(asked, Vec::<String>::new());
// The service says this account owns a cosmetic: the other player's style shows.
let owner = Extras {
cosmetics_owner: true,
..Extras::default()
};
let (worn, asked) = other_player(&owner, long);
let (worn, asked) = other_player(&seat_of(None, true), long);
assert_eq!(
worn.map(|w| w.iter().map(|i| i.id.clone()).collect::<Vec<_>>()),
Some(vec!["tag".to_owned()])
@@ -1047,16 +1038,12 @@ mod tests {
format!("POST /v1/worn {{\"players\":[\"{OTHER:032x}\"]}}")
);
// A choice wins over ownership both ways.
let chose = Extras {
cosmetics_others: Some(Others::Shown),
..Extras::default()
};
assert!(other_player(&chose, long).0.is_some());
let hid = Extras {
cosmetics_others: Some(Others::Hidden),
..owner
};
let (worn, asked) = other_player(&hid, short);
assert!(
other_player(&seat_of(Some(Others::Shown), false), long)
.0
.is_some()
);
let (worn, asked) = other_player(&seat_of(Some(Others::Hidden), true), short);
assert!(worn.is_none() && asked.is_empty());
}
@@ -1068,14 +1055,12 @@ mod tests {
let mine = |solo: bool, shown: bool, patience: Duration| {
let service = Service::default();
let client = horizon_cosmetics::Cosmetics::new(service.clone(), None);
let extras = Extras {
cosmetics_solo: shown,
..Extras::default()
};
let uuid = || own_uuid(Some(OFFLINE), solo, extras.cosmetics_solo, || Some(ACCOUNT));
let mut seat = Seat::default();
seat.settings.solo = shown;
let uuid = || own_uuid(Some(OFFLINE), solo, seat.settings.solo, || Some(ACCOUNT));
let deadline = Instant::now() + patience;
let worn = loop {
let worn = worn_of(&client, &said_of(&extras), true, uuid);
let worn = worn_of(&client, seat.shows(true), uuid);
if worn.is_some() || Instant::now() >= deadline {
break worn;
}
@@ -1108,7 +1093,6 @@ mod tests {
#[test]
fn a_tall_cosmetic_lifts_the_name_tag_only_while_it_is_drawn() {
use super::super::names::{NAME_TAG_UNIT, cosmetic_lift};
use horizon_cosmetics::prefs::Others;
// Vanilla: EntityAttachment.NAME_TAG of a standing player, 1.8 + 0.5 above the feet;
// the tag's lowest pixel 10 text pixels under it.
const VANILLA: f64 = 2.3;
@@ -1118,25 +1102,17 @@ mod tests {
// by the avatar's 0.9375.
let star = 0.9375 * (1.501 + 20.0 / 16.0);
let both: &'static [(&str, &str)] = &[("tag", TAG), ("crown", CROWN)];
let shown = Extras {
cosmetics_others: Some(Others::Shown),
..Extras::default()
};
let shown = seat_of(Some(Others::Shown), false);
let (short, long) = (Duration::from_millis(300), Duration::from_secs(10));
let top_of = |extras: &Extras, patience| {
let (worn, _) = other_player_from(Service(Arc::default(), both), extras, patience);
body_top(extras.effective_others(), false, worn.as_ref(), |_| false)
let top_of = |seat: &Seat, patience| {
let (worn, _) = other_player_from(Service(Arc::default(), both), seat, patience);
body_top(seat.others(), false, worn.as_ref(), |_| false)
};
// No cosmetics drawn on the body (a name tag style only): vanilla's height exactly.
let (worn, _) = other_player(&shown, long);
assert!(worn.is_some());
assert_eq!(
tag(body_top(
shown.effective_others(),
false,
worn.as_ref(),
|_| false
)),
tag(body_top(shown.others(), false, worn.as_ref(), |_| false)),
VANILLA
);
// A small hat under the tag: vanilla's height too.
@@ -1150,22 +1126,19 @@ mod tests {
);
// A helmet over it: vanilla's height.
let (worn, _) = other_player_from(Service(Arc::default(), both), &shown, long);
let helmet = body_top(shown.effective_others(), false, worn.as_ref(), |slot| {
let helmet = body_top(shown.others(), false, worn.as_ref(), |slot| {
slot == Slot::Head
});
assert_eq!(tag(helmet), VANILLA);
// Others' cosmetics on name tags only, or hidden: nothing on the body, vanilla's height.
for others in [Others::NameTags, Others::Hidden] {
let extras = Extras {
cosmetics_others: Some(others),
..Extras::default()
};
let seat = seat_of(Some(others), false);
let patience = if others == Others::Hidden {
short
} else {
long
};
assert_eq!(tag(top_of(&extras, patience)), VANILLA, "{others:?}");
assert_eq!(tag(top_of(&seat, patience)), VANILLA, "{others:?}");
}
}
@@ -1202,11 +1175,10 @@ mod tests {
let mut service = Seat {
local: true,
said: Said {
others_shown: true,
first: Some(item.clone()),
..Said::default()
},
..Seat::default()
..seat_of(Some(Others::Shown), false)
};
let model = |id, kind, uuid| (PlayerModel::new(id, kind, uuid, 50, false), None);
let mut view = View(vec![
@@ -1225,7 +1197,7 @@ mod tests {
service.tick(&mut view);
assert!(Arc::ptr_eq(&first, view.0[1].1.as_ref().unwrap()));
// Others' cosmetics not shown: a new mannequin plays nothing.
service.said.others_shown = false;
service.settings.others = Some(Others::NameTags);
view.0.push(model(3, Kind::Mannequin, None));
service.tick(&mut view);
assert!(view.0[3].1.is_none());
@@ -1334,13 +1306,7 @@ mod tests {
..Canvas::default()
};
// Others' cosmetics hidden, no service: the player's own emote still shows.
let mut seat = Seat {
said: Said {
others_hidden: true,
..Said::default()
},
..Seat::default()
};
let mut seat = seat_of(Some(Others::Hidden), false);
// Its texture not in the atlas yet: nothing drawn, the sprite asked for.
seat.player_layers(&player, &mut canvas);
assert!(canvas.drawn.is_empty());
@@ -1393,7 +1359,7 @@ mod tests {
assert_eq!(canvas.looks.len(), 1);
// Others' trails and auras hidden: theirs go, the player's own stays.
canvas.base = Some(glam::Mat4::from_translation(at));
seat.said.hide_fx = true;
seat.settings.hide_fx = true;
seat.fx(&other, &ring, &mut canvas);
assert_eq!(canvas.looks.len(), 1);
seat.fx(&own, &ring, &mut canvas);
@@ -93,7 +93,7 @@ impl Seat {
/// trails and auras stay hidden with "Hide other players' trails and auras".
pub(super) fn fx(&mut self, m: &PlayerModel, item: &Arc<Item>, draw: &mut dyn PlayerDraw) {
let own = m.kind == Kind::Own;
if !own && item.slot.is_effect() && self.said.hide_fx {
if !own && item.slot.is_effect() && self.settings.hide_fx {
return;
}
// Past `FAR` a companion, trail or aura is a few pixels across for the cost of a model:
@@ -0,0 +1,285 @@
//! The cosmetics' settings (spec #14091 step 5a): their lines of `horizon-extras.txt`, the ones
//! the launcher's Wardrobe shares (`horizon_cosmetics::prefs`), through the settings store
//! ([`STORE`]), and their rows in the Extras page's Cosmetics group ([`SECTION`]). Each
//! splitscreen player has their own, as their own settings file: they live in the player's
//! [`Seat`].
use super::{Seat, service};
use horizon_cosmetics::prefs::{self, Others};
use horizon_hooks::pages::{Act, Label, Page, Place, Row};
use horizon_hooks::settings::Store;
/// My cosmetics in single player too.
const SOLO: &str = "cosmeticsSolo";
/// The settings, each with its line in `horizon-extras.txt`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Settings {
/// Show my cosmetics (the account's, told to the service; `prefs::MINE`).
pub mine: bool,
/// How much of other players' cosmetics shows: no line until the player chooses
/// (`Seat::others`).
pub others: Option<Others>,
/// Other players' trails and auras hidden.
pub hide_fx: bool,
/// My cosmetics in single player too: the window's own server names the player by their
/// offline profile, which the service doesn't know, so they show only with this on.
pub solo: bool,
/// Horizon's mark before Horizon players' names (name tags, player list).
pub marks: bool,
/// The service may say this player plays Horizon.
pub listed: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
mine: true,
others: None,
hide_fx: false,
solo: false,
marks: true,
listed: true,
}
}
}
const KEYS: [&str; 6] = [
prefs::MINE,
prefs::OTHERS,
prefs::HIDE_FX,
SOLO,
prefs::MARKS,
prefs::LISTED,
];
impl Settings {
/// One line; a value it can't read keeps the setting.
fn load(&mut self, key: &str, value: &str) {
let flag = |was: bool| match value {
"true" => true,
"false" => false,
_ => was,
};
match key {
prefs::MINE => self.mine = flag(self.mine),
// `Others::parse` reads the old two-way switch too.
prefs::OTHERS => self.others = Others::parse(value).or(self.others),
prefs::HIDE_FX => self.hide_fx = flag(self.hide_fx),
SOLO => self.solo = flag(self.solo),
prefs::MARKS => self.marks = flag(self.marks),
prefs::LISTED => self.listed = flag(self.listed),
_ => {}
}
}
/// Their lines; others' only once chosen.
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
let text = |on: bool| if on { "true" } else { "false" };
line(prefs::MINE, text(self.mine));
if let Some(others) = self.others {
line(prefs::OTHERS, others.name());
}
line(prefs::HIDE_FX, text(self.hide_fx));
line(SOLO, text(self.solo));
line(prefs::MARKS, text(self.marks));
line(prefs::LISTED, text(self.listed));
}
}
/// The settings' lines of `horizon-extras.txt`, the player's own file in splitscreen: the game
/// loads and saves it on the player's turn, so they are that seat's.
pub(super) static STORE: Lines = Lines;
pub(super) struct Lines;
impl Store for Lines {
fn file(&self) -> &'static str {
prefs::FILE
}
fn owns(&self, key: &str) -> bool {
KEYS.contains(&key)
}
fn load(&self, key: &str, value: &str) {
service(|s| s.settings.load(key, value));
}
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
service(|s| s.settings).save(line);
}
fn is_default(&self) -> bool {
service(|s| s.settings) == Settings::default()
}
}
/// The rows, top to bottom.
const ROWS: [&str; 6] = [
"horizon.extras.cosmetics_mine",
"horizon.extras.cosmetics_solo",
"horizon.extras.cosmetics_others",
"horizon.extras.cosmetics_hide_fx",
"horizon.extras.horizon_marks",
"horizon.extras.horizon_me",
];
const TOOLTIPS: [&str; 6] = [
"horizon.extras.cosmetics_mine.tooltip",
"horizon.extras.cosmetics_solo.tooltip",
"horizon.extras.cosmetics_others.tooltip",
"horizon.extras.cosmetics_hide_fx.tooltip",
"horizon.extras.horizon_marks.tooltip",
"horizon.extras.horizon_me.tooltip",
];
/// The rows in the Extras page's Cosmetics group, while cosmetics are offered
/// ([`super::enabled`]).
pub(super) static SECTION: Section = Section;
pub(super) struct Section;
impl Section {
fn rows_of(seat: &Seat, rows: &mut Vec<Row>) {
let s = &seat.settings;
let shown = |on: bool| {
Label::Key(if on {
"horizon.extras.cosmetics.shown"
} else {
"horizon.extras.cosmetics.hidden"
})
};
let others = Label::Key(match seat.others() {
Others::Shown => "horizon.extras.cosmetics.shown",
Others::NameTags => "horizon.extras.cosmetics.name_tags",
Others::Hidden => "horizon.extras.cosmetics.hidden",
});
let toggle = |label, on| Row::Toggle { label, on };
let cycle = |label, value| Row::Cycle { label, value };
rows.extend([
cycle(ROWS[0], shown(s.mine)),
toggle(ROWS[1], s.solo),
cycle(ROWS[2], others),
toggle(ROWS[3], s.hide_fx),
toggle(ROWS[4], s.marks),
toggle(ROWS[5], s.listed),
]);
}
/// A press on row `row`; `back` turns the three-way row the other way.
fn act_on(seat: &mut Seat, row: usize, back: bool) {
let s = &mut seat.settings;
match row {
0 => s.mine = !s.mine,
1 => s.solo = !s.solo,
2 => {
// From what the row shows; the result is the player's choice from now on.
let o = seat.others();
seat.settings.others = Some(if back { o.next().next() } else { o.next() });
}
3 => s.hide_fx = !s.hide_fx,
4 => s.marks = !s.marks,
5 => s.listed = !s.listed,
_ => {}
}
}
}
impl Page for Section {
fn id(&self) -> &'static str {
"horizon-cosmetics.extras"
}
fn title(&self) -> &'static str {
"horizon.extras.header.cosmetics"
}
fn place(&self) -> Place {
Place::Extras
}
fn rows(&self, rows: &mut Vec<Row>) {
if super::enabled() {
service(|seat| Self::rows_of(seat, rows));
}
}
fn act(&self, row: usize, act: Act) {
let back = match act {
Act::Press => false,
Act::PressBack => true,
_ => return,
};
service(|seat| Self::act_on(seat, row, back));
}
fn tooltip(&self, row: usize) -> Option<Label> {
TOOLTIPS.get(row).copied().map(Label::Key)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn loaded(text: &str) -> Settings {
let mut s = Settings::default();
for (key, value) in text.lines().filter_map(|l| l.split_once(':')) {
s.load(key.trim(), value.trim());
}
s
}
fn saved(s: &Settings) -> String {
let mut text = String::new();
s.save(&mut |k, v| text.push_str(&format!("{k}:{v}\n")));
text
}
#[test]
fn the_lines_round_trip_and_the_wardrobe_reads_them() {
let s = Settings {
mine: false,
others: Some(Others::NameTags),
hide_fx: true,
solo: true,
marks: false,
listed: false,
};
let text = saved(&s);
assert_eq!(loaded(&text), s);
// The launcher's Wardrobe reads the same lines.
let wardrobe = prefs::Prefs::parse(&text);
assert_eq!(
(wardrobe.mine, wardrobe.others, wardrobe.hide_fx),
(false, Some(Others::NameTags), true)
);
// No choice made: no `cosmeticsOthers` line; the old switch still reads.
assert!(!saved(&Settings::default()).contains(prefs::OTHERS));
assert_eq!(
loaded("cosmeticsOthers:false\n").others,
Some(Others::Hidden)
);
// A value it can't read keeps the setting.
assert!(loaded("cosmeticsMine:maybe\n").mine);
}
#[test]
fn others_cycle_three_ways_from_what_the_row_shows() {
let mut seat = Seat::default();
// Not chosen, nothing owned: Hidden, and forward wraps to Shown.
assert_eq!(seat.others(), Others::Hidden);
Section::act_on(&mut seat, 2, false);
assert_eq!(seat.settings.others, Some(Others::Shown));
Section::act_on(&mut seat, 2, true);
assert_eq!(seat.settings.others, Some(Others::Hidden));
Section::act_on(&mut seat, 2, true);
assert_eq!(seat.settings.others, Some(Others::NameTags));
// Owning one shows them by default, until the player chooses.
let mut owner = Seat::default();
owner.owner.owns = true;
assert_eq!(owner.others(), Others::Shown);
let mut rows = Vec::new();
Section::rows_of(&owner, &mut rows);
assert_eq!(
rows[2],
Row::Cycle {
label: ROWS[2],
value: Label::Key("horizon.extras.cosmetics.shown")
}
);
assert_eq!(rows.len(), TOOLTIPS.len());
}
}
@@ -19,9 +19,7 @@ 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)
{
if let Err(e) = super::cosmetics::register() {
log::error!("Cosmetics: their hooks came too late: {e}");
}
}
-1
View File
@@ -7,7 +7,6 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
- [AGENTS.md](../AGENTS.md) and [Architecture](architecture.md): where each part of the code lives, how `Game` is organised, which harness checks which area.
- [How Horizon is built](how-horizon-is-built.md): who writes Horizon, how changes are checked, and what is safe to run.
- [Release flow](release-flow.md): pull request checks, merging and the automatic Nightly releases that reach players.
- [Operations](operations.md): running Horizon by hand when no agent runs and the maintainer's machines are off: merging, releases, offline runners, the mods registry, Kira, the official server, security.
- [Build from source](../README.md#build-from-source): your own copy of the game, resource preparation, `cargo run`.
- [Build times](build-times.md): `tools/build_times.py` measures cold and incremental builds per crate and the critical path, writes a JSON report and a table, and compares two reports; the first numbers, Windows and Linux.
- [Native client](native-client.md): options, controls, the dedicated server, `horizon-ctl` scripting, packaging, architecture.
-133
View File
@@ -1,133 +0,0 @@
# Operations: running Horizon by hand
How the Horizon Team and the council keep Horizon working when no AI agent is running and the maintainer's machines
are off. Each section says what normally happens by itself, who can do it by hand, and what to do when it breaks.
How decisions are made is in [GOVERNANCE.md](../GOVERNANCE.md); how a change reaches players is in
[release-flow.md](release-flow.md). This page only says how to operate them.
## Who can do what
| Who | Can |
| --- | --- |
| **Horizon Team** (GitHub team `horizon-team`, write access) | Review, label and merge pull requests; approve a fork's workflow runs; re-run failed jobs; start workflows by hand (`gh workflow run`); add `release-blocker` to an issue; merge in horizon-mods (form pull requests too) and upload its `staging` files |
| **Trusted reviewers** (`.github/trusted-reviewers.txt`) | `/nightly`, `/close`, `/reopen`, `/label`, `/unlabel`, `/unclaim` by comment ([release-flow.md](release-flow.md#trusted-reviewers)) |
| **Council** (🏛️ Council on Discord) | Decide by poll (`/vote`, `/poll start`); emergency actions (GOVERNANCE.md 4.6) |
| **Organisation owners** (today: Ludovic only) | Repository variables and secrets (`SELF_HOSTED_CI`, `RELEASES_PAUSED`), branch protection, adding people to GitHub teams, merging past a missing review, the security advisories |
| **Code owners** (`.github/CODEOWNERS`, today: Ludovic only) | Approve pull requests that touch `.github/`, `tools/ci/`, the release, signing and update code |
The council's first poll gives the organisation a second owner (GOVERNANCE.md section 11). Until then, anything in
the last two rows waits for Ludovic.
## Merging a pull request
Nobody merges automatically. A Team member:
1. Reads the whole diff. For a fork, do it before approving its workflow runs (they wait under **Actions**:
"Approve and run") and before adding `run-parity`.
2. Waits for `verify`, the one required check. A pull request that changes no Rust finishes it in about a minute.
3. Merges with a merge commit once `verify` passed, no change request is open and every comment has been read. A
"don't merge" from anyone holds it. Never merge your own pull request, never use `gh pr merge --auto`, never
force-push a contributor's branch (merge main into it instead).
Risky pull requests (saves, the network or server thread, world generation, the launcher's installs and updates,
the harnesses, anything over ~500 lines) get `needs-proof` and wait until someone has played the change: both
single-player paths, a LAN guest, a screenshot for anything visible. The list is in
[release-flow.md](release-flow.md#merging).
A pull request that touches a CODEOWNERS path needs a code owner's approving review: GitHub keeps its merge button
locked until then. Leave it open and say in the thread that it waits for a code owner.
Pull requests opened by the maintainer's agents (author `ludovic111`, body starting "Opened by Ludovic's AI agent")
are ordinary pull requests: review and merge them like any other, or close them if they went stale. Closing keeps
the branch.
## Releases
Everything below runs by itself on GitHub; you only step in when something is wrong.
- **The Nightly** is the only build players get. The train (`release.yml`, every 10 minutes) starts one after 5
player-facing merges, or when the oldest has waited 6 hours, or at once for a pull request labelled
`release-now`. Only a commit where both `checks` and `parity-main` passed ships. Who may add `release-now` is the
council's choice; until it decides, leave the label to the maintainer.
- **Stopping Nightlies:** open an issue about the problem and label it `release-blocker`. Nothing is published
while it is open, forced builds included. Close it when the fix is on main.
- **Rolling back:** revert the pull request and merge the revert. Players can pick an earlier build on the
launcher's Versions page meanwhile.
- **The reviewers' picks:** `/nightly status` shows the set, `/nightly clear` builds main alone.
- **Launcher releases** follow the same train (`launcher.yml`).
The Nightly's macOS builds, its smoke test and parity-main run on the maintainer's Mac mini. While it is off,
**no Nightly and no launcher release can be published**: players keep the last ones, which keep working. See below.
## Self-hosted runners are offline
Some jobs run only on the maintainer's machines, because they need his own copy of Minecraft or a Mac
([ci-runners.md](ci-runners.md)). When those machines are off, those jobs wait up to a day and then fail. The
**runner watch** opens one issue, "CI: self-hosted runners are not taking jobs", within about 2 hours, listing what
is waiting, and closes it once the runners are back.
| Runner label | What waits | What to do |
| --- | --- | --- |
| `horizon-light` (bots) | Triage and claim commands, labels, overlap notes, the Mojang check | An organisation owner sets `SELF_HOSTED_CI` to `off` (Settings → Secrets and variables → Actions → Variables). Then everything runs on GitHub |
| `horizon-checks` | `verify` of this repository's own pull requests ending in 0 or 1 | Same switch (`off` or `light`), then re-run the waiting checks. Forks' pull requests never use these runners |
| `horizon-game` | A pull request's own parity job | It isn't required: merge on `verify`, and label pull requests that touch gameplay `needs-proof` |
| `horizon-parity-main` | parity-main on main | Nothing can be done on GitHub. Nightlies wait for the last commit it passed on |
| `horizon-nightly`, `horizon-release` | The Nightly's and the launcher's macOS builds | Nothing can be done on GitHub. No Nightly or launcher release until the Mac is back |
What keeps working without them: `verify` and merging, triage and labels (once `SELF_HOSTED_CI=off`), Kira, the
website, the mods registry and its checks, the official server.
What the council decides about it (which pull requests may still merge without the vanilla harnesses, and who could
host a runner with their own Minecraft) is a poll, not a rule on this page.
## Mojang republished the version
The hourly `mojang republish` workflow opens an issue "Mojang republished 26.3: <hash>" when Mojang changes the
version file the game is pinned to. If its text says the jars are unchanged, a pull request that pastes the snippet
from the issue into `research-lock.json` fixes it. New players can't prepare the game until a Nightly with that fix
is out, so label that pull request `release-now` (or ask the council to) once it merged.
## The mods registry (horizon-rust/horizon-mods)
- **Submissions from the website** arrive as pull requests authored by `ludovic111` (the registry's account). A
Team member reviews the mod (what it does, its licence, no sign-in or token handling, no Mojang files; see
`MOD-POLICY.md` there), approves, and squash-merges once `check` passed. Publishing to the registry is hourly
(`publish.yml`).
- **Fork pull requests:** approve their workflow run, and upload the mod's file to the `staging` release as
`<sha256>.<extension>` after checking its hash.
- **Taking a mod down:** a pull request adding it to `revoked.toml`. That file is a code-owner path: today only
Ludovic can approve it. For malware, two council members act at once as an emergency (GOVERNANCE.md 4.6), post
in #mod-log, and ask Ludovic to approve.
## Kira
Kira runs on Railway and needs nobody's machine.
- **Questions Kira can't answer** ("passed to the team") are listed in #kira-handoffs. Answer the member in their
thread.
- **Switches anyone on staff can use:** `/kira-ai off|on` (AI answers), the triage pause.
- **Polls:** `/vote question:` (council members) starts a 24-hour poll; `/poll start` for the other kinds. The
results are in #polls-and-decisions and in `https://kira-production-500e.up.railway.app/decisions.json`.
- **If Kira is down:** only the account holder can reach Railway today. Post in #council so someone tells him.
## The official server
The server (play.playhorizon.dev) runs on its host's panel, not on the maintainer's machines, and keeps running the
engine it has. Engine updates, backups and disk clean-ups are done by people with panel access; who has it, and the
steps, are in the council's private notes. Players' reports go to #mod-chat as usual. If the server is down, a
council member tells the panel holders and the host.
## Security problems
- **Reports** come through GitHub's private vulnerability reporting ([security.md](security.md#reporting-a-security-problem)).
- **A leaked secret, a malicious pull request or mod, a hijacked account:** any two council members act at once
(GOVERNANCE.md 4.6): label an issue `release-blocker` to stop Nightlies, `/nightly clear` the picks, close the pull
request, take the mod down, remove the account's roles, and ask the account holder to rotate the secret. Record it
with `/emergency` in Kira once that command exists, else in #council.
## Who to ask
- Code and merges: the Horizon Team, in #horizon-team.
- Decisions: the council, by poll.
- Accounts (GitHub organisation, Discord ownership, Railway, the server panel, domains): the account holders; the
list is in GOVERNANCE.md section 11.
-1
View File
@@ -106,7 +106,6 @@
"horizon-client-lib -> horizon-shader-api",
"horizon-client-lib -> horizon-shaders",
"horizon-client-lib -> horizon-telemetry",
"horizon-client-render -> horizon-cosmetics",
"horizon-client-render -> horizon-hdr",
"horizon-client-render -> horizon-optifine",
"horizon-client-render -> horizon-polytone",