From 39a8a268ed90c56b1ec0a6c7ded2effa0579b77a Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:09:38 -0400 Subject: [PATCH 1/6] feat(runtime): hide the attack cooldown indicator in full-screen windows --- README.md | 6 ++-- .../minestom/internal/MinestomFullscreen.kt | 33 +++++++++++++++---- .../window/minestom/MinestomFullscreenTest.kt | 22 +++++++++---- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bd0a95a5..e4e73861 100644 --- a/README.md +++ b/README.md @@ -246,9 +246,9 @@ MyLobby(player).open() While the window is open, the Minestom host: - keeps the player in spectator mode on the server and tells only their client it is in survival, so clicks, the scroll - wheel, and number keys reach the server. Game mode, ability, hotbar, time, and max health updates the server sends - meanwhile still reach the client, followed by packets that hide them again. Closing restores the player's game mode - (or the one the server set meanwhile), abilities, position, and held slot. + wheel, and number keys reach the server. Game mode, ability, hotbar, time, max health, and attack speed updates the + server sends meanwhile still reach the client, followed by packets that hide them again. Closing restores the + player's game mode (or the one the server set meanwhile), abilities, position, and held slot. - hides the HUD by holding the client's world age negative, which the pack's shaders read. The hidden strip at the bottom of the screen can clip the player's own inventory (E) at small GUI heights, since the server is not told it opened. While the keyboard prompt is open, the age sits in a second negative band in which the shaders also hide the diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index 8aa1cfea..8ec53a6f 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -75,7 +75,8 @@ import net.minestom.server.world.clock.WorldClock * * The HUD is hidden: the hotbar holds blank items, which also hide the hand, and the client's world age is held * negative, which the pack's GUI shader reads as "clip the bottom-centre HUD strip". So the hearts fit that strip, the - * client is told its max health is at most the canvas's `hudMaxHealth` and its absorption is 0. While the server has a + * client is told its max health is at most the canvas's `hudMaxHealth` and its absorption is 0. The client is also told + * its attack speed is 1024, so the cooldown indicator never draws. While the server has a * container open, the client gets the real world age back and its real hotbar so the strip and slots show. The text * prompt's dialog is a client screen rather than a server container, so it keeps the HUD hidden and the hotbar blank. * The keyboard prompt's anvil is a server container, but not one the player sees: while it is open the client gets the @@ -97,7 +98,8 @@ import net.minestom.server.world.clock.WorldClock * not teleported back. A window opened by a spawn listener is in the spawn's instance and stays open. * * In horse mode, [MinestomFullscreenHorse] also locks the view. The client then draws no hearts, since its camera is - * not a player, so their max health and absorption are left alone. + * not a player, so their max health and absorption are left alone. The crosshair's cooldown indicator still draws, so + * attack speed is hidden in both modes. * * A prompt's callback runs on the player's thread after the state it reports has changed, never under [lock]: a server * container opening over the dialog or replacing the anvil cancels it on the next tick, and a window ending cancels it @@ -300,6 +302,7 @@ internal class MinestomFullscreen private constructor( private fun hideHud() { if (!containerOpen) hideTime(realTime().clocks()) capMaxHealth() + hideAttackSpeed() if (horse == null) player.sendPacket(EntityMetaDataPacket(player.entityId, mapOf(ABSORPTION to ZERO))) } @@ -329,6 +332,10 @@ internal class MinestomFullscreen private constructor( return EntityAttributesPacket.Property(Attribute.MAX_HEALTH, cap, emptyList()) } + private fun hideAttackSpeed() { + player.sendPacket(EntityAttributesPacket(player.entityId, listOf(HIDDEN_ATTACK_SPEED))) + } + private fun listen() { node.addListener(PlayerPacketEvent::class.java, ::onPacket) node.addListener(PlayerPacketOutEvent::class.java, ::onPacketOut) @@ -417,9 +424,19 @@ internal class MinestomFullscreen private constructor( } is EntityAttributesPacket -> { - val capped = cappedMaxHealth() ?: return - val uncapped = packet.properties().any { it.attribute() == Attribute.MAX_HEALTH && it != capped } - if (packet.entityId() == player.entityId && uncapped) capMaxHealth() + if (packet.entityId() != player.entityId) return + val capped = cappedMaxHealth() + if (capped != null && + packet.properties().any { it.attribute() == Attribute.MAX_HEALTH && it != capped } + ) { + capMaxHealth() + } + if (packet.properties().any { + it.attribute() == Attribute.ATTACK_SPEED && it != HIDDEN_ATTACK_SPEED + } + ) { + hideAttackSpeed() + } } is EntityMetaDataPacket -> { @@ -598,7 +615,10 @@ internal class MinestomFullscreen private constructor( player.sendPacket(realTime()) val maxHealth = player.getAttribute(Attribute.MAX_HEALTH) val property = EntityAttributesPacket.Property(Attribute.MAX_HEALTH, maxHealth.baseValue, maxHealth.modifiers()) - player.sendPacket(EntityAttributesPacket(player.entityId, listOf(property))) + val attackSpeed = player.getAttribute(Attribute.ATTACK_SPEED) + val attackSpeedProperty = + EntityAttributesPacket.Property(Attribute.ATTACK_SPEED, attackSpeed.baseValue, attackSpeed.modifiers()) + player.sendPacket(EntityAttributesPacket(player.entityId, listOf(property, attackSpeedProperty))) player.sendPacket(player.metadataPacket) player.sendPacket(UpdateHealthPacket(player.health, player.food, player.foodSaturation)) player.setHeldItemSlot(heldSlot) @@ -606,6 +626,7 @@ internal class MinestomFullscreen private constructor( } companion object { + private val HIDDEN_ATTACK_SPEED = EntityAttributesPacket.Property(Attribute.ATTACK_SPEED, 1024.0, emptyList()) private val ACTIVE = Tag.Transient("window:fullscreen") private val ABSORPTION = MetadataDef.Player.ADDITIONAL_HEARTS.index() private val ZERO = Metadata.Float(0f) diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index e5ec499e..952eb3b6 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -119,10 +119,9 @@ class MinestomFullscreenTest : fixture.sent().single().gameTime() shouldBe HIDDEN_AGE fixture .sent() - .single() - .properties() - .single() - .value() shouldBe 20.0 + .flatMap { it.properties() } + .associate { it.attribute() to it.value() } shouldBe + mapOf(Attribute.MAX_HEALTH to 20.0, Attribute.ATTACK_SPEED to 1024.0) fixture.sent().map { it.value() } shouldBe listOf(GameMode.SPECTATOR.ordinal.toFloat(), 0f) @@ -155,8 +154,8 @@ class MinestomFullscreenTest : .sent() .single() .properties() - .single() - .value() shouldBe 40.0 + .associate { it.attribute() to it.value() } shouldBe + mapOf(Attribute.MAX_HEALTH to 40.0, Attribute.ATTACK_SPEED to 4.0) } "a click is hit-tested at the end of the player's tick, after the rotation the client sent with it" { @@ -592,6 +591,12 @@ class MinestomFullscreenTest : .sent() .map { it.properties().single().value() } shouldContainExactly listOf(40.0, 20.0) fixture.clear() + val attackSpeed = EntityAttributesPacket.Property(Attribute.ATTACK_SPEED, 2.0, emptyList()) + fixture.send(EntityAttributesPacket(player.entityId, listOf(attackSpeed))) + fixture + .sent() + .map { it.properties().single().value() } shouldContainExactly listOf(2.0, 1024.0) + fixture.clear() val absorption = MetadataDef.Player.ADDITIONAL_HEARTS.index() fixture.send(EntityMetaDataPacket(player.entityId, mapOf(absorption to Metadata.Float(4f)))) fixture @@ -722,7 +727,10 @@ class MinestomFullscreenTest : fixture.sent().map { it.type() } shouldContainExactly listOf(EntityType.TEXT_DISPLAY, EntityType.INTERACTION) - fixture.sent() shouldBe emptyList() + fixture + .sent() + .flatMap { it.properties() } + .map { it.attribute() } shouldContainExactly listOf(Attribute.ATTACK_SPEED) fixture.clear() fixture.tick() From 80ca219bd0b147784a84ba6fb7bbe7c9d4c438e7 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:10:05 -0400 Subject: [PATCH 2/6] style: format the README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e4e73861..df259303 100644 --- a/README.md +++ b/README.md @@ -247,8 +247,8 @@ While the window is open, the Minestom host: - keeps the player in spectator mode on the server and tells only their client it is in survival, so clicks, the scroll wheel, and number keys reach the server. Game mode, ability, hotbar, time, max health, and attack speed updates the - server sends meanwhile still reach the client, followed by packets that hide them again. Closing restores the - player's game mode (or the one the server set meanwhile), abilities, position, and held slot. + server sends meanwhile still reach the client, followed by packets that hide them again. Closing restores the player's + game mode (or the one the server set meanwhile), abilities, position, and held slot. - hides the HUD by holding the client's world age negative, which the pack's shaders read. The hidden strip at the bottom of the screen can clip the player's own inventory (E) at small GUI heights, since the server is not told it opened. While the keyboard prompt is open, the age sits in a second negative band in which the shaders also hide the From 3f84bd9ca45a91da5f15cfc232c726099b5b778b Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:22:16 -0400 Subject: [PATCH 3/6] fix(runtime): hide the attack indicator with the crosshair marker instead of attack speed The pack now overrides the crosshair and hotbar attack-indicator sprites with marker-alpha texels, which core/position_tex_color discards while the HUD is hidden. This replaces the client-side attack speed, which could not cover the tick after the attack ticker resets. --- README.md | 14 +- crates/window-core/src/fullscreen.rs | 140 +++++++++++++++++- crates/window-core/src/pipeline/fullscreen.rs | 9 +- .../src/pipeline/tests/fullscreens.rs | 8 + .../minestom/internal/MinestomFullscreen.kt | 33 +---- .../window/minestom/MinestomFullscreenTest.kt | 22 +-- plugin/test/golden/fullscreen/expected.sha256 | 7 +- 7 files changed, 178 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index df259303..a41ede7d 100644 --- a/README.md +++ b/README.md @@ -246,13 +246,13 @@ MyLobby(player).open() While the window is open, the Minestom host: - keeps the player in spectator mode on the server and tells only their client it is in survival, so clicks, the scroll - wheel, and number keys reach the server. Game mode, ability, hotbar, time, max health, and attack speed updates the - server sends meanwhile still reach the client, followed by packets that hide them again. Closing restores the player's - game mode (or the one the server set meanwhile), abilities, position, and held slot. -- hides the HUD by holding the client's world age negative, which the pack's shaders read. The hidden strip at the - bottom of the screen can clip the player's own inventory (E) at small GUI heights, since the server is not told it - opened. While the keyboard prompt is open, the age sits in a second negative band in which the shaders also hide the - untitled anvil that captures the typing. + wheel, and number keys reach the server. Game mode, ability, hotbar, time, and max health updates the server sends + meanwhile still reach the client, followed by packets that hide them again. Closing restores the player's game mode + (or the one the server set meanwhile), abilities, position, and held slot. +- hides the HUD by holding the client's world age negative, which the pack's shaders read. The shaders also drop the + crosshair and its attack indicator. The hidden strip at the bottom of the screen can clip the player's own inventory + (E) at small GUI heights, since the server is not told it opened. While the keyboard prompt is open, the age sits in a + second negative band in which the shaders also hide the untitled anvil that captures the typing. - closes the window when the player dies or spawns into another instance. Opening one while the player is dead or respawning throws, so a respawn listener should open it on the next tick. diff --git a/crates/window-core/src/fullscreen.rs b/crates/window-core/src/fullscreen.rs index b57ac665..5a31674d 100644 --- a/crates/window-core/src/fullscreen.rs +++ b/crates/window-core/src/fullscreen.rs @@ -33,6 +33,7 @@ use std::fmt::Write as _; +use crate::authoring::HudGroup; use crate::compose::Texture; use crate::geometry::{Point, Rect, Size}; use crate::ir::Rgb; @@ -117,9 +118,9 @@ pub const HUD_STRIP_HALF_WIDTH: u32 = 96; /// The most max health the client is told while the HUD is hidden, so its hearts fit in one row inside the strip. pub const HUD_MAX_HEALTH: u32 = 20; -/// The alpha of the opaque texels in the pack's override of the vanilla crosshair sprite. While the HUD is hidden, -/// `core/position_tex_color` discards GUI texels with this alpha, which removes the crosshair without cutting -/// anything else. With the crosshair's inverting blend, 254 draws the same as 255. +/// The alpha of the opaque texels in the pack's overrides of the vanilla crosshair and attack-indicator sprites. +/// While the HUD is hidden, `core/position_tex_color` discards GUI texels with this alpha, which removes them +/// without cutting anything else. 254 draws the same as 255, including with the crosshair's inverting blend. pub const CROSSHAIR_MARKER_ALPHA: u8 = 254; /// The sprite the crosshair pipeline draws, relative to the pack root. @@ -228,6 +229,139 @@ pub(crate) fn crosshair_marker_texture() -> Texture { texture } +/// The attack-indicator sprites the pack overrides, as `(group, sprite name, rows, palette)`: each sprite's opaque +/// texels become [`CROSSHAIR_MARKER_ALPHA`], so they are discarded while the HUD is hidden. The client draws the +/// crosshair ones around the crosshair, and the hotbar ones beside the hotbar, which the HUD strip's width does not +/// cover. Rows are the Minecraft 26.2 art, `.` being transparent. +type Palette = &'static [(char, [u8; 3])]; + +const INDICATOR_SPRITES: [(HudGroup, &str, &[&str], Palette); 5] = [ + ( + HudGroup::Crosshair, + "crosshair_attack_indicator_background", + &["...a............", "aaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaa..", "...a............"], + &[('a', [0x3a, 0x3b, 0x3c])], + ), + ( + HudGroup::Crosshair, + "crosshair_attack_indicator_progress", + &["...a............", "bcbcdddddddddddd", "bcbceeffffffff..", "...a............"], + &CROSSHAIR_INDICATOR_PALETTE, + ), + ( + HudGroup::Crosshair, + "crosshair_attack_indicator_full", + &[ + "...a............", + "bcbcdddddddddddf", + "bcbceeffffffff..", + "...a............", + "........d.......", + ".......ddd......", + "........d.......", + "................", + "................", + "................", + "................", + "................", + "................", + "................", + "................", + "................", + ], + &CROSSHAIR_INDICATOR_PALETTE, + ), + ( + HudGroup::Hotbar, + "hotbar_attack_indicator_background", + &[ + "..................", + "..............aa..", + ".............a..a.", + "............a...a.", + "...........a....a.", + "..........a...a.a.", + ".........a...aa..a", + "........a...a.a..a", + ".......a...a.a...a", + "......a...a..a...a", + "..a..a...a...a...a", + ".a.aa...a.....a.a.", + "..a....a.......a..", + "..a...a...........", + ".a....a...........", + "a...aa.a..........", + "a..a..a...........", + ".aa...............", + ], + &[('a', [0, 0, 0])], + ), + ( + HudGroup::Hotbar, + "hotbar_attack_indicator_progress", + &[ + "..................", + "..............aa..", + ".............abca.", + "............abcca.", + "...........abdcea.", + "..........abdfaea.", + ".........abdfaagca", + "........abdfa.agca", + ".......abdfa.acgca", + "......abdfa..aegca", + "..a..abdfa...aceca", + ".ahaaidfa.....aca.", + "..ahjdfa.......a..", + "..ajhja...........", + ".akljha...........", + "aklkaaha..........", + "ahka..a...........", + ".aa...............", + ], + &[ + ('a', [0x00, 0x00, 0x00]), + ('b', [0xd2, 0xd2, 0xd6]), + ('c', [0x6f, 0x47, 0x4b]), + ('d', [0x9f, 0xa1, 0xaf]), + ('e', [0x85, 0x59, 0x5c]), + ('f', [0xaf, 0xb0, 0xb5]), + ('g', [0x95, 0x6b, 0x6e]), + ('h', [0x47, 0x36, 0x35]), + ('i', [0xbd, 0xbe, 0xc3]), + ('j', [0x2b, 0x20, 0x1f]), + ('k', [0x58, 0x47, 0x46]), + ('l', [0x62, 0x4d, 0x46]), + ], + ), +]; + +const CROSSHAIR_INDICATOR_PALETTE: [(char, [u8; 3]); 6] = [ + ('a', [0x5b, 0x4a, 0x49]), + ('b', [0x82, 0x6d, 0x6c]), + ('c', [0x6d, 0x59, 0x57]), + ('d', [0xd7, 0xd9, 0xea]), + ('e', [0xea, 0xec, 0xf2]), + ('f', [0xff, 0xff, 0xff]), +]; + +/// The vanilla attack-indicator sprites' overrides, as `(group, path)` and texture, with opaque texels at +/// [`CROSSHAIR_MARKER_ALPHA`]. +pub(crate) fn indicator_marker_textures() -> impl Iterator { + INDICATOR_SPRITES.iter().map(|&(group, name, rows, palette)| { + let mut texture = Texture::transparent(rows[0].len() as u32, rows.len() as u32); + for (y, row) in rows.iter().enumerate() { + for (x, pixel) in row.chars().enumerate() { + if let Some((_, [r, g, b])) = palette.iter().find(|(key, _)| *key == pixel) { + let at = (y * texture.width as usize + x) * 4; + texture.rgba[at..at + 4].copy_from_slice(&[*r, *g, *b, CROSSHAIR_MARKER_ALPHA]); + } + } + } + (group, format!("assets/minecraft/textures/gui/sprites/hud/{name}.png"), texture) + }) +} + /// The built-in backdrop glyph's texture: solid white, which the shader stretches over the screen. Its left and top /// corners lie on the line origin. pub(crate) fn backdrop_texture() -> Texture { diff --git a/crates/window-core/src/pipeline/fullscreen.rs b/crates/window-core/src/pipeline/fullscreen.rs index 60456627..ab89ec79 100644 --- a/crates/window-core/src/pipeline/fullscreen.rs +++ b/crates/window-core/src/pipeline/fullscreen.rs @@ -247,13 +247,18 @@ pub(super) fn emit_contract(ctx: &mut CompileContext<'_>, mut contract: Contract }) } -/// Emits the crosshair sprite override the HUD-hiding fragment shader discards, unless `vanilla.hud` already blanks -/// the crosshair. +/// Emits the sprite overrides the HUD-hiding fragment shader discards: the crosshair and the attack indicators, +/// except those of a group `blank_hud` already blanks. pub(super) fn emit_crosshair_marker(ctx: &mut CompileContext<'_>, blank_hud: &BTreeSet) -> Result<()> { if !blank_hud.contains(&HudGroup::Crosshair) { let texture = fullscreen::crosshair_marker_texture(); ctx.push(Part::Fullscreen, OutputFile::binary(fullscreen::CROSSHAIR_SPRITE_PATH, texture.encode_png()?)); } + for (group, path, texture) in fullscreen::indicator_marker_textures() { + if !blank_hud.contains(&group) { + ctx.push(Part::Fullscreen, OutputFile::binary(path, texture.encode_png()?)); + } + } Ok(()) } diff --git a/crates/window-core/src/pipeline/tests/fullscreens.rs b/crates/window-core/src/pipeline/tests/fullscreens.rs index 15f05144..4ab451b7 100644 --- a/crates/window-core/src/pipeline/tests/fullscreens.rs +++ b/crates/window-core/src/pipeline/tests/fullscreens.rs @@ -118,12 +118,20 @@ fn full_screen_windows_emit_their_shaders_and_need_the_define_variant_profile() let alphas: BTreeSet = crosshair.rgba.chunks(4).map(|texel| texel[3]).collect(); assert_eq!((crosshair.width, crosshair.height), (15, 15)); assert_eq!(alphas, BTreeSet::from([0, fullscreen::CROSSHAIR_MARKER_ALPHA])); + for (_, path, _) in fullscreen::indicator_marker_textures() { + let file = out.files.iter().find(|file| file.path == path).unwrap(); + let sprite = Texture::decode_png(file.contents.as_bytes()).unwrap(); + let alphas: BTreeSet = sprite.rgba.chunks(4).map(|texel| texel[3]).collect(); + assert_eq!(alphas, BTreeSet::from([0, fullscreen::CROSSHAIR_MARKER_ALPHA]), "{path}"); + } let blanked = json!({ "options": { "vanilla": { "hud": { "crosshair": false } } }, "fullscreens": [menu.clone()], "target": { "pack_format": 88 } }); let out = compile_project_json(blanked.to_string().as_bytes(), &CompileInput::new(BTreeMap::new())).unwrap(); let sprites: Vec<_> = out.files.iter().filter(|file| file.path == fullscreen::CROSSHAIR_SPRITE_PATH).collect(); assert_eq!(sprites.len(), 1); + let indicators = out.files.iter().filter(|file| file.path.contains("attack_indicator")).count(); + assert_eq!(indicators, 5, "blanking the crosshair leaves the hotbar indicators to the marker"); assert_eq!(Texture::decode_png(sprites[0].contents.as_bytes()).unwrap().rgba, Texture::transparent(15, 15).rgba); let error = compile_at(menu.clone(), json!(84)).unwrap_err().to_string(); diff --git a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt index 8ec53a6f..8aa1cfea 100644 --- a/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt +++ b/jvm/minestom/src/main/kotlin/com/chunkzero/window/minestom/internal/MinestomFullscreen.kt @@ -75,8 +75,7 @@ import net.minestom.server.world.clock.WorldClock * * The HUD is hidden: the hotbar holds blank items, which also hide the hand, and the client's world age is held * negative, which the pack's GUI shader reads as "clip the bottom-centre HUD strip". So the hearts fit that strip, the - * client is told its max health is at most the canvas's `hudMaxHealth` and its absorption is 0. The client is also told - * its attack speed is 1024, so the cooldown indicator never draws. While the server has a + * client is told its max health is at most the canvas's `hudMaxHealth` and its absorption is 0. While the server has a * container open, the client gets the real world age back and its real hotbar so the strip and slots show. The text * prompt's dialog is a client screen rather than a server container, so it keeps the HUD hidden and the hotbar blank. * The keyboard prompt's anvil is a server container, but not one the player sees: while it is open the client gets the @@ -98,8 +97,7 @@ import net.minestom.server.world.clock.WorldClock * not teleported back. A window opened by a spawn listener is in the spawn's instance and stays open. * * In horse mode, [MinestomFullscreenHorse] also locks the view. The client then draws no hearts, since its camera is - * not a player, so their max health and absorption are left alone. The crosshair's cooldown indicator still draws, so - * attack speed is hidden in both modes. + * not a player, so their max health and absorption are left alone. * * A prompt's callback runs on the player's thread after the state it reports has changed, never under [lock]: a server * container opening over the dialog or replacing the anvil cancels it on the next tick, and a window ending cancels it @@ -302,7 +300,6 @@ internal class MinestomFullscreen private constructor( private fun hideHud() { if (!containerOpen) hideTime(realTime().clocks()) capMaxHealth() - hideAttackSpeed() if (horse == null) player.sendPacket(EntityMetaDataPacket(player.entityId, mapOf(ABSORPTION to ZERO))) } @@ -332,10 +329,6 @@ internal class MinestomFullscreen private constructor( return EntityAttributesPacket.Property(Attribute.MAX_HEALTH, cap, emptyList()) } - private fun hideAttackSpeed() { - player.sendPacket(EntityAttributesPacket(player.entityId, listOf(HIDDEN_ATTACK_SPEED))) - } - private fun listen() { node.addListener(PlayerPacketEvent::class.java, ::onPacket) node.addListener(PlayerPacketOutEvent::class.java, ::onPacketOut) @@ -424,19 +417,9 @@ internal class MinestomFullscreen private constructor( } is EntityAttributesPacket -> { - if (packet.entityId() != player.entityId) return - val capped = cappedMaxHealth() - if (capped != null && - packet.properties().any { it.attribute() == Attribute.MAX_HEALTH && it != capped } - ) { - capMaxHealth() - } - if (packet.properties().any { - it.attribute() == Attribute.ATTACK_SPEED && it != HIDDEN_ATTACK_SPEED - } - ) { - hideAttackSpeed() - } + val capped = cappedMaxHealth() ?: return + val uncapped = packet.properties().any { it.attribute() == Attribute.MAX_HEALTH && it != capped } + if (packet.entityId() == player.entityId && uncapped) capMaxHealth() } is EntityMetaDataPacket -> { @@ -615,10 +598,7 @@ internal class MinestomFullscreen private constructor( player.sendPacket(realTime()) val maxHealth = player.getAttribute(Attribute.MAX_HEALTH) val property = EntityAttributesPacket.Property(Attribute.MAX_HEALTH, maxHealth.baseValue, maxHealth.modifiers()) - val attackSpeed = player.getAttribute(Attribute.ATTACK_SPEED) - val attackSpeedProperty = - EntityAttributesPacket.Property(Attribute.ATTACK_SPEED, attackSpeed.baseValue, attackSpeed.modifiers()) - player.sendPacket(EntityAttributesPacket(player.entityId, listOf(property, attackSpeedProperty))) + player.sendPacket(EntityAttributesPacket(player.entityId, listOf(property))) player.sendPacket(player.metadataPacket) player.sendPacket(UpdateHealthPacket(player.health, player.food, player.foodSaturation)) player.setHeldItemSlot(heldSlot) @@ -626,7 +606,6 @@ internal class MinestomFullscreen private constructor( } companion object { - private val HIDDEN_ATTACK_SPEED = EntityAttributesPacket.Property(Attribute.ATTACK_SPEED, 1024.0, emptyList()) private val ACTIVE = Tag.Transient("window:fullscreen") private val ABSORPTION = MetadataDef.Player.ADDITIONAL_HEARTS.index() private val ZERO = Metadata.Float(0f) diff --git a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt index 952eb3b6..e5ec499e 100644 --- a/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt +++ b/jvm/minestom/src/test/kotlin/com/chunkzero/window/minestom/MinestomFullscreenTest.kt @@ -119,9 +119,10 @@ class MinestomFullscreenTest : fixture.sent().single().gameTime() shouldBe HIDDEN_AGE fixture .sent() - .flatMap { it.properties() } - .associate { it.attribute() to it.value() } shouldBe - mapOf(Attribute.MAX_HEALTH to 20.0, Attribute.ATTACK_SPEED to 1024.0) + .single() + .properties() + .single() + .value() shouldBe 20.0 fixture.sent().map { it.value() } shouldBe listOf(GameMode.SPECTATOR.ordinal.toFloat(), 0f) @@ -154,8 +155,8 @@ class MinestomFullscreenTest : .sent() .single() .properties() - .associate { it.attribute() to it.value() } shouldBe - mapOf(Attribute.MAX_HEALTH to 40.0, Attribute.ATTACK_SPEED to 4.0) + .single() + .value() shouldBe 40.0 } "a click is hit-tested at the end of the player's tick, after the rotation the client sent with it" { @@ -591,12 +592,6 @@ class MinestomFullscreenTest : .sent() .map { it.properties().single().value() } shouldContainExactly listOf(40.0, 20.0) fixture.clear() - val attackSpeed = EntityAttributesPacket.Property(Attribute.ATTACK_SPEED, 2.0, emptyList()) - fixture.send(EntityAttributesPacket(player.entityId, listOf(attackSpeed))) - fixture - .sent() - .map { it.properties().single().value() } shouldContainExactly listOf(2.0, 1024.0) - fixture.clear() val absorption = MetadataDef.Player.ADDITIONAL_HEARTS.index() fixture.send(EntityMetaDataPacket(player.entityId, mapOf(absorption to Metadata.Float(4f)))) fixture @@ -727,10 +722,7 @@ class MinestomFullscreenTest : fixture.sent().map { it.type() } shouldContainExactly listOf(EntityType.TEXT_DISPLAY, EntityType.INTERACTION) - fixture - .sent() - .flatMap { it.properties() } - .map { it.attribute() } shouldContainExactly listOf(Attribute.ATTACK_SPEED) + fixture.sent() shouldBe emptyList() fixture.clear() fixture.tick() diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index 554a2cc2..fe18e5cb 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -7,6 +7,11 @@ df4975456519e08d7a7065027ae566a58314fa95d6a7f58764a2bac5638e1e72 dist/assets/mi f6a980f98a5850b6530119b72ef064d6e3365f2176c2ba9c50b7e3ce6524173b dist/assets/minecraft/shaders/core/text.vsh d8c8dc63512f18a6f85b86ce57d473cc2b2b76453ba1c0fd3ba97b47bbb5b26f dist/assets/minecraft/shaders/include/window_fullscreen.glsl 8f0a28c175d2c93ff86aa1377f34c9e8fbc76e922ca34ae2f6db19dd557d285e dist/assets/minecraft/textures/gui/sprites/hud/crosshair.png +1a7df954a94e6824491408c04827cc52e2ea1d329176d601abfdb7a58f8ba4fc dist/assets/minecraft/textures/gui/sprites/hud/crosshair_attack_indicator_background.png +77e049f061d2a6af6d1c1c3f7dd4410de8bfd5eff2f3a9c0ea19364cfe17a877 dist/assets/minecraft/textures/gui/sprites/hud/crosshair_attack_indicator_full.png +8060a8aa15746b6bb263e4a1dcdca644b2658340ed8218c0c3ee8bfbb7cc91eb dist/assets/minecraft/textures/gui/sprites/hud/crosshair_attack_indicator_progress.png +eb3e1048944848056a0abca1d2154c2c0146624cb10b65893664885bba9e09c2 dist/assets/minecraft/textures/gui/sprites/hud/hotbar_attack_indicator_background.png +0dcd130c4bc225bd1bfb5e55bd7916a91b28591dd6eae6d2fc368387fa8c0d60 dist/assets/minecraft/textures/gui/sprites/hud/hotbar_attack_indicator_progress.png 69beab9c90d9a84fa45fd48c393db16ea354338990a7955935cf8518f6fbcf74 dist/assets/window/equipment/fullscreen/horse_cursor.json 36544f87434a58ca80349fb16fbbe42b24133f26bb83bab37e684d1cb7a10c73 dist/assets/window/font/ui.json 8442db86bb9acb34f4a3af439905fd115deba750a42d5864e10d10b2973f3afa dist/assets/window/font/y111.json @@ -35,7 +40,7 @@ a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/wi 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_220_160_41x16_0_0.png 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_256_160_41x16_0_0.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png -5611a7288a716852a43533923f19ffe054ccf870e7275b31371bead51bb76d8d dist/assets/window/window/debug.json +1db8dfde3efda40652d8c66dccba2c1c07da4f613bd09b47d83e2e5d83be037f dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta 23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt c7112fbdeac4c678c3fdc1eacdd4fbccedbc28c3b0ff8baf44ef5af2d1d104a2 kotlin-out/MenuFullscreen.kt From e6f99e3615a2ed19a42645d52ab65282cbff21e9 Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:40:26 -0400 Subject: [PATCH 4/6] fix(runtime): drop the attack indicator by its exact quad instead of copied sprites The GUI sprite shader matches each vertex against the vanilla attack-indicator quads while the HUD is hidden and discards a quad only when all four corners match. --- README.md | 7 +- crates/window-core/src/fullscreen.rs | 140 +----------------- .../src/hud/renderer/fullscreen.rs | 45 +++++- crates/window-core/src/hud/tests.rs | 3 + crates/window-core/src/pipeline/fullscreen.rs | 9 +- .../src/pipeline/tests/fullscreens.rs | 8 - plugin/test/golden/fullscreen/expected.sha256 | 11 +- 7 files changed, 56 insertions(+), 167 deletions(-) diff --git a/README.md b/README.md index a41ede7d..e34c270d 100644 --- a/README.md +++ b/README.md @@ -250,9 +250,10 @@ While the window is open, the Minestom host: meanwhile still reach the client, followed by packets that hide them again. Closing restores the player's game mode (or the one the server set meanwhile), abilities, position, and held slot. - hides the HUD by holding the client's world age negative, which the pack's shaders read. The shaders also drop the - crosshair and its attack indicator. The hidden strip at the bottom of the screen can clip the player's own inventory - (E) at small GUI heights, since the server is not told it opened. While the keyboard prompt is open, the age sits in a - second negative band in which the shaders also hide the untitled anvil that captures the typing. + crosshair, and the attack indicator quads they match by exact position. The hidden strip at the bottom of the screen + can clip the player's own inventory (E) at small GUI heights, since the server is not told it opened. While the + keyboard prompt is open, the age sits in a second negative band in which the shaders also hide the untitled anvil that + captures the typing. - closes the window when the player dies or spawns into another instance. Opening one while the player is dead or respawning throws, so a respawn listener should open it on the next tick. diff --git a/crates/window-core/src/fullscreen.rs b/crates/window-core/src/fullscreen.rs index 5a31674d..b57ac665 100644 --- a/crates/window-core/src/fullscreen.rs +++ b/crates/window-core/src/fullscreen.rs @@ -33,7 +33,6 @@ use std::fmt::Write as _; -use crate::authoring::HudGroup; use crate::compose::Texture; use crate::geometry::{Point, Rect, Size}; use crate::ir::Rgb; @@ -118,9 +117,9 @@ pub const HUD_STRIP_HALF_WIDTH: u32 = 96; /// The most max health the client is told while the HUD is hidden, so its hearts fit in one row inside the strip. pub const HUD_MAX_HEALTH: u32 = 20; -/// The alpha of the opaque texels in the pack's overrides of the vanilla crosshair and attack-indicator sprites. -/// While the HUD is hidden, `core/position_tex_color` discards GUI texels with this alpha, which removes them -/// without cutting anything else. 254 draws the same as 255, including with the crosshair's inverting blend. +/// The alpha of the opaque texels in the pack's override of the vanilla crosshair sprite. While the HUD is hidden, +/// `core/position_tex_color` discards GUI texels with this alpha, which removes the crosshair without cutting +/// anything else. With the crosshair's inverting blend, 254 draws the same as 255. pub const CROSSHAIR_MARKER_ALPHA: u8 = 254; /// The sprite the crosshair pipeline draws, relative to the pack root. @@ -229,139 +228,6 @@ pub(crate) fn crosshair_marker_texture() -> Texture { texture } -/// The attack-indicator sprites the pack overrides, as `(group, sprite name, rows, palette)`: each sprite's opaque -/// texels become [`CROSSHAIR_MARKER_ALPHA`], so they are discarded while the HUD is hidden. The client draws the -/// crosshair ones around the crosshair, and the hotbar ones beside the hotbar, which the HUD strip's width does not -/// cover. Rows are the Minecraft 26.2 art, `.` being transparent. -type Palette = &'static [(char, [u8; 3])]; - -const INDICATOR_SPRITES: [(HudGroup, &str, &[&str], Palette); 5] = [ - ( - HudGroup::Crosshair, - "crosshair_attack_indicator_background", - &["...a............", "aaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaa..", "...a............"], - &[('a', [0x3a, 0x3b, 0x3c])], - ), - ( - HudGroup::Crosshair, - "crosshair_attack_indicator_progress", - &["...a............", "bcbcdddddddddddd", "bcbceeffffffff..", "...a............"], - &CROSSHAIR_INDICATOR_PALETTE, - ), - ( - HudGroup::Crosshair, - "crosshair_attack_indicator_full", - &[ - "...a............", - "bcbcdddddddddddf", - "bcbceeffffffff..", - "...a............", - "........d.......", - ".......ddd......", - "........d.......", - "................", - "................", - "................", - "................", - "................", - "................", - "................", - "................", - "................", - ], - &CROSSHAIR_INDICATOR_PALETTE, - ), - ( - HudGroup::Hotbar, - "hotbar_attack_indicator_background", - &[ - "..................", - "..............aa..", - ".............a..a.", - "............a...a.", - "...........a....a.", - "..........a...a.a.", - ".........a...aa..a", - "........a...a.a..a", - ".......a...a.a...a", - "......a...a..a...a", - "..a..a...a...a...a", - ".a.aa...a.....a.a.", - "..a....a.......a..", - "..a...a...........", - ".a....a...........", - "a...aa.a..........", - "a..a..a...........", - ".aa...............", - ], - &[('a', [0, 0, 0])], - ), - ( - HudGroup::Hotbar, - "hotbar_attack_indicator_progress", - &[ - "..................", - "..............aa..", - ".............abca.", - "............abcca.", - "...........abdcea.", - "..........abdfaea.", - ".........abdfaagca", - "........abdfa.agca", - ".......abdfa.acgca", - "......abdfa..aegca", - "..a..abdfa...aceca", - ".ahaaidfa.....aca.", - "..ahjdfa.......a..", - "..ajhja...........", - ".akljha...........", - "aklkaaha..........", - "ahka..a...........", - ".aa...............", - ], - &[ - ('a', [0x00, 0x00, 0x00]), - ('b', [0xd2, 0xd2, 0xd6]), - ('c', [0x6f, 0x47, 0x4b]), - ('d', [0x9f, 0xa1, 0xaf]), - ('e', [0x85, 0x59, 0x5c]), - ('f', [0xaf, 0xb0, 0xb5]), - ('g', [0x95, 0x6b, 0x6e]), - ('h', [0x47, 0x36, 0x35]), - ('i', [0xbd, 0xbe, 0xc3]), - ('j', [0x2b, 0x20, 0x1f]), - ('k', [0x58, 0x47, 0x46]), - ('l', [0x62, 0x4d, 0x46]), - ], - ), -]; - -const CROSSHAIR_INDICATOR_PALETTE: [(char, [u8; 3]); 6] = [ - ('a', [0x5b, 0x4a, 0x49]), - ('b', [0x82, 0x6d, 0x6c]), - ('c', [0x6d, 0x59, 0x57]), - ('d', [0xd7, 0xd9, 0xea]), - ('e', [0xea, 0xec, 0xf2]), - ('f', [0xff, 0xff, 0xff]), -]; - -/// The vanilla attack-indicator sprites' overrides, as `(group, path)` and texture, with opaque texels at -/// [`CROSSHAIR_MARKER_ALPHA`]. -pub(crate) fn indicator_marker_textures() -> impl Iterator { - INDICATOR_SPRITES.iter().map(|&(group, name, rows, palette)| { - let mut texture = Texture::transparent(rows[0].len() as u32, rows.len() as u32); - for (y, row) in rows.iter().enumerate() { - for (x, pixel) in row.chars().enumerate() { - if let Some((_, [r, g, b])) = palette.iter().find(|(key, _)| *key == pixel) { - let at = (y * texture.width as usize + x) * 4; - texture.rgba[at..at + 4].copy_from_slice(&[*r, *g, *b, CROSSHAIR_MARKER_ALPHA]); - } - } - } - (group, format!("assets/minecraft/textures/gui/sprites/hud/{name}.png"), texture) - }) -} - /// The built-in backdrop glyph's texture: solid white, which the shader stretches over the screen. Its left and top /// corners lie on the line origin. pub(crate) fn backdrop_texture() -> Texture { diff --git a/crates/window-core/src/hud/renderer/fullscreen.rs b/crates/window-core/src/hud/renderer/fullscreen.rs index 6d55c741..3128100e 100644 --- a/crates/window-core/src/hud/renderer/fullscreen.rs +++ b/crates/window-core/src/hud/renderer/fullscreen.rs @@ -1,5 +1,5 @@ //! Full-screen snippets of the pack_format 85-88 `core/text` vertex shader, and the `core/position_tex_color` -//! shaders that hide the HUD and the vanilla crosshair while a full-screen window is open, and the keyboard prompt's +//! shaders that hide the HUD, the vanilla crosshair, and the attack indicators while a full-screen window is open, and the keyboard prompt's //! anvil while it is open (see [`super::prompt`]). //! //! The text shader moves every vertex of a full-screen marker (see [`crate::fullscreen`]) in the world pass straight @@ -95,7 +95,8 @@ pub(super) fn render_fullscreen_vertex_helpers() -> String { VERTEX_HELPERS.replace("@PLACEMENT@", CANVAS_PLACEMENT).replace("@DEPTH_STEP@", &format!("{LAYER_DEPTH_STEP:?}")) } -/// Renders `core/position_tex_color.vsh`, vanilla's GUI sprite shader plus the HUD strip and prompt inputs. Like +/// Renders `core/position_tex_color.vsh`, vanilla's GUI sprite shader plus the HUD strip, attack indicator, and prompt +/// inputs. Like /// vanilla, it declares its uniform blocks inline, since the shader is also compiled before resource packs load. pub(in crate::hud) fn render_position_tex_color_vertex() -> String { let source = r#"#version 330 @@ -127,7 +128,34 @@ out vec2 texCoord0; out vec4 vertexColor; out vec2 windowHudPos; flat out int windowHudHidden; +out float windowHudIndicator; @PROMPT@ +// Whether a vertex of a GUI sprite quad sits where corner `corner` of `rect` (left, top, right, bottom in GUI pixels) +// belongs. GUI sprite quads list their corners top-left, bottom-left, bottom-right, top-right. `freeRight` leaves the +// right edge's x unchecked and `freeTop` the top edge's y, for the progress sprites whose far edge follows the charge. +bool window_fs_quad_corner(vec2 pos, int corner, vec4 rect, bool freeRight, bool freeTop) { + bool right = corner >= 2; + bool bottom = corner == 1 || corner == 2; + vec2 d = abs(pos - vec2(right ? rect.z : rect.x, bottom ? rect.w : rect.y)); + return (d.x < 0.01 || (right && freeRight)) && (d.y < 0.01 || (!bottom && freeTop)); +} + +// Whether a vertex belongs to a quad vanilla's Hud draws as an attack indicator, in GUI pixels from the top-left, +// with the GUI size gui: the crosshair indicator's 16x4 background and progress and its 16x16 full sprite, which +// start at (gui.x / 2 - 8, gui.y / 2 + 9), and the 18x18 hotbar indicator background and progress in the row +// gui.y - 20, either 97 pixels right of the centre or 113 left of it, depending on the main arm. +bool window_fs_indicator_corner(vec2 pos, int corner, vec2 gui) { + vec2 centre = floor(gui / 2.0); + vec4 crosshair = vec4(centre.x - 8.0, centre.y + 9.0, centre.x + 8.0, centre.y + 13.0); + vec4 full = vec4(crosshair.xy, crosshair.zy + vec2(0.0, 16.0)); + vec4 hotbarRight = vec4(centre.x + 97.0, gui.y - 20.0, centre.x + 115.0, gui.y - 2.0); + vec4 hotbarLeft = hotbarRight - vec4(210.0, 0.0, 210.0, 0.0); + return window_fs_quad_corner(pos, corner, crosshair, true, false) + || window_fs_quad_corner(pos, corner, full, false, false) + || window_fs_quad_corner(pos, corner, hotbarRight, false, true) + || window_fs_quad_corner(pos, corner, hotbarLeft, false, true); +} + void main() { gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); @@ -139,6 +167,11 @@ void main() { windowHudHidden = GameTime < 0.0 && ProjMat[3][3] == 1.0 ? 1 : 0; windowHudPos = vec2(gl_Position.x / ProjMat[0][0], (gl_Position.y + 1.0) / -ProjMat[1][1]); + // Only a quad whose four vertices all match reaches 1.0 across its fragments. + vec2 gui = vec2(2.0 / ProjMat[0][0], -2.0 / ProjMat[1][1]); + vec2 guiPos = vec2((gl_Position.x + 1.0) / ProjMat[0][0], (gl_Position.y - 1.0) / ProjMat[1][1]); + windowHudIndicator = windowHudHidden == 1 && window_fs_indicator_corner(guiPos, gl_VertexID % 4, floor(gui + 0.5)) ? 1.0 : 0.0; + window_prompt_clip(gl_Position); } "#; @@ -146,8 +179,8 @@ void main() { } /// Renders `core/position_tex_color.fsh`, which while the HUD is hidden discards GUI sprite fragments inside the HUD -/// strip, and texels carrying the crosshair marker alpha, which the pack's crosshair sprite override sets on its -/// opaque texels. Clipping per fragment cuts sprites crossing the strip's edge instead of distorting them. While the +/// strip, texels carrying the crosshair marker alpha, which the pack's crosshair sprite override sets on its opaque +/// texels, and quads the vertex shader matched to a vanilla attack indicator. Clipping per fragment cuts sprites crossing the strip's edge instead of distorting them. While the /// prompt's anvil is open, it also discards GUI sprite fragments inside the anvil's rect. pub(in crate::hud) fn render_position_tex_color_fragment() -> String { format!( @@ -170,6 +203,7 @@ in vec2 texCoord0; in vec4 vertexColor; in vec2 windowHudPos; flat in int windowHudHidden; +in float windowHudIndicator; {prompt_helpers} out vec4 fragColor; @@ -177,6 +211,9 @@ void main() {{{prompt_clip} if (windowHudHidden == 1 && abs(windowHudPos.x) < WINDOW_FS_HUD_STRIP.x && windowHudPos.y < WINDOW_FS_HUD_STRIP.y) {{ discard; }} + if (windowHudIndicator > 0.99) {{ + discard; + }} vec4 texel = texture(Sampler0, texCoord0); if (windowHudHidden == 1 && int(round(texel.a * 255.0)) == WINDOW_FS_CROSSHAIR_ALPHA) {{ discard; diff --git a/crates/window-core/src/hud/tests.rs b/crates/window-core/src/hud/tests.rs index 655e26c8..2fcb47e3 100644 --- a/crates/window-core/src/hud/tests.rs +++ b/crates/window-core/src/hud/tests.rs @@ -151,6 +151,9 @@ fn fullscreen_branch_joins_the_text_shader_and_hides_the_hud_strip() { assert!(alone.files[0].contents.contains("void window_fs_apply()")); let fragment = &alone.files[3].contents; assert!(fragment.contains("const vec2 WINDOW_FS_HUD_STRIP = vec2(96.0, 56.0);"), "{fragment}"); + let indicator = &alone.files[2].contents; + assert!(indicator.contains("windowHudHidden == 1 && window_fs_indicator_corner("), "{indicator}"); + assert!(fragment.contains("if (windowHudIndicator > 0.99)"), "{fragment}"); assert!(alone.files[2].contents.contains("float GameTime;\n int MenuBlurRadius;\n int UseRgss;\n};")); // While the prompt's anvil is open, GUI text is hidden only in its field and label bands, and fills only in the diff --git a/crates/window-core/src/pipeline/fullscreen.rs b/crates/window-core/src/pipeline/fullscreen.rs index ab89ec79..60456627 100644 --- a/crates/window-core/src/pipeline/fullscreen.rs +++ b/crates/window-core/src/pipeline/fullscreen.rs @@ -247,18 +247,13 @@ pub(super) fn emit_contract(ctx: &mut CompileContext<'_>, mut contract: Contract }) } -/// Emits the sprite overrides the HUD-hiding fragment shader discards: the crosshair and the attack indicators, -/// except those of a group `blank_hud` already blanks. +/// Emits the crosshair sprite override the HUD-hiding fragment shader discards, unless `vanilla.hud` already blanks +/// the crosshair. pub(super) fn emit_crosshair_marker(ctx: &mut CompileContext<'_>, blank_hud: &BTreeSet) -> Result<()> { if !blank_hud.contains(&HudGroup::Crosshair) { let texture = fullscreen::crosshair_marker_texture(); ctx.push(Part::Fullscreen, OutputFile::binary(fullscreen::CROSSHAIR_SPRITE_PATH, texture.encode_png()?)); } - for (group, path, texture) in fullscreen::indicator_marker_textures() { - if !blank_hud.contains(&group) { - ctx.push(Part::Fullscreen, OutputFile::binary(path, texture.encode_png()?)); - } - } Ok(()) } diff --git a/crates/window-core/src/pipeline/tests/fullscreens.rs b/crates/window-core/src/pipeline/tests/fullscreens.rs index 4ab451b7..15f05144 100644 --- a/crates/window-core/src/pipeline/tests/fullscreens.rs +++ b/crates/window-core/src/pipeline/tests/fullscreens.rs @@ -118,20 +118,12 @@ fn full_screen_windows_emit_their_shaders_and_need_the_define_variant_profile() let alphas: BTreeSet = crosshair.rgba.chunks(4).map(|texel| texel[3]).collect(); assert_eq!((crosshair.width, crosshair.height), (15, 15)); assert_eq!(alphas, BTreeSet::from([0, fullscreen::CROSSHAIR_MARKER_ALPHA])); - for (_, path, _) in fullscreen::indicator_marker_textures() { - let file = out.files.iter().find(|file| file.path == path).unwrap(); - let sprite = Texture::decode_png(file.contents.as_bytes()).unwrap(); - let alphas: BTreeSet = sprite.rgba.chunks(4).map(|texel| texel[3]).collect(); - assert_eq!(alphas, BTreeSet::from([0, fullscreen::CROSSHAIR_MARKER_ALPHA]), "{path}"); - } let blanked = json!({ "options": { "vanilla": { "hud": { "crosshair": false } } }, "fullscreens": [menu.clone()], "target": { "pack_format": 88 } }); let out = compile_project_json(blanked.to_string().as_bytes(), &CompileInput::new(BTreeMap::new())).unwrap(); let sprites: Vec<_> = out.files.iter().filter(|file| file.path == fullscreen::CROSSHAIR_SPRITE_PATH).collect(); assert_eq!(sprites.len(), 1); - let indicators = out.files.iter().filter(|file| file.path.contains("attack_indicator")).count(); - assert_eq!(indicators, 5, "blanking the crosshair leaves the hotbar indicators to the marker"); assert_eq!(Texture::decode_png(sprites[0].contents.as_bytes()).unwrap().rgba, Texture::transparent(15, 15).rgba); let error = compile_at(menu.clone(), json!(84)).unwrap_err().to_string(); diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index fe18e5cb..ea547344 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -1,17 +1,12 @@ b902022fd657ad3b7b7cd44391210c7c6f2d5086bebba022fecbbee2a951df3f dist/assets/minecraft/shaders/core/entity.vsh d56563e462372062c7a8e7a417b4a1b107f0d291ed561b6d74c6a3c0eb8ffa71 dist/assets/minecraft/shaders/core/gui.fsh de99b339c5bc4b28a85d9fe06066c528fa7c10497037c51dca18652360f14aa3 dist/assets/minecraft/shaders/core/gui.vsh -13dfc12b3e9554fae3cd1c2c43cd78d92ebd8405c7493b4252da616482ed93cd dist/assets/minecraft/shaders/core/position_tex_color.fsh -df4975456519e08d7a7065027ae566a58314fa95d6a7f58764a2bac5638e1e72 dist/assets/minecraft/shaders/core/position_tex_color.vsh +54ee52c369b29918df2bd52340ebcc95a934e39f7124679588b945fa812f1bfd dist/assets/minecraft/shaders/core/position_tex_color.fsh +cfd5164f1b9d52346376f6cabdd63439d26293a2cc4a7c51c05777c691576a54 dist/assets/minecraft/shaders/core/position_tex_color.vsh 56bb409c4026de938f0a2f77bd3ac9131658aad33facbb9b92145e9095ba0fdd dist/assets/minecraft/shaders/core/text.fsh f6a980f98a5850b6530119b72ef064d6e3365f2176c2ba9c50b7e3ce6524173b dist/assets/minecraft/shaders/core/text.vsh d8c8dc63512f18a6f85b86ce57d473cc2b2b76453ba1c0fd3ba97b47bbb5b26f dist/assets/minecraft/shaders/include/window_fullscreen.glsl 8f0a28c175d2c93ff86aa1377f34c9e8fbc76e922ca34ae2f6db19dd557d285e dist/assets/minecraft/textures/gui/sprites/hud/crosshair.png -1a7df954a94e6824491408c04827cc52e2ea1d329176d601abfdb7a58f8ba4fc dist/assets/minecraft/textures/gui/sprites/hud/crosshair_attack_indicator_background.png -77e049f061d2a6af6d1c1c3f7dd4410de8bfd5eff2f3a9c0ea19364cfe17a877 dist/assets/minecraft/textures/gui/sprites/hud/crosshair_attack_indicator_full.png -8060a8aa15746b6bb263e4a1dcdca644b2658340ed8218c0c3ee8bfbb7cc91eb dist/assets/minecraft/textures/gui/sprites/hud/crosshair_attack_indicator_progress.png -eb3e1048944848056a0abca1d2154c2c0146624cb10b65893664885bba9e09c2 dist/assets/minecraft/textures/gui/sprites/hud/hotbar_attack_indicator_background.png -0dcd130c4bc225bd1bfb5e55bd7916a91b28591dd6eae6d2fc368387fa8c0d60 dist/assets/minecraft/textures/gui/sprites/hud/hotbar_attack_indicator_progress.png 69beab9c90d9a84fa45fd48c393db16ea354338990a7955935cf8518f6fbcf74 dist/assets/window/equipment/fullscreen/horse_cursor.json 36544f87434a58ca80349fb16fbbe42b24133f26bb83bab37e684d1cb7a10c73 dist/assets/window/font/ui.json 8442db86bb9acb34f4a3af439905fd115deba750a42d5864e10d10b2973f3afa dist/assets/window/font/y111.json @@ -40,7 +35,7 @@ a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/wi 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_220_160_41x16_0_0.png 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_256_160_41x16_0_0.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png -1db8dfde3efda40652d8c66dccba2c1c07da4f613bd09b47d83e2e5d83be037f dist/assets/window/window/debug.json +755d7ae7164c3f660fb540ecdec2740998812f762248653c5a56da77b3dd0b61 dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta 23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt c7112fbdeac4c678c3fdc1eacdd4fbccedbc28c3b0ff8baf44ef5af2d1d104a2 kotlin-out/MenuFullscreen.kt From 226837e40817855fa212ae8b38d7d5f0288ed6dd Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:49:10 -0400 Subject: [PATCH 5/6] fix(runtime): match attack indicator corners by position and the client's GUI size --- .../src/hud/renderer/fullscreen.rs | 38 ++++++++++--------- crates/window-core/src/hud/tests.rs | 4 +- plugin/test/golden/fullscreen/expected.sha256 | 6 +-- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/window-core/src/hud/renderer/fullscreen.rs b/crates/window-core/src/hud/renderer/fullscreen.rs index 3128100e..1aa7a1b3 100644 --- a/crates/window-core/src/hud/renderer/fullscreen.rs +++ b/crates/window-core/src/hud/renderer/fullscreen.rs @@ -130,30 +130,31 @@ out vec2 windowHudPos; flat out int windowHudHidden; out float windowHudIndicator; @PROMPT@ -// Whether a vertex of a GUI sprite quad sits where corner `corner` of `rect` (left, top, right, bottom in GUI pixels) -// belongs. GUI sprite quads list their corners top-left, bottom-left, bottom-right, top-right. `freeRight` leaves the -// right edge's x unchecked and `freeTop` the top edge's y, for the progress sprites whose far edge follows the charge. -bool window_fs_quad_corner(vec2 pos, int corner, vec4 rect, bool freeRight, bool freeTop) { - bool right = corner >= 2; - bool bottom = corner == 1 || corner == 2; - vec2 d = abs(pos - vec2(right ? rect.z : rect.x, bottom ? rect.w : rect.y)); - return (d.x < 0.01 || (right && freeRight)) && (d.y < 0.01 || (!bottom && freeTop)); +// Whether a vertex lies on a corner of `rect` (left, top, right, bottom in GUI pixels). `freeRight` also accepts any x +// right of the left edge up to 17 pixels past it, and `freeTop` any y from the top edge down to the bottom edge, for +// the progress sprites whose far edge follows the charge. A quad drawn at the rect has all four vertices on corners. +bool window_fs_on_corner(vec2 pos, vec4 rect, bool freeRight, bool freeTop) { + bool onX = abs(pos.x - rect.x) < 0.01 || abs(pos.x - rect.z) < 0.01 + || (freeRight && pos.x > rect.x + 0.01 && pos.x <= rect.x + 17.0); + bool onY = abs(pos.y - rect.w) < 0.01 || abs(pos.y - rect.y) < 0.01 + || (freeTop && pos.y >= rect.y - 0.01 && pos.y < rect.w - 0.01); + return onX && onY; } // Whether a vertex belongs to a quad vanilla's Hud draws as an attack indicator, in GUI pixels from the top-left, // with the GUI size gui: the crosshair indicator's 16x4 background and progress and its 16x16 full sprite, which // start at (gui.x / 2 - 8, gui.y / 2 + 9), and the 18x18 hotbar indicator background and progress in the row // gui.y - 20, either 97 pixels right of the centre or 113 left of it, depending on the main arm. -bool window_fs_indicator_corner(vec2 pos, int corner, vec2 gui) { +bool window_fs_indicator_corner(vec2 pos, vec2 gui) { vec2 centre = floor(gui / 2.0); vec4 crosshair = vec4(centre.x - 8.0, centre.y + 9.0, centre.x + 8.0, centre.y + 13.0); vec4 full = vec4(crosshair.xy, crosshair.zy + vec2(0.0, 16.0)); vec4 hotbarRight = vec4(centre.x + 97.0, gui.y - 20.0, centre.x + 115.0, gui.y - 2.0); vec4 hotbarLeft = hotbarRight - vec4(210.0, 0.0, 210.0, 0.0); - return window_fs_quad_corner(pos, corner, crosshair, true, false) - || window_fs_quad_corner(pos, corner, full, false, false) - || window_fs_quad_corner(pos, corner, hotbarRight, false, true) - || window_fs_quad_corner(pos, corner, hotbarLeft, false, true); + return window_fs_on_corner(pos, crosshair, true, false) + || window_fs_on_corner(pos, full, false, false) + || window_fs_on_corner(pos, hotbarRight, false, true) + || window_fs_on_corner(pos, hotbarLeft, false, true); } void main() { @@ -167,10 +168,11 @@ void main() { windowHudHidden = GameTime < 0.0 && ProjMat[3][3] == 1.0 ? 1 : 0; windowHudPos = vec2(gl_Position.x / ProjMat[0][0], (gl_Position.y + 1.0) / -ProjMat[1][1]); - // Only a quad whose four vertices all match reaches 1.0 across its fragments. - vec2 gui = vec2(2.0 / ProjMat[0][0], -2.0 / ProjMat[1][1]); + // Only a quad whose four vertices all match reaches 1.0 across its fragments. The GUI size is the client's: the + // fractional framebuffer size over the scale, rounded up. + vec2 gui = ceil(vec2(2.0 / ProjMat[0][0], -2.0 / ProjMat[1][1]) - 1.0e-3); vec2 guiPos = vec2((gl_Position.x + 1.0) / ProjMat[0][0], (gl_Position.y - 1.0) / ProjMat[1][1]); - windowHudIndicator = windowHudHidden == 1 && window_fs_indicator_corner(guiPos, gl_VertexID % 4, floor(gui + 0.5)) ? 1.0 : 0.0; + windowHudIndicator = windowHudHidden == 1 && window_fs_indicator_corner(guiPos, gui) ? 1.0 : 0.0; window_prompt_clip(gl_Position); } @@ -211,7 +213,9 @@ void main() {{{prompt_clip} if (windowHudHidden == 1 && abs(windowHudPos.x) < WINDOW_FS_HUD_STRIP.x && windowHudPos.y < WINDOW_FS_HUD_STRIP.y) {{ discard; }} - if (windowHudIndicator > 0.99) {{ + // Fragment centres sit at least half a framebuffer pixel from an edge, so a quad with unmatched vertices stays + // below 1 - 0.5 / height in pixels, well under this threshold. + if (windowHudIndicator > 0.99999) {{ discard; }} vec4 texel = texture(Sampler0, texCoord0); diff --git a/crates/window-core/src/hud/tests.rs b/crates/window-core/src/hud/tests.rs index 2fcb47e3..260841d1 100644 --- a/crates/window-core/src/hud/tests.rs +++ b/crates/window-core/src/hud/tests.rs @@ -149,11 +149,13 @@ fn fullscreen_branch_joins_the_text_shader_and_hides_the_hud_strip() { ["text.vsh", "text.fsh", "position_tex_color.vsh", "position_tex_color.fsh", "gui.vsh", "gui.fsh"] ); assert!(alone.files[0].contents.contains("void window_fs_apply()")); + assert!(!alone.files[2].contents.contains("gl_VertexID")); + assert!(alone.files[2].contents.contains("ceil(vec2(2.0 / ProjMat[0][0]")); let fragment = &alone.files[3].contents; assert!(fragment.contains("const vec2 WINDOW_FS_HUD_STRIP = vec2(96.0, 56.0);"), "{fragment}"); let indicator = &alone.files[2].contents; assert!(indicator.contains("windowHudHidden == 1 && window_fs_indicator_corner("), "{indicator}"); - assert!(fragment.contains("if (windowHudIndicator > 0.99)"), "{fragment}"); + assert!(fragment.contains("if (windowHudIndicator > 0.99999)"), "{fragment}"); assert!(alone.files[2].contents.contains("float GameTime;\n int MenuBlurRadius;\n int UseRgss;\n};")); // While the prompt's anvil is open, GUI text is hidden only in its field and label bands, and fills only in the diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index ea547344..426c8b15 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -1,8 +1,8 @@ b902022fd657ad3b7b7cd44391210c7c6f2d5086bebba022fecbbee2a951df3f dist/assets/minecraft/shaders/core/entity.vsh d56563e462372062c7a8e7a417b4a1b107f0d291ed561b6d74c6a3c0eb8ffa71 dist/assets/minecraft/shaders/core/gui.fsh de99b339c5bc4b28a85d9fe06066c528fa7c10497037c51dca18652360f14aa3 dist/assets/minecraft/shaders/core/gui.vsh -54ee52c369b29918df2bd52340ebcc95a934e39f7124679588b945fa812f1bfd dist/assets/minecraft/shaders/core/position_tex_color.fsh -cfd5164f1b9d52346376f6cabdd63439d26293a2cc4a7c51c05777c691576a54 dist/assets/minecraft/shaders/core/position_tex_color.vsh +2d810695e59874e65245c4babc51236277444d40537ac83421c86d11201096e2 dist/assets/minecraft/shaders/core/position_tex_color.fsh +5ef85b3b3978c2eb3d9b0202823a2eeb9d8427b3e382a01bb7f209f2aee0d0bb dist/assets/minecraft/shaders/core/position_tex_color.vsh 56bb409c4026de938f0a2f77bd3ac9131658aad33facbb9b92145e9095ba0fdd dist/assets/minecraft/shaders/core/text.fsh f6a980f98a5850b6530119b72ef064d6e3365f2176c2ba9c50b7e3ce6524173b dist/assets/minecraft/shaders/core/text.vsh d8c8dc63512f18a6f85b86ce57d473cc2b2b76453ba1c0fd3ba97b47bbb5b26f dist/assets/minecraft/shaders/include/window_fullscreen.glsl @@ -35,7 +35,7 @@ a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/wi 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_220_160_41x16_0_0.png 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_256_160_41x16_0_0.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png -755d7ae7164c3f660fb540ecdec2740998812f762248653c5a56da77b3dd0b61 dist/assets/window/window/debug.json +21afa6698fb93bdd037a674d26fdfe6a1e12c46a195522a19e7e2784b40d5f5f dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta 23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt c7112fbdeac4c678c3fdc1eacdd4fbccedbc28c3b0ff8baf44ef5af2d1d104a2 kotlin-out/MenuFullscreen.kt From ebccfef20725af2d35971529d90a979f24315e3f Mon Sep 17 00:00:00 2001 From: oglassdev <23040613+oglassdev@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:07:50 -0400 Subject: [PATCH 6/6] fix(runtime): keep each attack indicator rect's match separate --- .../src/hud/renderer/fullscreen.rs | 49 +++++++++++-------- crates/window-core/src/hud/tests.rs | 9 +++- plugin/test/golden/fullscreen/expected.sha256 | 6 +-- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/crates/window-core/src/hud/renderer/fullscreen.rs b/crates/window-core/src/hud/renderer/fullscreen.rs index 1aa7a1b3..0b464526 100644 --- a/crates/window-core/src/hud/renderer/fullscreen.rs +++ b/crates/window-core/src/hud/renderer/fullscreen.rs @@ -128,7 +128,9 @@ out vec2 texCoord0; out vec4 vertexColor; out vec2 windowHudPos; flat out int windowHudHidden; -out float windowHudIndicator; +out vec4 windowHudIndicator; +out vec2 windowHudGui; +flat out vec4 windowHudRects[4]; @PROMPT@ // Whether a vertex lies on a corner of `rect` (left, top, right, bottom in GUI pixels). `freeRight` also accepts any x // right of the left edge up to 17 pixels past it, and `freeTop` any y from the top edge down to the bottom edge, for @@ -141,20 +143,22 @@ bool window_fs_on_corner(vec2 pos, vec4 rect, bool freeRight, bool freeTop) { return onX && onY; } -// Whether a vertex belongs to a quad vanilla's Hud draws as an attack indicator, in GUI pixels from the top-left, -// with the GUI size gui: the crosshair indicator's 16x4 background and progress and its 16x16 full sprite, which -// start at (gui.x / 2 - 8, gui.y / 2 + 9), and the 18x18 hotbar indicator background and progress in the row -// gui.y - 20, either 97 pixels right of the centre or 113 left of it, depending on the main arm. -bool window_fs_indicator_corner(vec2 pos, vec2 gui) { +// Sets, per attack indicator rect, whether a vertex lies on one of its corners (windowHudIndicator) and the rect's +// maximal extent (windowHudRects), in GUI pixels from the top-left with the GUI size gui: the crosshair indicator's 16x4 +// background and progress and its 16x16 full sprite, which start at (gui.x / 2 - 8, gui.y / 2 + 9), and the 18x18 +// hotbar indicator background and progress in the row gui.y - 20, either 97 pixels right of the centre or 113 left of +// it, depending on the main arm. +void window_fs_indicator(vec2 pos, vec2 gui, bool hidden) { vec2 centre = floor(gui / 2.0); vec4 crosshair = vec4(centre.x - 8.0, centre.y + 9.0, centre.x + 8.0, centre.y + 13.0); vec4 full = vec4(crosshair.xy, crosshair.zy + vec2(0.0, 16.0)); vec4 hotbarRight = vec4(centre.x + 97.0, gui.y - 20.0, centre.x + 115.0, gui.y - 2.0); vec4 hotbarLeft = hotbarRight - vec4(210.0, 0.0, 210.0, 0.0); - return window_fs_on_corner(pos, crosshair, true, false) - || window_fs_on_corner(pos, full, false, false) - || window_fs_on_corner(pos, hotbarRight, false, true) - || window_fs_on_corner(pos, hotbarLeft, false, true); + windowHudRects = vec4[4](crosshair + vec4(0.0, 0.0, 1.0, 0.0), full, hotbarRight, hotbarLeft); + windowHudIndicator = hidden ? vec4( + window_fs_on_corner(pos, crosshair, true, false), window_fs_on_corner(pos, full, false, false), + window_fs_on_corner(pos, hotbarRight, false, true), window_fs_on_corner(pos, hotbarLeft, false, true)) + : vec4(0.0); } void main() { @@ -168,11 +172,13 @@ void main() { windowHudHidden = GameTime < 0.0 && ProjMat[3][3] == 1.0 ? 1 : 0; windowHudPos = vec2(gl_Position.x / ProjMat[0][0], (gl_Position.y + 1.0) / -ProjMat[1][1]); - // Only a quad whose four vertices all match reaches 1.0 across its fragments. The GUI size is the client's: the - // fractional framebuffer size over the scale, rounded up. - vec2 gui = ceil(vec2(2.0 / ProjMat[0][0], -2.0 / ProjMat[1][1]) - 1.0e-3); - vec2 guiPos = vec2((gl_Position.x + 1.0) / ProjMat[0][0], (gl_Position.y - 1.0) / ProjMat[1][1]); - windowHudIndicator = windowHudHidden == 1 && window_fs_indicator_corner(guiPos, gui) ? 1.0 : 0.0; + // Three matching corners of an axis-aligned quad fix its fourth, so a rect's flag reaches 1.0 across a quad only if + // it is that rect, and the fragment's bounds check confines any sliver of a partial match to the rect itself. The + // GUI size is the client's: the fractional framebuffer size over the scale, rounded up. Fractional sizes are + // multiples of 1 / scale, so 0.01 absorbs float error without rounding a true fraction down. + vec2 gui = ceil(vec2(2.0 / ProjMat[0][0], -2.0 / ProjMat[1][1]) - 0.01); + windowHudGui = vec2((gl_Position.x + 1.0) / ProjMat[0][0], (gl_Position.y - 1.0) / ProjMat[1][1]); + window_fs_indicator(windowHudGui, gui, windowHudHidden == 1); window_prompt_clip(gl_Position); } @@ -205,7 +211,9 @@ in vec2 texCoord0; in vec4 vertexColor; in vec2 windowHudPos; flat in int windowHudHidden; -in float windowHudIndicator; +in vec4 windowHudIndicator; +in vec2 windowHudGui; +flat in vec4 windowHudRects[4]; {prompt_helpers} out vec4 fragColor; @@ -213,10 +221,11 @@ void main() {{{prompt_clip} if (windowHudHidden == 1 && abs(windowHudPos.x) < WINDOW_FS_HUD_STRIP.x && windowHudPos.y < WINDOW_FS_HUD_STRIP.y) {{ discard; }} - // Fragment centres sit at least half a framebuffer pixel from an edge, so a quad with unmatched vertices stays - // below 1 - 0.5 / height in pixels, well under this threshold. - if (windowHudIndicator > 0.99999) {{ - discard; + for (int i = 0; i < 4; i++) {{ + if (windowHudIndicator[i] > 0.99999 && all(greaterThanEqual(windowHudGui, windowHudRects[i].xy)) + && all(lessThanEqual(windowHudGui, windowHudRects[i].zw))) {{ + discard; + }} }} vec4 texel = texture(Sampler0, texCoord0); if (windowHudHidden == 1 && int(round(texel.a * 255.0)) == WINDOW_FS_CROSSHAIR_ALPHA) {{ diff --git a/crates/window-core/src/hud/tests.rs b/crates/window-core/src/hud/tests.rs index 260841d1..2ccae680 100644 --- a/crates/window-core/src/hud/tests.rs +++ b/crates/window-core/src/hud/tests.rs @@ -154,8 +154,13 @@ fn fullscreen_branch_joins_the_text_shader_and_hides_the_hud_strip() { let fragment = &alone.files[3].contents; assert!(fragment.contains("const vec2 WINDOW_FS_HUD_STRIP = vec2(96.0, 56.0);"), "{fragment}"); let indicator = &alone.files[2].contents; - assert!(indicator.contains("windowHudHidden == 1 && window_fs_indicator_corner("), "{indicator}"); - assert!(fragment.contains("if (windowHudIndicator > 0.99999)"), "{fragment}"); + assert!(indicator.contains("window_fs_indicator(windowHudGui, gui, windowHudHidden == 1);"), "{indicator}"); + assert!( + fragment + .contains("windowHudIndicator[i] > 0.99999 && all(greaterThanEqual(windowHudGui, windowHudRects[i].xy))"), + "{fragment}" + ); + assert!(indicator.contains("- 0.01);"), "{indicator}"); assert!(alone.files[2].contents.contains("float GameTime;\n int MenuBlurRadius;\n int UseRgss;\n};")); // While the prompt's anvil is open, GUI text is hidden only in its field and label bands, and fills only in the diff --git a/plugin/test/golden/fullscreen/expected.sha256 b/plugin/test/golden/fullscreen/expected.sha256 index 426c8b15..1d6d4849 100644 --- a/plugin/test/golden/fullscreen/expected.sha256 +++ b/plugin/test/golden/fullscreen/expected.sha256 @@ -1,8 +1,8 @@ b902022fd657ad3b7b7cd44391210c7c6f2d5086bebba022fecbbee2a951df3f dist/assets/minecraft/shaders/core/entity.vsh d56563e462372062c7a8e7a417b4a1b107f0d291ed561b6d74c6a3c0eb8ffa71 dist/assets/minecraft/shaders/core/gui.fsh de99b339c5bc4b28a85d9fe06066c528fa7c10497037c51dca18652360f14aa3 dist/assets/minecraft/shaders/core/gui.vsh -2d810695e59874e65245c4babc51236277444d40537ac83421c86d11201096e2 dist/assets/minecraft/shaders/core/position_tex_color.fsh -5ef85b3b3978c2eb3d9b0202823a2eeb9d8427b3e382a01bb7f209f2aee0d0bb dist/assets/minecraft/shaders/core/position_tex_color.vsh +96913f57883c88fa694c0ba08eeeda4b9d8973313fa1adf4326c58cadab77aa3 dist/assets/minecraft/shaders/core/position_tex_color.fsh +fce31a4ee7abc60a5676275134fba3200604442b2e9031cdaa4cedc8d4c4d2c4 dist/assets/minecraft/shaders/core/position_tex_color.vsh 56bb409c4026de938f0a2f77bd3ac9131658aad33facbb9b92145e9095ba0fdd dist/assets/minecraft/shaders/core/text.fsh f6a980f98a5850b6530119b72ef064d6e3365f2176c2ba9c50b7e3ce6524173b dist/assets/minecraft/shaders/core/text.vsh d8c8dc63512f18a6f85b86ce57d473cc2b2b76453ba1c0fd3ba97b47bbb5b26f dist/assets/minecraft/shaders/include/window_fullscreen.glsl @@ -35,7 +35,7 @@ a962b9aee37baf82e29390badf062f445b50a8e577de3731aa0947fcdbbd9222 dist/assets/wi 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_220_160_41x16_0_0.png 922d405eb87e7d345085f0d93e32a111d1e4bf6ddcc4102f0b1bb0e754ce06be dist/assets/window/textures/font/fullscreen/window/menu_tooltip_256_160_41x16_0_0.png eacb1a012fc0820bb358ea06380857fd97d62a420932142014ac89bcc4afbbc3 dist/assets/window/textures/gui/hitbox.png -21afa6698fb93bdd037a674d26fdfe6a1e12c46a195522a19e7e2784b40d5f5f dist/assets/window/window/debug.json +1de63867dbef29c521d76f06c2855db8fd50fc1c5d5d302223d4ee3b58022fe2 dist/assets/window/window/debug.json 65dd34ebb2d97b0c2f9ba3e310bc02fe652f4690a069e47d8b3aa99f76a94bfc dist/pack.mcmeta 23bf284dd31ac909286bdae4abd75e24f127fe9c720c9bd0cac4872c6f130a46 kotlin-out/LookoutFullscreen.kt c7112fbdeac4c678c3fdc1eacdd4fbccedbc28c3b0ff8baf44ef5af2d1d104a2 kotlin-out/MenuFullscreen.kt