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
Ludovic Marie 537539fe58 Merge pull request #14291 from horizon-rust/split/cosmetics-fx
🤖 Agent: step 2b-iii of spec #14091 moves companions, trails and auras onto the hooks; stacked on #14282.
2026-11-09 23:37:32 +01:00
Ludovic Marie cbbd362eed Merge pull request #14011 from v0ntr3n/fix/stand-command-hurt-events-freeze-immunity
🤖 Agent: the explosion/ignite/burn branches now attribute their game events to the cause in place and ignition clears TicksFrozen; damage_immunity checks the stand's active slots first. Reworked so no dead copy remains; 15/15 stand tests pass with resources.
2026-11-09 23:37:28 +01:00
Ludovic Marie 1430b9990a Merge pull request #14381 from horizon-rust/split/discord-setting
🤖 Agent: the Discord switch keeps its discordPresence line in horizon-extras.txt through horizon-discord's settings store, so players keep their choice while the row moves to Extras > Multiplayer; e2e 4/4 and verify green on this head.
2026-11-09 23:37:24 +01:00
Ludovic Marie a84f637dd6 Merge pull request #14282 from horizon-rust/split/cosmetics-fx-api
🤖 Agent: step 2b-ii of spec #14091 gives PlayerDraw motion, view, looks, particles and first_person so the cosmetics need nothing else from the engine; stacked on #14274.
2026-11-09 23:32:09 +01:00
Horizon agentandClaude Opus 5.5 c21f627431 discord_presence_e2e: scroll the Extras page to the switch
Only the rows on screen are widgets; the Multiplayer group is below the fold
of an offscreen window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:26:36 +01:00
Horizon agentandClaude Opus 5.5 62e62c21e6 horizon-discord: the switch is the first splitscreen player's (seat)
hooks::seat() is on main now: other players' settings neither load nor
save the line, and their Extras page has no row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:02:53 +01:00
Horizon agent a6350aa8f9 Merge branch 'split/discord-crate' into split/discord-setting
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 22:02:16 +01:00
Horizon agentandClaude Opus 5.5 4e22c1ad9a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:27:08 +01:00
Horizon agentandClaude Opus 5.5 e1aa435188 horizon-discord: the Discord switch leaves the engine's Extras (layer plan M5)
The crate keeps its switch: a settings store for its discordPresence line of
horizon-extras.txt (players keep their choice) and a row in the Extras page's
Multiplayer group, which horizon-official registers. The engine loses
Extras::discord_presence, Item::DiscordPresence and the Horizon Settings
button; the hooks lose Activity::Hidden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:19:29 +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 e138304d2e Put the aura test's item at the feet
It reused the crown's 18-pixel attach offset, which an aura may not have
(at most 6 pixels above the feet), so the item failed to parse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:10:39 +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
Horizon agentandClaude Opus 5.5 4be9343eca Pass trail corners by value to to_array
Vec3::to_array takes &self, so it can't be handed to array::map directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:32:08 +01:00
Horizon agentandClaude Opus 5.5 03fd6705a1 Drop the unused shown binding in Seat::player_layers
worn_of decides what shows since the fx moved onto the hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:27:38 +01:00
Horizon agent a5a60598e4 Merge branch 'split/cosmetics-fx-api' into split/cosmetics-fx 2026-11-09 20:20:28 +01:00
Horizon agent 0fd4e22139 Merge branch 'split/cosmetics-own' into split/cosmetics-fx-api
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:20:25 +01:00
Horizon agentandClaude Opus 5.5 3d9df204e9 Cosmetics: companions, trails and auras through the hooks; the game's own path removed (spec #14091 step 2b-iii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:08:05 +01:00
Horizon agentandClaude Opus 5.5 984b7d8635 Hooks: PlayerDraw gets motion, view, looks and particles, and first_person (spec #14091 step 2b-ii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:02:47 +01:00
Horizon agentandClaude Opus 5.5 661a253ec4 Armor stand /damage: attribute and clear freeze in the existing branches
Main already attributes the hit branch and has entity_event_by. The
explosion, ignite and burn branches now take the cause in place, so the
separate refused function (which left them dead) is gone; causeDamage keeps
one name. The frost-walker test no longer shadows game() (it did not compile).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:04:13 +01:00
Horizon agent 7bfa76577f Merge remote-tracking branch 'origin/main' into w14011 2026-11-09 18:57:29 +01:00
Tran Anh Van 96aa9791e6 Isolate ArmorStand.hurtServer changes to avoid 3-way merge overlap 2026-11-09 23:12:17 +07:00
Tran Anh Van c66936f747 Sync sculk entity_event_by and entity_killed_event_by with upstream main 2026-11-09 22:54:38 +07:00
Tran Anh Van 0a6874877d Sync Guest::may_build with upstream main 2026-11-09 22:49:53 +07:00
Tran Anh Van e2ad02a58a Complete ArmorStand.hurtServer game events, freeze clearing, and damage immunity 2026-11-09 22:46:25 +07:00
26 changed files with 1447 additions and 1307 deletions

No files matched your search

-34
View File
@@ -1,34 +0,0 @@
name: runner watch
# Every 30 minutes: one issue when jobs wait over 2 hours for a self-hosted runner nobody runs (the maintainer's
# machines are off), closed again once nothing waits (tools/ci/runner_watch.py, docs/operations.md). It runs on
# GitHub's runners, never ours: it must work when every self-hosted runner is gone.
# gh workflow run "runner watch" -f dry_run=true
on:
schedule:
- cron: '11,41 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with the issue
type: boolean
default: false
permissions:
actions: read
contents: read
issues: write
concurrency:
group: runner-watch
cancel-in-progress: false
jobs:
watch:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Jobs waiting for a self-hosted runner
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/runner_watch.py ${{ inputs.dry_run && '--dry-run' || '' }}
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" }
+12 -235
View File
@@ -2,7 +2,7 @@
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
//! (No AI, Agent Control) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -145,8 +145,6 @@ pub struct Extras {
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
@@ -168,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`).
@@ -233,7 +213,6 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
@@ -241,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(),
}
@@ -257,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))
@@ -338,7 +305,6 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
@@ -357,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);
@@ -376,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:{}\ndiscordPresence:{}\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),
@@ -418,7 +375,6 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
@@ -426,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),
);
@@ -613,8 +559,6 @@ pub enum Item {
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
@@ -623,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),
}
@@ -637,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"),
@@ -659,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).
@@ -691,19 +618,12 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
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(),
}
}
@@ -724,19 +644,12 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
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(),
}
}
@@ -768,17 +681,10 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
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(),
}
}
@@ -792,10 +698,8 @@ impl Item {
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | Pregen => {
return None;
}
})
@@ -844,9 +748,6 @@ impl Item {
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
@@ -856,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."
@@ -900,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,
}
}
@@ -927,15 +804,10 @@ impl Item {
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
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,
})
@@ -945,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,
@@ -986,21 +842,10 @@ impl Item {
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
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 => {}
}
@@ -1019,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);
@@ -1104,7 +882,7 @@ mod tests {
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles && !d.pregen);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && !d.wheel_cycles_settings);
assert!(!d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -1143,7 +921,6 @@ mod tests {
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
+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() {
@@ -124,21 +124,15 @@ impl OptionsUi {
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai)
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
)
.collect();
if s.extras.no_ai {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
} else {
Row::Buttons(
open("horizon.pages.open.agent", Page::Agent),
Some(self.extra_button(s, crate::extras::Item::NoAi)),
)
},
];
self.added_entries(&mut rows);
rows
}
+3 -3
View File
@@ -6716,8 +6716,8 @@ impl Game<'_> {
.extend(out.drain(player_start..));
}
self.mark_motion(out.len(), 0);
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
self.cosmetics_fx_geometry(camera, alpha, &mut out);
// What crates draw around players: first person, see-through (game/player_hooks.rs).
self.players_after_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);
@@ -12528,7 +12528,7 @@ pub fn run_with() -> Result<()> {
narrator::start(&early_settings);
}
// A game window's hooks (Discord's presence): never for a server.
activity::client_started(&options, &early_settings);
activity::client_started(&options);
// Snapshots of derived tables live in the game folder, never beside the
// shared resources.
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
+2 -10
View File
@@ -6,7 +6,7 @@ use horizon_hooks::{Activity, Client, Dimension, Play};
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
/// `Hooks::client_started`.
pub(super) fn client_started(options: &Options, settings: &Settings) {
pub(super) fn client_started(options: &Options) {
if options.dedicated.is_some() {
return;
}
@@ -14,12 +14,7 @@ pub(super) fn client_started(options: &Options, settings: &Settings) {
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
// The Discord switch's state is the first thing the presence hears, so nothing is sent with
// it off.
horizon_hooks::engine::activity(match settings.extras.discord_presence {
true => Activity::Menus,
false => Activity::Hidden,
});
horizon_hooks::engine::activity(Activity::Menus);
horizon_hooks::engine::client_started(&Client::new(automated));
}
@@ -33,9 +28,6 @@ impl Game<'_> {
horizon_hooks::engine::activity(self.activity());
}
fn activity(&self) -> Activity {
if !self.settings.extras.discord_presence {
return Activity::Hidden;
}
if self.title.is_some() {
return Activity::Menus;
}
+93 -14
View File
@@ -1278,10 +1278,21 @@ impl Game<'_> {
.raw
.get(&id)
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
// Entity.isInvulnerableTo: the flag, then EnchantmentHelper.isImmuneToDamage.
let a = &self.armor_stands[i];
if (invulnerable && !creative) || a.invisible || a.marker {
if (invulnerable && !creative)
|| a.invisible
|| a.marker
|| self.stand_immune_to_damage(i, damage_type, causing)
{
return false;
}
// ArmorStand.hurtServer attributes its game events to the source
// entity: the player, a LAN guest or the mob.
let by = match causing {
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
_ => None,
};
let (explosion, ignites, burns) = (
tagged("minecraft:is_explosion"),
tagged("minecraft:ignites_armor_stands"),
@@ -1292,21 +1303,26 @@ impl Game<'_> {
tagged("minecraft:always_kills_armor_stands"),
);
if explosion {
self.break_armor_stand_by_anything(i);
self.break_armor_stand_with_source(i, by);
return false;
}
if ignites {
if self.armor_stands[i].fire_ticks > 0 {
self.cause_stand_damage(i, 0.15);
self.cause_stand_damage(i, 0.15, by);
} else {
// igniteForSeconds(5.0f): at least 100 ticks.
// igniteForSeconds(5.0f): at least 100 ticks, and
// Entity.igniteForTicks calls clearFreeze.
let a = &mut self.armor_stands[i];
a.fire_ticks = a.fire_ticks.max(100);
if let Some(horizon_world::nbt::Tag::Compound(map)) = self.persist.raw.get_mut(&id)
{
map.remove(&"TicksFrozen".into());
}
}
return false;
}
if burns && self.armor_stands[i].health > 0.5 {
self.cause_stand_damage(i, 4.0);
self.cause_stand_damage(i, 4.0, by);
return false;
}
if !breaks && !always_kills {
@@ -1321,22 +1337,85 @@ impl Game<'_> {
if !may_build {
return false;
}
// ArmorStand.hurtServer attributes its game events to the source
// entity: the player, a LAN guest or the mob.
let by = match causing {
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
_ => None,
};
self.break_armor_stand_by_hit(i, creative, always_kills, by)
}
/// `EnchantmentHelper.isImmuneToDamage` for the stand at `i`: whether an
/// enchantment on an active equipment slot has a `minecraft:damage_immunity`
/// effect whose requirements hold for `damage_type` and `causing`.
fn stand_immune_to_damage(
&mut self,
i: usize,
damage_type: &str,
causing: Option<super::commands::Target>,
) -> bool {
use super::commands::Target;
let mut checks = Vec::new();
let equipment = &self.armor_stands[i].equipment;
for (stack, slot) in equipment.iter().zip(ArmorStand::SLOTS) {
let Some(stack) = stack else { continue };
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
let hand = matches!(slot, "mainhand" | "offhand");
for (id, level) in stack.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
if !def.slots.iter().any(|s| {
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
}) {
continue;
}
let immunity = &def.effects["minecraft:damage_immunity"];
for entry in immunity.as_array().into_iter().flatten() {
checks.push((entry.get("requirements").cloned(), *level));
}
}
}
if checks.is_empty() {
return false;
}
let attacker = match causing {
Some(Target::Player | Target::Guest(_)) => Some("minecraft:player".to_owned()),
Some(Target::Mob(m_id)) => self
.mobs
.iter()
.find(|m| m.id == m_id)
.map(|m| m.kind.id.clone()),
Some(Target::Stand(_)) => Some("minecraft:armor_stand".to_owned()),
_ => None,
};
let canonical = if damage_type.contains(':') {
damage_type.to_owned()
} else {
format!("minecraft:{damage_type}")
};
let ctx = horizon_client::enchant::EffectContext {
items: Some(self.res.items()),
damage_type: Some(&canonical),
is_direct: attacker.is_some(),
this: Some("minecraft:armor_stand"),
attacker: attacker.as_deref(),
direct_attacker: attacker.as_deref(),
..Default::default()
};
let ench = &self.res.stations().enchantments;
let rng = &mut self.world.random;
for (req, level) in checks {
if req.is_none_or(|r| ench.condition(&r, level, &ctx, rng)) {
return true;
}
}
false
}
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
/// at 0.5 or less.
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
/// at 0.5 or less, else `gameEvent(ENTITY_DAMAGE, damageSource.getEntity())`.
fn cause_stand_damage(&mut self, i: usize, amount: f32, by: Option<super::commands::Target>) {
let health = self.armor_stands[i].health - amount;
if health <= 0.5 {
self.break_armor_stand_by_anything(i);
self.break_armor_stand_with_source(i, by);
} else {
let at = self.armor_stands[i].position;
self.armor_stands[i].health = health;
self.stand_game_event("minecraft:entity_damage", [at.x, at.y, at.z], by);
}
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
+202 -399
View File
@@ -10,21 +10,16 @@
pub(super) mod emote_wheel;
pub(super) mod emotes;
mod fx;
mod settings;
use super::Game;
use horizon_client::fast_hash::FxHashMap;
use horizon_client::mesher::Vertex;
use horizon_client::mobs::anim::Poses;
use horizon_client::mobs::{Mob, Part, Polygon};
use horizon_cosmetics::follow::Follow;
use horizon_cosmetics::prefs::{self, Others};
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{
Badge, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Mark, Mesh, NameDecoration,
PlayerDraw, PlayerModel, PlayerModels, Quad, SpriteStore, Sprites,
Badge, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Look, Mark, Mesh, NameDecoration,
Pass, PlayerDraw, PlayerModel, PlayerModels, Quad, SpriteStore, Sprites,
};
use std::cell::{Cell, RefCell};
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
@@ -32,46 +27,10 @@ use std::time::{Duration, Instant};
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
const MARK: Mark = &horizon_cosmetics::mark::MARK;
/// An item as the renderer takes it.
struct Baked {
item: Arc<Item>,
/// A model per bone it rides (a shirt's sleeves are on the arms).
models: Vec<(horizon_cosmetics::Bone, Arc<Part>)>,
/// Its texture's sprite (a 64×64 skin slot of the atlas).
location: String,
/// The frame it was last marked drawn in (its slot is kept).
marked: Cell<u32>,
}
impl Baked {
fn new(item: &Arc<Item>, frame: u32) -> Self {
Self {
item: item.clone(),
models: item
.roots()
.map(|(bone, root)| (bone, Arc::new(part(root))))
.collect(),
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
marked: Cell::new(frame.wrapping_sub(1)),
}
}
}
#[derive(Default)]
pub(super) struct Cosmetics {
/// The hooks' client (`Seat`), taken once it started.
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
/// By the item's address; each entry holds its item, so the address isn't reused.
baked: RefCell<FxHashMap<usize, Baked>>,
/// Items drawn whose texture isn't in the atlas yet.
uploads: RefCell<Vec<Arc<Item>>>,
frame: Cell<u32>,
/// Kept between players so a frame of avatars allocates none: a companion or an aura.
poses: RefCell<Poses>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
/// Companions, trails and auras.
fx: RefCell<Fx>,
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
emotes: emotes::Emotes,
/// The emote wheel (`cosmetics/emote_wheel.rs`).
@@ -130,49 +89,6 @@ fn load_owned_emotes(
)
}
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
const OWN: u32 = u32::MAX;
/// Full light, for what glows.
const FULL_LIGHT: u32 = 240 | 240 << 8;
/// What companions and trails keep between frames, per wearer (marked with the frame they were
/// last drawn in; the others are dropped), and the frame's trails and auras, drawn after the
/// players (`cosmetics_fx_geometry`).
#[derive(Default)]
struct Fx {
companions: FxHashMap<u32, (Follow, u32)>,
trails: FxHashMap<u32, (Emitter, u32)>,
budget: Budget,
clock: Option<(Instant, Instant)>,
/// Ticks since the first frame, and the last frame's length in seconds.
now: f64,
dt: f32,
/// Trails and auras: see-through.
clear: Vec<Vertex>,
}
impl Fx {
/// A new frame: the clock moves on and what no frame drew since the last one goes.
fn frame(&mut self, frame: u32) {
let now = Instant::now();
let (start, last) = self.clock.get_or_insert((now, now));
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
*last = now;
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
self.companions.retain(|_, (_, mark)| seen(*mark));
let budget = &mut self.budget;
self.trails.retain(|_, (e, mark)| {
let keep = seen(*mark);
if !keep {
e.clear(budget);
}
keep
});
self.clear.clear();
}
}
/// Whether the signed-in account owns a cosmetic: the answer kept from the last run first
/// (`prefs::owned`), then the service's, asked once a run and retried after a failure like Show my
/// cosmetics. Not known yet, or no session: not owning.
@@ -329,20 +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,
extras: &horizon_client::extras::Extras,
own: bool,
shown: bool,
uuid: impl FnOnce() -> Option<u128>,
) -> Option<horizon_cosmetics::Worn> {
let shown = if own {
extras.cosmetics_mine
} else {
extras.effective_others() != Others::Hidden
};
if !shown {
return None;
}
@@ -369,24 +278,6 @@ fn own_uuid(
/// How often a looping emote is said again (the service keeps one a minute).
const LOOP_AGAIN: Duration = Duration::from_secs(30);
/// The game's model of an item's part.
fn part(p: &horizon_cosmetics::Part) -> Part {
Part {
name: p.name.clone(),
pose: p.pose,
polygons: p
.faces
.iter()
.map(|f| Polygon {
normal: f.normal,
vertices: f.vertices,
cull_back: false,
})
.collect(),
children: p.children.iter().map(part).collect(),
}
}
/// The texture in the top-left corner of a skin slot's 64×64 RGBA.
fn slot_image(item: &Item) -> Vec<u8> {
let t = &item.texture;
@@ -468,6 +359,10 @@ struct Seat {
last_sprite: u64,
/// Sprites drawing missed, put in the atlas next frame.
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
@@ -502,20 +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,
/// 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.
@@ -538,14 +423,26 @@ 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) {
service(|s| {
s.solo = frame.solo;
s.frame();
s.fx.frame();
s.put_sprites(sprites);
});
}
@@ -564,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)
})
}
@@ -574,6 +471,11 @@ impl horizon_hooks::Hooks for Hooks {
service(|s| s.player_layers(model, draw));
}
/// The player's own companion in first person.
fn first_person(&self, own: &PlayerModel, draw: &mut dyn PlayerDraw) {
service(|s| s.first_person(own, draw));
}
fn camera(&self, own: &CameraView) -> Option<CameraOverride> {
let start = service(|s| s.said.pull)?;
let t = ((own.age - start) / 5.0).clamp(0.0, 1.0);
@@ -582,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) {
@@ -654,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);
@@ -678,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);
}
}
@@ -693,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
@@ -718,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)),
@@ -782,34 +701,37 @@ impl Seat {
self.items.retain(|_, k| Arc::strong_count(&k.item) > 1);
}
/// The cosmetics `m` wears, on its bones, and an emote's props (companions, trails and auras
/// are still the game's: `cosmetics_geometry`).
/// The cosmetics `m` wears, on its bones and around it in the world (companions, trails,
/// auras: `cosmetics/fx.rs`), and an emote's props.
fn player_layers(&mut self, m: &PlayerModel, draw: &mut dyn PlayerDraw) {
if m.invisible {
return;
}
let said = &self.said;
let own = m.kind == Kind::Own;
let shown = if own { said.mine } else { !said.others_hidden };
// "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| {
let (t, w) = p.at(ticks)?;
(p.item.vertices > 0).then(|| (p.item.clone(), t, w))
});
let worn = match (&self.client, self.uuid_of(m)) {
(Some(client), Some(uuid)) if shown && bodies => client.frame().worn(uuid),
_ => None,
};
let client = self.client.as_ref().filter(|_| bodies);
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];
let worn_items = worn.iter().flat_map(|w| w.iter()).map(|i| (i, None));
let emote_items = emote.iter().map(|(i, t, w)| (i, Some((*t, *w))));
for (item, emoting) in worn_items.chain(emote_items) {
// Covered by gear, or drawn by no bone (a name tag style, companions, trails, auras).
// On no bone, around the model in the world.
if let (Slot::Companion | Slot::Trail | Slot::Aura, None) = (item.slot, emoting) {
if draw.pass() == Pass::World {
self.fx(m, item, draw);
}
continue;
}
// Covered by gear, or drawn by no bone (a name tag style).
if emoting.is_none() && covered(m.gear, item.slot) {
continue;
}
@@ -831,7 +753,7 @@ impl Seat {
matrix
}
};
draw.draw(&mesh, matrix, uv, &mut |p| {
draw.draw(&mesh, matrix, Look::new(uv), &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose);
if let (Some((t, w)), Some(e)) = (emoting, &item.emote) {
e.pose_props(t, |n| item.rest(n), |n, pose| *p.pose(n) = pose);
@@ -846,6 +768,19 @@ impl Seat {
}
}
/// The window's own companion while its body is out of view.
fn first_person(&mut self, m: &PlayerModel, draw: &mut dyn PlayerDraw) {
let Some(client) = self.client.as_ref().filter(|_| !m.invisible) else {
return;
};
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) {
self.fx(m, item, draw);
}
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
@@ -862,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 {
@@ -927,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;
@@ -940,17 +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.mine = extras.cosmetics_mine;
said.listed = extras.horizon_listed;
said.others_shown = extras.effective_others() == Others::Shown;
said.others_hidden = extras.effective_others() == Others::Hidden;
said.marks = extras.horizon_marks;
said.solo = extras.cosmetics_solo;
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));
@@ -961,36 +888,13 @@ impl Game<'_> {
});
}
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
/// textures the last frame wanted into the atlas.
/// 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 c = &mut self.cosmetics;
let owns = service(|s| {
if c.client.is_none() {
c.client = s.client.clone();
c.local = s.local;
}
s.owner.owns
});
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = owns;
c.frame.set(c.frame.get().wrapping_add(1));
c.fx.get_mut().frame(c.frame.get());
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
for item in uploads {
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
if !self.gui.atlas.sprites.contains_key(&location) {
self.store_skin(location, slot_image(&item));
}
}
// Items no player wears any more.
self.cosmetics
.baked
.borrow_mut()
.retain(|_, b| Arc::strong_count(&b.item) > 1);
self.cosmetics.local = service(|s| s.local);
}
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
@@ -1016,159 +920,11 @@ impl Game<'_> {
})
.ok();
}
/// What `m` wears, if cosmetics are shown for it: `own` for the player's own model.
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
let client = self.cosmetics.client.as_ref()?;
worn_of(client, &self.settings.extras, own, || {
self.cosmetics_uuid(m, own)
})
}
/// The UUID the service knows `m` by: a player's, or a mannequin's id with local items.
fn cosmetics_uuid(&self, m: &Mob, own: bool) -> Option<u128> {
if own {
own_uuid(
self.remote.as_ref().map(|r| r.profile()),
self.integrated.is_some(),
self.settings.extras.cosmetics_solo,
|| horizon_client::auth::current().map(|s| s.uuid),
)
} else if &*m.kind.id == "minecraft:player" {
self.remote_player_uuid(m.id)
} else if self.cosmetics.local && &*m.kind.id == "minecraft:mannequin" {
Some(u128::from(m.id))
} else {
None
}
}
/// The companion, trail and aura `m` wears, in the world (`camera`: where the view's camera
/// is); the items on its bones are the hook's (`Seat::player_layers`). `gpu`: a companion
/// goes on the GPU's skin path (the mob pass's skin frame, begun) and the bounds of what went
/// there come back, for culling; else its vertices go to `out`.
#[allow(clippy::too_many_arguments)]
pub(super) fn cosmetics_fx_of(
&self,
m: &Mob,
own: bool,
base: glam::Mat4,
camera: glam::DVec3,
alpha: f32,
light: u32,
gpu: bool,
out: &mut Vec<horizon_client::mesher::Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
// "Name tags only": other players' styles beside their names, nothing around them.
let bodies = own || self.settings.extras.effective_others() == Others::Shown;
// The player's own in first person: only its companion, drawn after the drain of the
// first-person model (`cosmetics_fx_geometry`).
if !bodies || own && !self.own_body_in_view() {
return None;
}
let worn = self.worn(m, own)?;
if horizon_client::mobs::trader::is_invisible(m) {
return None;
}
let mut poses = self.cosmetics.poses.borrow_mut();
let mut bounds = None;
for item in worn.iter() {
if let Slot::Companion | Slot::Trail | Slot::Aura = item.slot {
let at = fx::Wearer {
m,
own,
key: if own { OWN } else { m.id },
base,
camera,
alpha,
light,
};
bounds = self.cosmetics_fx(&at, item, gpu, bounds, &mut poses, out);
}
}
bounds
}
/// An item's models and its texture's corners in the atlas, once its texture is there (it is
/// asked for meanwhile).
#[allow(clippy::type_complexity)]
fn cosmetics_baked(
&self,
item: &Arc<Item>,
) -> Option<(Vec<(horizon_cosmetics::Bone, Arc<Part>)>, [f32; 4])> {
let frame = self.cosmetics.frame.get();
let mut baked = self.cosmetics.baked.borrow_mut();
let b = baked
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| Baked::new(item, frame));
let Some(rect) = self.gui.atlas.sprites.get(&b.location) else {
self.cosmetics.uploads.borrow_mut().push(item.clone());
return None;
};
if b.marked.replace(frame) != frame {
self.skin_downloads.borrow_mut().drawn(&b.location);
}
// The texture fills the top-left of its 64×64 slot.
let (w, h) = (
item.texture.width as f32 / 64.0,
item.texture.height as f32 / 64.0,
);
let uv = [
rect.u0,
rect.v0,
rect.u0 + (rect.u1 - rect.u0) * w,
rect.v0 + (rect.v1 - rect.v0) * h,
];
Some((b.models.clone(), uv))
}
/// One model at `matrix`, posed by `poses`: on the GPU's skin path when `gpu` (the bounds
/// grow to hold it), else its vertices go to `out`.
#[allow(clippy::too_many_arguments)]
fn cosmetics_model(
&self,
model: &Arc<Part>,
matrix: glam::Mat4,
poses: &horizon_client::mobs::anim::Poses,
uv: [f32; 4],
light: u32,
gpu: bool,
bounds: Option<(glam::Vec3, glam::Vec3)>,
out: &mut Vec<Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
if gpu {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
skin.bounds = 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) {
return skin.bounds;
}
}
horizon_client::mobs::render_model(
model,
matrix,
&|p: &Part| poses.pose(p),
uv,
light,
0,
out,
);
bounds
}
}
#[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.
@@ -1229,24 +985,32 @@ mod tests {
const OTHER: u128 = 0x1234;
/// What another player wears as `extras` shows it, drawn frame after frame for up to
/// 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 `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, extras, false, || Some(OTHER));
let worn = worn_of(&client, seat.shows(false), || Some(OTHER));
if worn.is_some() || Instant::now() >= deadline {
break worn;
}
@@ -1258,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()])
@@ -1279,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());
}
@@ -1300,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, &extras, true, uuid);
let worn = worn_of(&client, seat.shows(true), uuid);
if worn.is_some() || Instant::now() >= deadline {
break worn;
}
@@ -1340,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;
@@ -1350,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.
@@ -1382,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:?}");
}
}
@@ -1434,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![
@@ -1457,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());
@@ -1465,10 +1205,16 @@ mod tests {
/// A player model as `player_layers` draws on it: one bone, the sprites it is given; logs
/// each draw (mesh, matrix, uv, its root's scale once posed).
#[derive(Default)]
struct Canvas {
pose: Option<Arc<dyn BodyPose>>,
sprite: Option<[f32; 4]>,
drawn: Vec<(String, [[f32; 4]; 4], [f32; 4], [f32; 3])>,
/// The model's matrix (`LEFT_ARM` if None) and how it moves.
base: Option<glam::Mat4>,
motion: horizon_hooks::players::Motion,
/// Each draw's look: see-through alpha and light.
looks: Vec<(Option<f32>, horizon_hooks::players::Light)>,
}
const LEFT_ARM: [[f32; 4]; 4] = [[2.0, 0.0, 0.0, 0.0]; 4];
@@ -1480,6 +1226,22 @@ mod tests {
fn ticks(&self) -> f32 {
102.0
}
fn base(&self) -> [[f32; 4]; 4] {
self.base.map_or(LEFT_ARM, |b| b.to_cols_array_2d())
}
fn motion(&self) -> horizon_hooks::players::Motion {
self.motion
}
fn view(&self) -> horizon_hooks::players::View {
Default::default()
}
fn particles(
&mut self,
_: [f32; 4],
_: &[horizon_hooks::players::Particle],
_: horizon_hooks::players::Light,
) {
}
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
(bone == Bone::LeftArm).then_some(LEFT_ARM)
}
@@ -1493,7 +1255,7 @@ mod tests {
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 4],
look: Look,
pose: &mut dyn FnMut(&mut dyn horizon_hooks::players::PartPoses),
) {
struct Rest(std::collections::HashMap<String, [f32; 9]>);
@@ -1505,8 +1267,13 @@ mod tests {
let mut rest = Rest([(mesh.name.clone(), mesh.pose)].into());
pose(&mut rest);
let root = rest.0[&mesh.name];
self.drawn
.push((mesh.name.clone(), matrix, uv, [root[6], root[7], root[8]]));
self.looks.push((look.see_through, look.light));
self.drawn.push((
mesh.name.clone(),
matrix,
look.uv,
[root[6], root[7], root[8]],
));
}
}
@@ -1536,17 +1303,10 @@ mod tests {
emotes::set_model_playing(&mut view, 9, Some(playing));
let mut canvas = Canvas {
pose: view.0[0].1.clone(),
sprite: None,
drawn: Vec::new(),
..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());
@@ -1562,4 +1322,47 @@ mod tests {
assert!((s - weight).abs() < 1e-6, "{scale:?} by {weight}");
}
}
#[test]
fn an_aura_goes_see_through_at_the_feet_unless_swimming_far_or_hidden() {
use horizon_hooks::players::{Light, Motion};
let ring = CROWN
.replace(r#""slot": "head", "bone": "head""#, r#""slot": "aura""#)
// At the feet: an aura reaches at most 6 pixels above them.
.replace(r#""attach": {"offset": [0, -18, 0]},"#, "")
.replace(
r#""uv": [0, 0]}]}]}"#,
r#""uv": [0, 0]}]}], "aura": {"at": "feet", "alpha": 0.5}}"#,
);
let ring = Arc::new(Item::parse(ring.as_bytes()).unwrap());
let at = glam::Vec3::new(3.0, 0.0, 4.0);
let mut canvas = Canvas {
sprite: Some([0.0, 0.0, 0.5, 0.5]),
base: Some(glam::Mat4::from_translation(at)),
..Canvas::default()
};
let other = PlayerModel::new(9, Kind::Player, Some(5), 102, false);
let own = PlayerModel::new(1, Kind::Own, None, 102, false);
let mut seat = Seat::default();
seat.fx(&other, &ring, &mut canvas);
// Half see-through, lit as the wearer, 24 pixels under the neck.
assert_eq!(canvas.looks, [(Some(0.5), Light::Model)]);
let feet = glam::Mat4::from_cols_array_2d(&canvas.drawn[0].1).w_axis;
assert_eq!(feet.truncate(), at + glam::Vec3::new(0.0, 1.5, 0.0));
// Swimming, it would tip over with the body: none.
canvas.motion = Motion::default().poses(false, false, false, true);
seat.fx(&other, &ring, &mut canvas);
canvas.motion = Motion::default();
// Past 48 blocks: none.
canvas.base = Some(glam::Mat4::from_translation(glam::Vec3::X * 49.0));
seat.fx(&other, &ring, &mut canvas);
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.settings.hide_fx = true;
seat.fx(&other, &ring, &mut canvas);
assert_eq!(canvas.looks.len(), 1);
seat.fx(&own, &ring, &mut canvas);
assert_eq!(canvas.looks.len(), 2);
}
}
+160 -254
View File
@@ -1,47 +1,78 @@
//! Companions, trails and auras ([docs/cosmetics.md](../../../../../docs/cosmetics.md)): worn
//! like the other cosmetics but on no bone. A companion floats beside its wearer and follows with
//! a lag (`horizon_cosmetics::follow`), drawn with the wearer; a trail's particles
//! (`horizon_cosmetics::trail`) and an aura blend, so they go after every player in one
//! see-through span (`cosmetics_fx_geometry`). Nothing of them is an entity, collides or is sent
//! to a server.
//! (`horizon_cosmetics::trail`) and an aura blend, so they are drawn see-through after every
//! player (`PlayerDraw::particles`, `Look::see_through`). Nothing of them is an entity, collides
//! or is sent to a server.
use super::{FULL_LIGHT, OWN};
use crate::game::Game;
use super::Seat;
use glam::{DVec3, Mat4, Vec3};
use horizon_client::mesher::Vertex;
use horizon_client::mobs::anim::Poses;
use horizon_client::mobs::{Mob, Part};
use horizon_client::fast_hash::FxHashMap;
use horizon_cosmetics::follow::{self, Follow};
use horizon_cosmetics::trail::Emitter;
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{Kind, Light, Look, Particle, Pass, PlayerDraw, PlayerModel};
use std::sync::Arc;
use std::time::Instant;
/// How far from the camera (blocks) a wearer's companion, trail and aura are drawn.
const FAR: f32 = 48.0;
/// Who wears it, as drawn this frame.
pub(super) struct Wearer<'a> {
pub m: &'a Mob,
/// The player's own model.
pub own: bool,
/// `OWN`, or the entity id.
pub key: u32,
/// The wearer's model matrix (`render::base_transform`), relative to `camera`.
pub base: Mat4,
pub camera: DVec3,
pub alpha: f32,
pub light: u32,
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
const OWN: u32 = u32::MAX;
/// What companions and trails keep between frames, per wearer (marked with the frame they were
/// last drawn in; the others are dropped).
#[derive(Default)]
pub(super) struct Fx {
companions: FxHashMap<u32, (Follow, u32)>,
trails: FxHashMap<u32, (Emitter, u32)>,
budget: Budget,
clock: Option<(Instant, Instant)>,
/// Ticks since the first frame, and the last frame's length in seconds.
now: f64,
dt: f32,
frame: u32,
/// Kept so a frame of trails allocates none.
particles: Vec<Particle>,
}
/// `m`'s head yaw this frame (degrees).
fn head_yaw(m: &Mob, alpha: f32) -> f32 {
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
m.previous_head_yaw + turn * alpha
}
impl Fx {
/// A new frame: the clock moves on and what no frame drew since the last one goes.
pub(super) fn frame(&mut self) {
self.frame = self.frame.wrapping_add(1);
let frame = self.frame;
let now = Instant::now();
let (start, last) = self.clock.get_or_insert((now, now));
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
*last = now;
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
self.companions.retain(|_, (_, mark)| seen(*mark));
let budget = &mut self.budget;
self.trails.retain(|_, (e, mark)| {
let keep = seen(*mark);
if !keep {
e.clear(budget);
}
keep
});
}
/// Whether `m` lies in a bed.
fn asleep(m: &Mob) -> bool {
m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
/// is drawn).
fn follow(&mut self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
let (frame, dt) = (self.frame, self.dt);
let (f, mark) = self
.companions
.entry(key)
.or_insert((Follow::at(target, yaw), frame));
if *mark != frame {
f.step(target, yaw, lag, dt);
*mark = frame;
}
*f
}
}
/// Where a companion floats: `at` (relative to the camera) facing `yaw`, at the wearer's scale,
@@ -52,269 +83,144 @@ fn companion_matrix(at: Vec3, yaw: f32, scale: f32) -> Mat4 {
* Mat4::from_scale(Vec3::new(-scale, -scale, scale))
}
impl Game<'_> {
/// A companion, trail or aura `at` wears: a companion goes where the wearer's other items go
/// (`gpu` and `out`, `bounds` grown), trails and auras to the frame's see-through vertices.
/// Other players' trails and auras stay hidden with "Hide other players' trails and auras".
pub(super) fn cosmetics_fx(
&self,
at: &Wearer,
item: &Arc<Item>,
gpu: bool,
bounds: Option<(Vec3, Vec3)>,
poses: &mut Poses,
out: &mut Vec<Vertex>,
) -> Option<(Vec3, Vec3)> {
if !at.own && item.slot.is_effect() && self.settings.extras.cosmetics_hide_fx {
return bounds;
/// Full light for what glows, else `otherwise`.
fn glowing(glow: bool, otherwise: Light) -> Light {
if glow { Light::Full } else { otherwise }
}
impl Seat {
/// A companion, trail or aura `m` wears, around its model as `draw` draws it. Other players'
/// 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.settings.hide_fx {
return;
}
// Past `FAR` a companion, trail or aura is a few pixels across for the cost of a model:
// not drawn, and a trail that isn't drawn doesn't spawn (its particles go back to the
// budget).
if at.base.w_axis.truncate().length_squared() > FAR * FAR {
return bounds;
let base = Mat4::from_cols_array_2d(&draw.base());
if base.w_axis.truncate().length_squared() > FAR * FAR {
return;
}
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
return;
};
let key = if own { OWN } else { m.id };
match item.slot {
Slot::Companion => {
let Some((model, uv, matrix)) = self.companion_at(at, item, None) else {
return bounds;
};
self.pose_companion(item, at.m, at.alpha, poses, &model);
let glow = item.companion.as_ref().is_some_and(|c| c.glow);
let light = if glow { FULL_LIGHT } else { at.light };
self.cosmetics_model(&model, matrix, poses, uv, light, gpu, bounds, out)
}
Slot::Trail => {
self.trail(at, item);
bounds
}
Slot::Aura => {
self.aura(at, item, poses);
bounds
}
_ => bounds,
Slot::Companion => self.companion(key, item, uv, base, draw),
Slot::Trail => self.trail(key, item, uv, draw),
Slot::Aura => self.aura(item, uv, base, draw),
_ => {}
}
}
/// The companion's model, texture and matrix this frame, its follow moved on once a frame.
/// `first_person`: the camera's forward and left, for the player's own in first person: the
/// companion keeps out of the middle of the view.
fn companion_at(
&self,
at: &Wearer,
/// The companion where it floats, its follow moved on once a frame. In first person it keeps
/// out of the middle of the view, lit where it floats.
fn companion(
&mut self,
key: u32,
item: &Arc<Item>,
first_person: Option<(Vec3, Vec3)>,
) -> Option<(Arc<Part>, [f32; 4], Mat4)> {
let c = item.companion.as_ref()?;
let (models, uv) = self.cosmetics_baked(item)?;
let model = models.first()?.1.clone();
let scale = at.base.x_axis.truncate().length();
let target = at.base.transform_point3(Vec3::from(c.anchor) / 16.0);
let head = at
.base
.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
let yaw = head_yaw(at.m, at.alpha);
let follow = self.companion_follow(at.key, at.camera + target.as_dvec3(), yaw, c.lag);
uv: [f32; 4],
base: Mat4,
draw: &mut dyn PlayerDraw,
) {
let Some(c) = item.companion.as_ref() else {
return;
};
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
return;
};
let first_person = draw.pass() == Pass::FirstPerson;
let (motion, view) = (draw.motion(), draw.view());
let camera = DVec3::from(view.camera);
let scale = base.x_axis.truncate().length();
let target = base.transform_point3(Vec3::from(c.anchor) / 16.0);
let head = base.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
let follow = self
.fx
.follow(key, camera + target.as_dvec3(), motion.head_yaw, c.lag);
let clearance = (horizon_cosmetics::HEAD_CLEARANCE + c.reach) / 16.0 * scale;
let mut rel = (follow.pos - at.camera).as_vec3();
if let Some((forward, left)) = first_person {
rel = follow::out_of_view(rel, forward, left);
let mut rel = (follow.pos - camera).as_vec3();
if first_person {
rel = follow::out_of_view(rel, Vec3::from(view.forward), Vec3::from(view.left));
}
rel = follow::clear_of(rel, head, clearance);
Some((model, uv, companion_matrix(rel, follow.yaw, scale)))
}
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
/// is drawn).
fn companion_follow(&self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
let frame = self.cosmetics.frame.get();
let mut fx = self.cosmetics.fx.borrow_mut();
let dt = fx.dt;
let (f, mark) = fx
.companions
.entry(key)
.or_insert((Follow::at(target, yaw), frame));
if *mark != frame {
f.step(target, yaw, lag, dt);
*mark = frame;
}
*f
}
/// The companion's animations, and its `look` part turned with the wearer's pitch.
fn pose_companion(&self, item: &Item, m: &Mob, alpha: f32, poses: &mut Poses, model: &Part) {
poses.reset(model);
item.animate(m.age as f32 + alpha, |name, pose| *poses.get(name) = pose);
if let Some(look) = item.companion.as_ref().and_then(|c| c.look.as_deref()) {
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
poses.get(look)[3] += (pitch * 0.6).clamp(-30.0, 30.0).to_radians();
}
let matrix = companion_matrix(rel, follow.yaw, scale).to_cols_array_2d();
let near = if first_person {
Light::AtPlace
} else {
Light::Model
};
let look = Look::new(uv).lit(glowing(c.glow, near));
let ticks = draw.ticks();
// Its animations, and its `look` part turned with the wearer's pitch.
let pitch = (motion.head_pitch * 0.6).clamp(-30.0, 30.0).to_radians();
draw.draw(&mesh, matrix, look, &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose);
if let Some(part) = c.look.as_deref() {
p.pose(part)[3] += pitch;
}
});
}
/// The trail's particles: spawned once a frame by the distance walked (not while sneaking or
/// asleep), facing the camera.
fn trail(&self, at: &Wearer, item: &Arc<Item>) {
fn trail(&mut self, key: u32, item: &Arc<Item>, uv: [f32; 4], draw: &mut dyn PlayerDraw) {
let Some(t) = &item.trail else {
return;
};
let Some((_, uv)) = self.cosmetics_baked(item) else {
return;
};
let m = at.m;
let a = f64::from(at.alpha);
let feet = DVec3::new(
m.previous.x + (m.position.x - m.previous.x) * a,
m.previous.y + (m.position.y - m.previous.y) * a,
m.previous.z + (m.position.z - m.previous.z) * a,
);
let moves = !m.extra.crouching && !asleep(m);
let q = self.camera_orientation();
let (right, up) = (q * Vec3::X, q * Vec3::Y);
let light = if t.glow { FULL_LIGHT } else { at.light };
let frame = self.cosmetics.frame.get();
let mut fx = self.cosmetics.fx.borrow_mut();
let super::Fx {
let (motion, view) = (draw.motion(), draw.view());
let moves = !motion.crouching && !motion.asleep;
let Fx {
trails,
budget,
now,
clear,
frame,
particles,
..
} = &mut *fx;
} = &mut self.fx;
let (e, mark) = trails
.entry(at.key)
.entry(key)
.or_insert_with(|| (Emitter::default(), frame.wrapping_sub(1)));
if *mark != frame {
e.step(t, feet, moves, *now, budget);
*mark = frame;
}
for quad in e.quads(t, *now, at.camera, right, up) {
let color = ((quad.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
for (corner, [u, v]) in quad.corners.iter().zip(quad.uv) {
clear.push(Vertex {
pos: corner.to_array(),
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],
});
}
if *mark != *frame {
e.step(t, DVec3::from(motion.feet), moves, *now, budget);
*mark = *frame;
}
let (camera, right, up) = (
DVec3::from(view.camera),
Vec3::from(view.right),
Vec3::from(view.up),
);
particles.clear();
particles.extend(
e.quads(t, *now, camera, right, up)
.map(|q| Particle::new(q.corners.map(|c| c.to_array()), q.uv, q.alpha)),
);
draw.particles(uv, particles, glowing(t.glow, Light::Model));
}
/// The aura at the wearer's feet, see-through. None while they fly, swim or sleep: it would
/// tip over with the body.
fn aura(&self, at: &Wearer, item: &Arc<Item>, poses: &mut Poses) {
let (m, Some(a)) = (at.m, &item.aura) else {
fn aura(&mut self, item: &Arc<Item>, uv: [f32; 4], base: Mat4, draw: &mut dyn PlayerDraw) {
let Some(a) = &item.aura else {
return;
};
if m.extra.fall_flying || asleep(m) || m.extra.swim.amount(at.alpha) > 0.0 {
let motion = draw.motion();
if motion.flying || motion.asleep || motion.swimming {
return;
}
let Some((models, uv)) = self.cosmetics_baked(item) else {
return;
};
let Some((_, model)) = models.first() else {
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
return;
};
// The model's origin is the middle of the feet: 24 pixels under the neck.
let matrix = at.base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
poses.reset(model);
item.animate(m.age as f32 + at.alpha, |name, pose| {
*poses.get(name) = pose
let matrix = base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
let look = Look::new(uv)
.lit(glowing(a.glow, Light::Model))
.see_through(a.alpha);
let ticks = draw.ticks();
draw.draw(&mesh, matrix.to_cols_array_2d(), look, &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose)
});
let light = if a.glow { FULL_LIGHT } else { at.light };
let mut fx = self.cosmetics.fx.borrow_mut();
let start = fx.clear.len();
horizon_client::mobs::render_model(
model,
matrix,
&|p: &Part| poses.pose(p),
uv,
light,
0,
&mut fx.clear,
);
let color = ((a.alpha * 255.0) as u32) << 24 | 0x00FF_FFFF;
for v in &mut fx.clear[start..] {
v.color = color;
// Seen through, a face turned away would add to the near one (and a card's two
// faces would double it): `entity.wgsl` drops back faces, so it is `alpha` at most.
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
}
}
/// After every player: the player's own companion in first person (out of the middle of the
/// view, after the first-person model went to the shadow pass only), then the frame's trails
/// and auras in one see-through span.
pub(in crate::game) fn cosmetics_fx_geometry(
&self,
camera: DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
if !super::enabled() {
return;
}
if !self.own_body_in_view()
&& let Some(m) = self.player_model.as_ref()
{
self.mark_entity(out.len(), "minecraft:player");
self.own_companion_first_person(m, camera, alpha as f32, out);
}
let fx = self.cosmetics.fx.borrow();
if !fx.clear.is_empty() {
self.mark_translucent_entity(out.len(), "minecraft:player");
out.extend_from_slice(&fx.clear);
}
}
fn own_companion_first_person(
&self,
m: &Mob,
camera: DVec3,
alpha: f32,
out: &mut Vec<Vertex>,
) {
if horizon_client::mobs::trader::is_invisible(m) || self.own_invisible() {
return;
}
let Some(worn) = self.worn(m, true) else {
return;
};
let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) else {
return;
};
let at = Wearer {
m,
own: true,
key: OWN,
base: horizon_client::mobs::render::base_transform(m, alpha, camera),
camera,
alpha,
light: FULL_LIGHT,
};
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let forward = Vec3::new(
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
);
let left = Vec3::new(yaw.cos(), 0.0, yaw.sin());
let Some((model, uv, matrix)) = self.companion_at(&at, item, Some((forward, left))) else {
return;
};
// Lit where it floats.
let p = (camera + matrix.w_axis.truncate().as_dvec3()).floor();
let light = self.world.light(p.x as i32, p.y as i32, p.z as i32);
let mut light = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
if item.companion.as_ref().is_some_and(|c| c.glow) {
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);
}
}
@@ -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());
}
}
+184 -63
View File
@@ -11,17 +11,15 @@ 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, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
PlayerModels, SpriteStore, Sprites,
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)
{
if let Err(e) = super::cosmetics::register() {
log::error!("Cosmetics: their hooks came too late: {e}");
}
}
@@ -182,6 +180,14 @@ fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
})
}
/// 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| {
@@ -208,6 +214,7 @@ struct DrawPlayer<'a, 'g> {
pass: Pass,
/// The model's matrix (`render::base_transform`).
base: glam::Mat4,
camera: glam::DVec3,
alpha: f32,
light: u32,
gpu: bool,
@@ -223,6 +230,19 @@ impl DrawPlayer<'_, '_> {
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<'_, '_> {
@@ -234,7 +254,52 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
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());
@@ -260,13 +325,30 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 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();
@@ -276,30 +358,43 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light: self.light,
light,
overlay: 0,
};
if skin.draw(&model, matrix, &self.poses, paint) {
if skin.draw(&model, matrix, poses, paint) {
self.bounds = skin.bounds;
return;
}
}
let poses = &self.poses;
horizon_client::mobs::render_model(
&model,
matrix,
&|p: &Part| poses.pose(p),
uv,
self.light,
0,
self.out,
);
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.
/// What crates draw on player model `m` in the world pass (`creatures.rs`): see
/// [`DrawPlayer`]. The bounds of what went to the GPU.
#[allow(clippy::too_many_arguments)]
pub(super) fn player_layers_geometry(
&self,
@@ -315,39 +410,29 @@ impl Game<'_> {
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,
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),
}
let kind = kind(m).filter(|_| 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,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
draw.bounds
}
/// 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.
/// third person (`Pass::World`), 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,
@@ -361,31 +446,66 @@ impl Game<'_> {
self.serving.is_none(),
"a server thread calls no client hook"
);
let fx = pass == Pass::World && super::cosmetics::enabled();
if !engine::any() && !fx {
if !engine::any() {
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 model = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
let mut draw = DrawPlayer {
game: self,
m,
pass,
base: render::base_transform(m, alpha, camera),
camera,
alpha,
light,
gpu: false,
out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
}
/// After every player (`game.rs`): what crates draw around the window's own model in first
/// person, 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,
base,
alpha,
light,
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::player_layers(&model, &mut draw);
}
if fx {
self.cosmetics_fx_of(m, true, base, camera, alpha, light, false, out);
engine::first_person(&own, &mut draw);
}
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.
@@ -402,6 +522,7 @@ impl Game<'_> {
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();
}
@@ -113,6 +113,117 @@ fn fire_from_damage_lights_or_burns_the_stand_though_the_command_fails() {
assert!(health.ends_with(": 16.0f"), "{health}");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn igniting_a_frozen_stand_clears_its_freeze_while_burning_keeps_it() {
let mut game = game();
summon(&mut game, ",TicksFrozen:140");
let (_, frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
assert!(frozen.ends_with(": 140"), "{frozen}");
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 100s"), "{fire}");
let (ok, frozen_after) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
assert!(!ok, "{frozen_after}");
// When already burning, in_fire calls causeDamage(0.15) instead of igniteForSeconds.
assert!(game.run_command(&format!("data merge entity {STAND} {{TicksFrozen:140}}")));
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
let (ok, still_frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
assert!(ok && still_frozen.ends_with(": 140"), "{still_frozen}");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn damage_immunity_on_worn_boots_refuses_campfire_but_not_in_fire_or_wrong_slot() {
let mut game = game();
summon(
&mut game,
r#",equipment:{feet:{id:"minecraft:diamond_boots",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
);
// campfire is in both #ignites_armor_stands and #burn_from_stepping.
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 0s"), "{fire}");
// in_fire is not in #burn_from_stepping, so it still ignites the stand.
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 100s"), "{fire}");
// Frost Walker on a helmet (head) is not in an active slot for the enchantment.
let mut helmeted = self::game();
summon(
&mut helmeted,
r#",equipment:{head:{id:"minecraft:diamond_helmet",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
);
assert!(!helmeted.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
let (_, fire) = say(&mut helmeted, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 100s"), "{fire}");
}
fn capture() {
super::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
}
fn take_events() -> Vec<(&'static str, [f64; 3], Option<horizon_client::mobs::Who>)> {
super::sculk::TEST_POSTED_EVENTS.with(|events| events.borrow_mut().take().unwrap_or_default())
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn cause_damage_and_explosion_post_attributed_game_events() {
use horizon_client::mobs::Who;
let mut game = game();
summon(&mut game, ",Fire:200s");
assert!(game.run_command("summon pig 14.5 100 2.5 {NoAI:1b}"));
let pig_id = game.mobs[0].id;
let at = [2.5, 100.0, 8.5];
// Burning stand hurt by in_fire (0.15) posts entity_damage attributed to the pig.
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:in_fire by @e[type=pig,limit=1]"
)));
assert_eq!(
take_events(),
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
);
// Burning stand hurt by on_fire (4.0) while surviving posts entity_damage.
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
)));
assert_eq!(
take_events(),
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
);
// Burning stand broken by on_fire (health <= 0.5) posts entity_die attributed to the pig.
assert!(game.run_command(&format!("data merge entity {STAND} {{Health:4f}}")));
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
)));
assert!(!game.run_command(&format!("execute if entity {STAND}")));
assert_eq!(
take_events(),
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
);
// Stand broken by explosion posts entity_die attributed to the pig.
summon(&mut game, "");
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:explosion by @e[type=pig,limit=1]"
)));
assert!(!game.run_command(&format!("execute if entity {STAND}")));
assert_eq!(
take_events(),
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
);
}
// Controls added after the baseline: the guards before the acting sources,
// and a stand broken once however often it is hurt.
+149 -3
View File
@@ -8,11 +8,14 @@
//!
//! A Horizon crate (docs/layers.md): the game tells it what the player does through the
//! platform's hooks ([`HOOKS`]: `client_started`, `activity`, `shutdown`), which the official
//! executable (`horizon-official`) registers. horizon-client's own executable has no presence.
//! executable (`horizon-official`) registers with the switch: its line of `horizon-extras.txt`
//! ([`SETTING`]) and its row in Extras' Multiplayer group ([`SECTION`]). horizon-client's own
//! executable has no presence.
#![forbid(unsafe_code)]
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Condvar, Mutex, OnceLock};
use std::thread::Thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -20,6 +23,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use horizon_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
use horizon_platform::pages::{Act, Label, Page, Place, Row};
use horizon_platform::settings::Store;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
@@ -67,7 +72,7 @@ impl Hooks for Presence {
dimension,
mode,
},
// Hidden, and anything a later game says that this crate doesn't know yet.
// Anything a later game says that this crate doesn't know yet.
_ => Status::Off,
});
}
@@ -78,6 +83,109 @@ impl Hooks for Presence {
}
}
/// The switch's line in `horizon-extras.txt`, where the engine's Extras kept it before.
const KEY: &str = "discordPresence";
/// The switch's row, and what it says on hover.
const LABEL: &str = "horizon.extras.discord_presence";
const ABOUT: &str = "horizon.extras.discord_presence.tooltip";
/// The switch (on by default). One presence per computer: the first splitscreen player's
/// switch, in their settings, on their Extras page; the other players have none.
static ON: AtomicBool = AtomicBool::new(true);
/// Whether the player whose turn it is owns the switch: the first one.
fn first_player() -> bool {
hooks::seat() == 0
}
/// Turns the presence on or off; off clears the activity at once.
pub fn set_enabled(on: bool) {
ON.store(on, Ordering::Relaxed);
let mut shared = lock();
let status = shown(shared.game);
let changed = shared.wanted.update(status, unix_now());
drop(shared);
if let Some(thread) = THREAD.get().filter(|_| changed) {
thread.unpark();
}
}
pub fn enabled() -> bool {
ON.load(Ordering::Relaxed)
}
/// What Discord is told for the game's `status`: nothing with the switch off.
fn shown(status: Status) -> Status {
if enabled() { status } else { Status::Off }
}
/// The switch's line of `horizon-extras.txt`, for `horizon_platform::settings::register`.
pub static SETTING: Setting = Setting;
pub struct Setting;
impl Store for Setting {
fn file(&self) -> &'static str {
"horizon-extras.txt"
}
fn owns(&self, key: &str) -> bool {
key == KEY
}
fn load(&self, _key: &str, value: &str) {
if !first_player() {
return;
}
match value {
"true" => set_enabled(true),
"false" => set_enabled(false),
_ => {}
}
}
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
if first_player() {
line(KEY, if enabled() { "true" } else { "false" });
}
}
fn is_default(&self) -> bool {
enabled() || !first_player()
}
}
/// The switch's row in the Extras page's Multiplayer group, for
/// `horizon_platform::pages::register`.
pub static SECTION: Section = Section;
pub struct Section;
impl Page for Section {
fn id(&self) -> &'static str {
"horizon-discord.extras"
}
fn title(&self) -> &'static str {
"horizon.extras.header.multiplayer"
}
fn place(&self) -> Place {
Place::Extras
}
fn rows(&self, rows: &mut Vec<Row>) {
if !first_player() {
return;
}
rows.push(Row::Toggle {
label: LABEL,
on: enabled(),
});
}
fn act(&self, row: usize, act: Act) {
if row == 0 && first_player() && matches!(act, Act::Press | Act::PressBack) {
set_enabled(!enabled());
}
}
fn tooltip(&self, _row: usize) -> Option<Label> {
Some(Label::Key(ABOUT))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
/// The player turned Rich Presence off: the activity is cleared.
@@ -115,11 +223,15 @@ impl Wanted {
}
struct Shared {
/// What the game said last.
game: Status,
/// What Discord is to show: the game's, or nothing with the switch off.
wanted: Wanted,
stop: bool,
stopped: bool,
}
static SHARED: Mutex<Shared> = Mutex::new(Shared {
game: Status::Menus,
wanted: Wanted {
status: Status::Menus,
since: None,
@@ -154,7 +266,10 @@ pub fn start(automated: bool) {
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
pub fn set(status: Status) {
let changed = lock().wanted.update(status, unix_now());
let mut shared = lock();
shared.game = status;
let changed = shared.wanted.update(shown(status), unix_now());
drop(shared);
if let Some(thread) = THREAD.get().filter(|_| changed) {
thread.unpark();
}
@@ -596,6 +711,37 @@ mod tests {
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
#[cfg(unix)]
/// The switch as the player's settings and the Extras page see it. The only test that
/// touches the presence's statics.
#[test]
fn the_switch_clears_the_activity_at_once() {
let wanted = || lock().wanted.status;
Setting.load(KEY, "false");
set(Status::Menus);
assert!(!enabled());
assert_eq!(wanted(), Status::Off);
let mut rows = Vec::new();
SECTION.rows(&mut rows);
assert_eq!(
rows,
[Row::Toggle {
label: LABEL,
on: false
}]
);
let mut lines = Vec::new();
Setting.save(&mut |k, v| lines.push(format!("{k}:{v}")));
assert_eq!(lines, ["discordPresence:false"]);
assert!(!Setting.is_default());
// On from the page: what the game said last is shown at once.
SECTION.act(0, Act::Press);
assert_eq!(wanted(), Status::Menus);
assert!(Setting.is_default());
SECTION.act(0, Act::Press);
assert_eq!(wanted(), Status::Off);
SECTION.act(0, Act::Press);
}
#[test]
fn talks_to_discord_over_its_socket() {
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
+45 -15
View File
@@ -77,6 +77,10 @@ pub trait Hooks: Sync {
/// 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) {}
/// 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 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.
@@ -110,9 +114,6 @@ impl Client {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Activity {
/// The player keeps it to themselves: the Discord switch in Horizon Settings is off. (The
/// switch moves to the Discord crate's own settings with the settings store.)
Hidden,
/// On the title screen or another menu outside a world.
Menus,
World {
@@ -309,6 +310,13 @@ pub mod engine {
}
}
/// [`Hooks::first_person`].
pub fn first_person(own: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
for hooks in all() {
hooks.first_person(own, draw);
}
}
/// [`Hooks::camera`].
pub fn camera(own: &players::CameraView) -> Option<players::CameraOverride> {
all().iter().find_map(|h| h.camera(own))
@@ -380,7 +388,14 @@ mod tests {
return;
};
let turn = draw.ticks() / 100.0;
draw.draw(&HAT, head, uv, &mut |p| p.pose("brim")[4] = turn);
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();
@@ -457,9 +472,10 @@ mod tests {
))
});
/// A player drawn at age 50, its head where the camera is; logs each draw.
/// 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)>);
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
@@ -467,6 +483,15 @@ mod tests {
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])
}
@@ -480,7 +505,7 @@ mod tests {
&mut self,
mesh: &std::sync::Arc<players::Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 4],
look: players::Look,
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
) {
struct Rest([f32; 9]);
@@ -491,7 +516,10 @@ mod tests {
}
let mut brim = Rest(mesh.children[0].pose);
pose(&mut brim);
self.0.push((mesh.name.clone(), matrix, uv, brim.0[4]));
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));
}
}
@@ -510,12 +538,12 @@ mod tests {
for open in [false, true, true, true, false, false] {
engine::published(open);
}
for now in [
Activity::Menus,
Activity::Menus,
Activity::Hidden,
Activity::Hidden,
] {
let world = Activity::World {
play: Play::Singleplayer,
dimension: Dimension::Nether,
mode: GameMode::Creative,
};
for now in [Activity::Menus, Activity::Menus, world, world] {
engine::activity(now);
}
engine::shutdown();
@@ -548,6 +576,8 @@ mod tests {
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());
@@ -558,7 +588,7 @@ mod tests {
"published true",
"published false",
"Menus",
"Hidden",
"World { play: Singleplayer, dimension: Nether, mode: Creative }",
"frame 4"
]
);
+149 -4
View File
@@ -278,6 +278,145 @@ pub enum Pass {
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.
@@ -293,19 +432,25 @@ 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;
/// Where `bone` is as the body is drawn this frame; None if the model has no such part.
/// 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`, textured by the sprite part `uv` (u0, v0, u1, v1) and lit as the
/// model is; `pose` poses its parts first.
/// Draws `mesh` at `matrix` as `look` says; `pose` poses its parts first.
fn draw(
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 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);
}
+11 -4
View File
@@ -4,11 +4,12 @@
//! (`game-build.yml`); horizon-client keeps its own plain executable.
//!
//! Each Horizon crate that runs in the game gives a `&'static` value implementing
//! `horizon_platform::hooks::Hooks`, and [`register`] adds it. Nothing here depends on an engine
//! crate.
//! `horizon_platform::hooks::Hooks`, and its settings store and pages if it has any, and
//! [`register`] adds them. Nothing here depends on an engine crate.
#![forbid(unsafe_code)]
use horizon_platform::hooks::{self, Hooks};
use horizon_platform::{pages, settings};
/// Every Horizon part in the game, in the order its hooks run.
const PARTS: [&dyn Hooks; 4] = [
@@ -21,12 +22,18 @@ const PARTS: [&dyn Hooks; 4] = [
// "Playing Horizon" on the player's Discord profile.
&horizon_discord::HOOKS,
];
/// Their settings, in the player's settings files.
const STORES: [&dyn settings::Store; 1] = [&horizon_discord::SETTING];
/// Their rows and pages in the options screens.
const PAGES: [&dyn pages::Page; 1] = [&horizon_discord::SECTION];
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
/// game, and so does the launcher's generated executable for native mods.
pub fn register() {
for part in PARTS {
if let Err(e) = hooks::register(part) {
let late = PARTS.into_iter().map(hooks::register);
let late = late.chain(STORES.into_iter().map(settings::register));
for result in late.chain(PAGES.into_iter().map(pages::register)) {
if let Err(e) = result {
// The executable registered after starting the game: a bug, not a player's problem.
log::warn!("Horizon: an in-game part did not register: {e}");
}
+3 -3
View File
@@ -50,9 +50,9 @@ pub mod hooks {
/// [`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, Mark,
Mesh, NameDecoration, PartPoses, Pass, PlayerDraw, PlayerModel, PlayerModels, Quad,
SpriteStore, Sprites,
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::{
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, seat,
-9
View File
@@ -115,12 +115,3 @@ ssh desktop 'sudo -n rm -f /var/lib/gha-control/pause && sudo -n systemctl start
```
With the desktop paused, set `SELF_HOSTED_CI=light`, or `checks` waits for it.
## When our runners are offline
`.github/workflows/runner-watch.yml` runs every 30 minutes on GitHub's runners (never ours) and calls
`tools/ci/runner_watch.py`. When a job has waited over 2 hours for a self-hosted runner, in a pool where no job is
running at all (offline, not just busy), it opens one issue, "CI: self-hosted runners are not taking jobs", listing
each pool, what stops and what to do, and keeps its text current; it closes the issue once nothing waits. It needs
only the workflow token (`actions: read`, `issues: write`). What the Team and the council do meanwhile:
[operations.md](operations.md#self-hosted-runners-are-offline).
+2 -2
View File
@@ -23,8 +23,8 @@ What changes in that build:
|---|---|---|
| Cosmetics, emotes and the emote wheel, "Show me as a Horizon player" | `game/cosmetics.rs` (+ `cosmetics/fx.rs`, `cosmetics/emotes.rs`, `cosmetics/emote_wheel.rs`), crate `horizon-cosmetics` | `game::cosmetics::enabled()`: the service is never asked, nothing is drawn, B opens no wheel |
| The official server added to the server list, the withdrawn partner server taken out | crate `horizon-official-servers` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence | crate `horizon-discord` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence and its switch | crate `horizon-discord` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
+8 -6
View File
@@ -423,16 +423,18 @@ While Discord runs on the same computer, the player's Discord profile shows
"Playing Horizon" and what they are doing: "In the menus", "Singleplayer",
"Multiplayer" or "Hosting a world" (with the party size, of 8), the dimension
("In the Nether") and the time since they entered the world. It never shows a
world's name or a server's address. Horizon Settings has the switch
(**Discord Rich Presence**, on by default; `discordPresence` in
`horizon-extras.txt`), and `HORIZON_NO_DISCORD=1` turns it off for one run.
world's name or a server's address. The switch is on the Extras page, in its
Multiplayer group (**Discord Rich Presence**, on by default; `discordPresence`
in `horizon-extras.txt`; in splitscreen only the first player has it), and
`HORIZON_NO_DISCORD=1` turns it off for one run.
The crate `horizon-discord` (Horizon layer) speaks Discord's local IPC itself
(the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its own.
The game hands it a status only when it changes, through the platform's
`activity` hook (`game/activity.rs` says what the player does). Only the
official executable (`horizon-official`) registers it: horizon-client's own
executable shows no presence. It retries every 15 s
`activity` hook (`game/activity.rs` says what the player does), and keeps the
switch itself: its settings store and its Extras row (the platform's settings
and pages registries). Only the official executable (`horizon-official`)
registers it: horizon-client's own executable shows no presence and no switch. It retries every 15 s
while Discord isn't running and stays within Discord's 5 updates per 20 s.
Dedicated servers never start it, and automated runs (offscreen, benchmarks)
only talk to a fake Discord: `lab.game_env` sets `HORIZON_NO_DISCORD=1`, and
-54
View File
@@ -1,54 +0,0 @@
import datetime as dt
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "ci"))
import runner_watch as rw # noqa: E402
NOW = dt.datetime(2026, 11, 10, 12, 0, tzinfo=dt.timezone.utc)
LIGHT = ["self-hosted", "linux", "horizon-light"]
GAME = ["self-hosted", "linux", "horizon-game"]
MAC_NIGHTLY = ["self-hosted", "macOS", "horizon-nightly"]
def job(labels, hours_ago, status="queued", name="j"):
created = (NOW - dt.timedelta(hours=hours_ago)).strftime("%Y-%m-%dT%H:%M:%SZ")
return {"name": name, "labels": labels, "status": status, "created_at": created, "html_url": f"https://x/{name}"}
class WaitingJobs(unittest.TestCase):
def test_a_job_waiting_past_the_limit_in_an_idle_pool_counts(self):
waiting = rw.waiting_jobs([job(LIGHT, 3, name="triage")], NOW, 2)
self.assertEqual([j["name"] for j in waiting], ["triage"])
self.assertAlmostEqual(waiting[0]["waited_h"], 3)
def test_recent_jobs_github_hosted_jobs_and_finished_jobs_do_not(self):
jobs = [job(LIGHT, 1), job(["ubuntu-latest"], 9), job(GAME, 9, status="completed")]
self.assertEqual(rw.waiting_jobs(jobs, NOW, 2), [])
def test_a_busy_pool_is_not_offline_but_another_idle_pool_still_is(self):
jobs = [job(GAME, 7, name="parity"), job(GAME, 0.5, status="in_progress"), job(MAC_NIGHTLY, 5, name="mac")]
self.assertEqual([j["name"] for j in rw.waiting_jobs(jobs, NOW, 2)], ["mac"])
class Plan(unittest.TestCase):
def test_opens_once_then_updates_then_closes(self):
waiting = rw.waiting_jobs([job(LIGHT, 3), job(LIGHT, 5, name="claim"), job(MAC_NIGHTLY, 4)], NOW, 2)
action, text = rw.plan(waiting, None, NOW)
self.assertEqual(action, "open")
self.assertIn("| `horizon-light` | 2 | 5 h | [claim](https://x/claim) |", text)
self.assertIn("SELF_HOSTED_CI", text) # the fix for the light pool is named
self.assertIn("no Nightly is published", text) # and what the Mac pool stops
self.assertNotIn("parity job waits", text) # pools with nothing waiting are left out
self.assertEqual(rw.plan(waiting, 42, NOW)[0], "update")
self.assertEqual(rw.plan([], 42, NOW)[0], "close")
self.assertEqual(rw.plan([], None, NOW), (None, ""))
def test_an_unknown_label_set_is_still_reported_by_its_labels(self):
waiting = rw.waiting_jobs([job(["self-hosted", "gpu-box"], 3)], NOW, 2)
self.assertIn("| `gpu-box` | 1 |", rw.plan(waiting, None, NOW)[1])
if __name__ == "__main__":
unittest.main()
-154
View File
@@ -1,154 +0,0 @@
#!/usr/bin/env python3
"""Every 30 minutes: are jobs waiting for a self-hosted runner nobody runs? (.github/workflows/runner-watch.yml)
python3 tools/ci/runner_watch.py [--dry-run] [--hours 2]
Several jobs run only on the maintainer's machines (docs/ci-runners.md): the vanilla harnesses need his own copy
of Minecraft, the macOS builds his Mac. When those machines are off, GitHub keeps such a job queued for up to a day
and then fails it, and nothing says why: triage commands go unanswered, Nightlies stop, a pull request's parity
check stays yellow. This script lists the jobs of this repository's queued and running workflow runs that wait for
a self-hosted runner, and when any has waited longer than --hours in a pool where no job runs at all (so the
runners are offline, not just busy) it opens one issue (or updates its text) saying
which runners are missing, what stops because of it and what to do (docs/operations.md). When nothing waits any
more, it comments and closes that issue. Needs only the workflow token's `actions: read` and `issues: write`, so it
runs on GitHub's own runners even when every self-hosted one is gone.
"""
import argparse
import datetime as dt
import json
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
TITLE = "CI: self-hosted runners are not taking jobs"
GUIDE = "https://github.com/horizon-rust/horizon/blob/main/docs/operations.md#self-hosted-runners-are-offline"
# What stops while no runner with this label takes jobs, and what the Team does meanwhile. A job asks for a set of
# labels; the first of these it asks for names it.
EFFECTS = [
("horizon-light", "Small jobs (triage and claim commands, pull request labels, overlap notes, the Mojang "
"republish check, a pull request's parity scope) wait instead of running.",
"An organisation owner sets the repository variable `SELF_HOSTED_CI` to `off`: they run on GitHub's runners."),
("horizon-checks", "`verify` and `launcher` of the pull requests whose number ends in 0 or 1 wait, so those pull "
"requests can't merge.",
"An organisation owner sets `SELF_HOSTED_CI` to `off` (or `light`), then the authors push again or a Team "
"member re-runs the checks."),
("horizon-parity-main", "parity-main doesn't run, so no newer commit of main is green for the Nightly: Nightlies "
"keep shipping the last green commit or nothing.",
"Nothing to do on GitHub: the harnesses need the maintainer's Minecraft. Merge on `verify` as usual; label "
"risky pull requests `needs-proof`."),
("horizon-nightly", "The Nightly's macOS builds wait, so no Nightly is published.",
"Players keep the last Nightly. Nothing to do on GitHub until a Mac runner is back."),
("horizon-release", "The launcher's macOS builds wait, so no launcher release is published.",
"Players keep their launcher. Nothing to do on GitHub until a Mac runner is back."),
("horizon-game", "A pull request's own parity job waits (it isn't required: merging still works).",
"Merge on `verify` after reading the diff; label pull requests that touch gameplay `needs-proof`."),
]
def parse_time(text):
return dt.datetime.fromisoformat(text.replace("Z", "+00:00"))
def waiting_jobs(jobs, now, hours):
"""The jobs queued for a self-hosted runner for at least `hours`, in pools where no job runs at all (a busy
pool works through its queue; an idle one with jobs waiting is offline): [{name, labels, waited_h, url}]."""
hosted = [job for job in jobs if "self-hosted" in (job.get("labels") or [])]
running = {pool(job["labels"]) for job in hosted if job.get("status") == "in_progress"}
out = []
for job in hosted:
labels = job["labels"]
if job.get("status") != "queued" or pool(labels) in running or not job.get("created_at"):
continue
waited = (now - parse_time(job["created_at"])).total_seconds() / 3600
if waited >= hours:
out.append({"name": job.get("name", "?"), "labels": labels, "waited_h": waited,
"url": job.get("html_url", "")})
return out
def pool(labels):
"""The runner pool a job waits for: the first EFFECTS label it asks for, else its labels joined."""
return next((label for label, _, _ in EFFECTS if label in labels),
", ".join(label for label in labels if label != "self-hosted"))
def body(waiting, now):
pools = {}
for job in waiting:
pools.setdefault(pool(job["labels"]), []).append(job)
lines = [f"🤖 Agent: written by `tools/ci/runner_watch.py` (runner-watch workflow), updated every 30 minutes; "
f"last {now:%Y-%m-%d %H:%M} UTC.", "",
"Jobs are waiting for self-hosted runners and none of those runners is running anything: they are "
"offline (the maintainer's machines are probably off). GitHub fails such a job after a day.", "",
"| Runner label | Jobs waiting | Longest wait | Example |", "| --- | --- | --- | --- |"]
for name, jobs in sorted(pools.items()):
oldest = max(jobs, key=lambda job: job["waited_h"])
lines.append(f"| `{name}` | {len(jobs)} | {oldest['waited_h']:.0f} h | [{oldest['name']}]({oldest['url']}) |")
lines += ["", "**What stops, and what to do:**", ""]
for label, effect, action in EFFECTS:
if label in pools:
lines.append(f"- `{label}`: {effect} {action}")
lines += ["", f"The whole procedure: [docs/operations.md]({GUIDE}). This issue closes itself when no job "
"waits any more."]
return "\n".join(lines) + "\n"
def plan(waiting, issue, now):
"""(action, text): open, update or close the issue, or nothing. issue: the open issue's number or None."""
if waiting:
return ("update" if issue else "open"), body(waiting, now)
if issue:
return "close", "🤖 Agent: no job waits for a self-hosted runner any more: closing."
return None, ""
def gh(*arguments):
return subprocess.run(["gh", *arguments], cwd=ROOT, check=True, capture_output=True, text=True).stdout
def all_jobs(repo, since):
jobs = []
for status in ("queued", "in_progress"):
runs = json.loads(gh("api", f"repos/{repo}/actions/runs?status={status}&per_page=100&created=%3E%3D{since}",
"--jq", "[.workflow_runs[].id]") or "[]")
for run in runs:
jobs += json.loads(gh("api", f"repos/{repo}/actions/runs/{run}/jobs?per_page=100", "--jq", ".jobs") or "[]")
return jobs
def open_issue(repo):
listed = json.loads(gh("issue", "list", "--repo", repo, "--state", "open", "--search", f'"{TITLE}" in:title',
"--json", "number,title") or "[]")
return next((issue["number"] for issue in listed if issue["title"] == TITLE), None)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--repo", default="horizon-rust/horizon")
parser.add_argument("--hours", type=float, default=2.0, help="how long a job may wait before it counts")
parser.add_argument("--dry-run", action="store_true", help="print what it would do, change nothing")
args = parser.parse_args()
now = dt.datetime.now(dt.timezone.utc)
since = (now - dt.timedelta(days=2)).strftime("%Y-%m-%d")
waiting = waiting_jobs(all_jobs(args.repo, since), now, args.hours)
issue = open_issue(args.repo)
action, text = plan(waiting, issue, now)
print(f"{len(waiting)} job(s) waiting over {args.hours:g} h for a self-hosted runner; issue: {issue}; "
f"action: {action}")
if args.dry_run or action is None:
print(text)
return 0
if action == "open":
gh("issue", "create", "--repo", args.repo, "--title", TITLE, "--body", text)
elif action == "update":
gh("issue", "edit", str(issue), "--repo", args.repo, "--body", text)
else:
gh("issue", "close", str(issue), "--repo", args.repo, "--comment", text)
return 0
if __name__ == "__main__":
sys.exit(main())
+17 -4
View File
@@ -9,7 +9,7 @@ The game goes title -> world -> Nether -> quit through horizon-ctl, and the acti
single single player on its server thread
lan a host opens its world to LAN and a guest joins: the guest shows "Multiplayer",
the host "Hosting a world" with a party of 2 of 8
setting off in horizon-extras.txt: nothing reaches Discord; the Horizon Settings switch
setting off in horizon-extras.txt: nothing reaches Discord; the Extras switch
turns it on (the activity appears) and off (it is cleared)
late Discord starts after the game: found within the 15 s retry
@@ -207,8 +207,21 @@ def lan(bins):
g.close()
def extras_row(game, label):
"""The Extras page's row whose label starts with `label`, scrolling the list down to it: only
the rows on screen are widgets."""
first = game.ctl_run('ui')['widgets'][0]['bounds']
for _ in range(20):
found = [w for w in game.ctl_run('ui')['widgets'] if (w.get('label') or '').startswith(label)]
if found:
return found[0]
game.ctl_run('scroll-at', str(first[0] + 5), str(first[1] + 5), '-5')
time.sleep(0.3)
raise AssertionError(f'no {label!r} row on the Extras page')
def setting(bins):
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Horizon Settings: the
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Extras: the
activity appears; off again: it is cleared."""
game = Game(bins, 'setting', extras='discordPresence:false\n')
d = game.discord
@@ -216,8 +229,8 @@ def setting(bins):
game.ctl_run('wait-for', 'screen', 'title')
time.sleep(3)
assert not d.handshakes and not d.activities, (d.handshakes, d.activities)
game.ctl_run('options', 'horizon')
switch = next(w for w in game.ctl_run('ui')['widgets'] if w.get('label', '').startswith('Discord Rich'))
game.ctl_run('options', 'extras')
switch = extras_row(game, 'Discord Rich')
assert switch['label'] == 'Discord Rich Presence: Off', switch
game.ctl_run('click', switch['id'])
d.wait('turned on', shows('In the menus'))
-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",