HTTPS
https://git.nageyqma.icu/nageyqma/jeanaddons.git
SSH
git@git.nageyqma.icu:nageyqma/jeanaddons.git
HTTPS 链接可直接 git clone;推送代码请使用 SSH。
仅展示默认分支 main 最近 100 条提交。
| fix(skyblock): strip § codes from menu titles in middle-click override Hypixel sends some menu titles as §-coded legacy strings, which
Component#getString keeps verbatim, so the whole-string blacklist/
whitelist never matched and the override never rewrote left clicks.
Titles are now stripped with toPlain before matching (a no-op on
clean titles). The debug toggle now prints one line per evaluated
left click with the raw/stripped title and every gate's value.
Also restore the Gradle wrapper to 9.7.1: the previous commit pinned
7.4.2, which cannot run this build on the Java 26 toolchain. 查看变更README.md | 2 +- conf/jeanaddons.conf | 5 ++++- gradle/wrapper/gradle-wrapper.properties | 3 +-- src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickOverride.kt | 49 +++++++++++++++++++++++++++++++++---------------- src/main/resources/assets/jeanaddons/default.conf | 5 ++++- 5 files changed, 43 insertions(+), 21 deletions(-) @@ -30,7 +30,7 @@ input, movement, or gameplay behavior. | `command-cooldown-ticks` | Skyblock | `60` | Minimum interval between two command-keybind sends, counted in game ticks (20 = 1 second). One global value, because what it rate-limits is the account's overall command rate. While on cooldown the press is blocked and the remaining seconds are shown in local chat. 0 disables. | | `middle-click-fix` | Skyblock | `true` | Lets the middle mouse button send its container click in survival mode, exactly like the 1.8.9 client did. Modern vanilla gates the middle click's `CLONE` path behind creative-only `hasInfiniteMaterials()`, so in survival it sends nothing; this restores the original behavior everywhere — the feature carries no Skyblock gating of its own. | | `middle-click-override` | Skyblock | `true` | In Skyblock menus whose intended action is the middle click (Pets, Bits Shop, shops with `Cost`/`Sell Price`/`Bazaar Price` lore, …), a plain left click is rewritten to that middle click (`CLONE`, button 2), so items never get picked up onto the cursor under latency. Blacklisted menus (chests, storage, anvils, auctions, …) keep vanilla behavior; inactive outside Skyblock. | -| `middle-click-override-debug` | Skyblock | `false` | Prints one local chat line for every click the override rewrites: which slot, what the original click was. | +| `middle-click-override-debug` | Skyblock | `false` | Prints one local chat line for every left click the override evaluates in a container screen: the menu title as received and stripped of `§` codes, every check's value (blacklist/whitelist/Skyblock/id/lore), and the rewrite decision. | **Known limitation (remove-text-shadow):** mods that render text with their own font engine (for example NanoVG-based HUDs, like Odin's custom HUD) @@ -101,7 +101,10 @@ middle-click-fix = true middle-click-override = true # Feature: Middle Click Override Debug -# Prints one local chat line for every click the override rewrites. +# Prints one local chat line for every left click the override +# evaluates in a container screen: the menu title as received and +# stripped of formatting codes, every check's value, and whether +# the click gets rewritten. # Valid values: true | false middle-click-override-debug = false @@ -1,7 +1,6 @@ -#Tue Sep 15 18:48:05 HKT 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME @@ -22,7 +22,10 @@ import org.lwjgl.glfw.GLFW * Nothing here constructs a container-click packet or holds a menu/screen * reference past a UI change. * - * Ported from NoFrills' `MiddleClickOverride`, title lists verbatim. + * Ported from NoFrills' `MiddleClickOverride`, title lists verbatim. The one + * deviation: menu titles are stripped of legacy `§` codes before the lists + * are matched, so §-coded legacy titles Hypixel still sends match their + * visible text (see [shouldOverride]). */ object MiddleClickOverride : Feature( id = "middle-click-override", @@ -98,6 +101,13 @@ object MiddleClickOverride : Feature( * 3. otherwise override when the item has no Skyblock id, the title is * whitelisted, or the lore marks a transaction (`Cost` / `Sell Price` / * `Bazaar Price`). + * + * One deliberate deviation from NoFrills: the title is stripped of legacy + * `§` formatting codes before the lists are matched. Hypixel sends some + * titles as §-coded legacy strings, which `Component#getString` keeps + * verbatim ("§aPets"), and a whole-string match against "Pets" then never + * fires — disabling the override on exactly the menus it exists for. On + * clean titles the strip is a no-op. */ fun shouldOverride(slot: Slot?, button: Int, actionType: ContainerInput): Boolean { if (!enabled) return false @@ -105,21 +115,27 @@ object MiddleClickOverride : Feature( if (slot == null) return false if (button != GLFW.GLFW_MOUSE_BUTTON_LEFT || actionType != ContainerInput.PICKUP) return false - val title = screen.title.string + val rawTitle = screen.title.string + val title = SkyblockUtils.toPlain(rawTitle) val stack = slot.item - if (stack.isEmpty || isBlacklisted(title) || !SkyblockUtils.isInSkyblock() || isInLoadoutEdit(screen)) { - return false - } + val blacklisted = isBlacklisted(title) + val inSkyblock = SkyblockUtils.isInSkyblock() + val loadoutEdit = isInLoadoutEdit(screen) + val skyblockId = SkyblockUtils.getSkyblockId(stack) + val whitelisted = isWhitelisted(title) + val transaction = isTransaction(stack) + val override = + !(stack.isEmpty || blacklisted || !inSkyblock || loadoutEdit) && + (skyblockId.isEmpty() || whitelisted || transaction) - if (SkyblockUtils.getSkyblockId(stack).isEmpty() || isWhitelisted(title) || isTransaction(stack)) { - if (MiddleClickOverrideDebug.enabled) { - SkyblockUtils.localNotice( - "Middle click override: slot ${slot.index}, replaced left PICKUP click with CLONE.", - ) - } - return true + if (MiddleClickOverrideDebug.enabled) { + SkyblockUtils.localNotice( + "MCO: raw='$rawTitle' title='$title' sb=$inSkyblock black=$blacklisted white=$whitelisted " + + "id=${if (skyblockId.isEmpty()) "none" else "present"} lore=$transaction " + + "empty=${stack.isEmpty} loadout=$loadoutEdit -> ${if (override) "override" else "vanilla"}", + ) } - return false + return override } private fun isBlacklisted(title: String): Boolean = @@ -140,12 +156,13 @@ object MiddleClickOverride : Feature( } /** - * Middle Click Override Debug: prints one local chat line for every click - * the override rewrites (slot, original button, original action type). + * Middle Click Override Debug: prints one local chat line for every left + * click the override evaluates in a container screen — the raw and stripped + * menu title, each gate's value, and the rewrite decision. */ object MiddleClickOverrideDebug : Feature( id = "middle-click-override-debug", category = FeatureCategory.SKYBLOCK, - description = "Prints one local chat line for every click the override rewrites.", + description = "Prints one local chat line for every left click the override evaluates, with its decision.", defaultEnabled = false, ) @@ -101,7 +101,10 @@ middle-click-fix = true middle-click-override = true # Feature: Middle Click Override Debug -# Prints one local chat line for every click the override rewrites. +# Prints one local chat line for every left click the override +# evaluates in a container screen: the menu title as received and +# stripped of formatting codes, every check's value, and whether +# the click gets rewritten. # Valid values: true | false middle-click-override-debug = false |
nvim auto fix查看变更gradle/wrapper/gradle-wrapper.properties | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) @@ -1,6 +1,7 @@ +#Tue Sep 15 18:48:05 HKT 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME |
function add:refill,commandkeybind,middleclickfix查看变更README.md | 55 +++++++++++++++++++++++---- conf/jeanaddons.conf | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- gradle.properties | 2 +- src/main/java/dev/chatfatigue/mixin/AbstractContainerScreenMixin.java | 81 +++++++++++++++++++++++++++++++++++++++ src/main/java/dev/chatfatigue/mixin/KeyboardHandlerMixin.java | 34 +++++++++++++++++ src/main/java/dev/chatfatigue/mixin/MouseHandlerMixin.java | 32 ++++++++++++++++ src/main/kotlin/dev/chatfatigue/JeanAddonsMod.kt | 12 ++++-- src/main/kotlin/dev/chatfatigue/command/JeanAddonsCommand.kt | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt | 27 +++++++++++++ src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt | 3 ++ src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt | 18 +++++++++ src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/CommandCooldownTicks.kt | 20 ++++++++++ src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/CommandKeybinds.kt | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/KeybindRegistry.kt | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickFix.kt | 22 +++++++++++ src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickOverride.kt | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/skyblock/SkyblockUtils.kt | 90 +++++++++++++++++++++++++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/skyblock/TickCooldown.kt | 51 +++++++++++++++++++++++++ src/main/resources/assets/jeanaddons/default.conf | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/main/resources/fabric.mod.json | 2 +- src/main/resources/jeanaddons.mixins.json | 5 ++- 21 files changed, 1243 insertions(+), 15 deletions(-) @@ -1,14 +1,20 @@ # JeanAddons A lightweight, personal-use **client-side** [Fabric](https://fabricmc.net/) mod for -Minecraft **26.1.2**: a small collection of rendering tweaks and chat-screen -conveniences. +Minecraft **26.1.2**: rendering tweaks, chat-screen conveniences, and Hypixel +Skyblock QoL. **Core contract — client-side only.** Every feature is a purely client-side -change: cosmetic rendering tweaks plus conveniences inside the chat screen. -The mod **never** alters any packet the client sends, nor any input outside -the chat screen, movement, or gameplay behavior, and it never loads on a -dedicated server (`"environment": "client"`). +change: cosmetic rendering tweaks, conveniences inside the chat screen, and +Skyblock conveniences that reproduce vanilla-client behavior. The mod is +still strictly client-side (`"environment": "client"`, never loads on a +dedicated server) and produces outbound traffic in exactly two deliberate, +Hypixel-allowed places: (a) it sends user-configured chat commands through +the vanilla `sendCommand` path, always under a game-tick cooldown, and (b) it +rewrites the user's real click on the currently open screen into the +1.8.9-equivalent middle-click form before vanilla builds the packet. Outside +those two patterns the mod never alters any packet the client sends, nor any +input, movement, or gameplay behavior. ## Features @@ -20,6 +26,11 @@ dedicated server (`"environment": "client"`). | `force-cape` | Visual | `true` | Always renders your own player with the bundled 2016 MineCon cape, even if your account has none; while enabled it also replaces a cape you already own. Only your own view of your own player is affected — other players keep their own capes. The vanilla guards still apply: the cape hides for invisible players, hidden cape model parts, and a worn elytra. | | `copy-chat-line` | Chat | `true` | Right-click a chat line to copy the whole message it belongs to, as plain text, to the system clipboard. A green confirmation is briefly drawn in the bottom-right corner. Vanilla ignores right-clicks in the chat screen, so nothing vanilla is shadowed. | | `chat-history-size` | Chat | `1000` | How many chat messages the client keeps in memory and can scroll back through, counting the lines a long message wraps into. Vanilla hard-caps this at 100. | +| `command-keybinds` | Skyblock | `true` | Press a bound key or mouse button (side buttons included) to send its chat command straight through the vanilla `sendCommand`/`sendChat` path — no chat screen involved. Binds only fire while no screen is open, so chat and GUI input are never touched; a matched key no longer triggers its vanilla behavior (e.g. hotbar switching). The bind list is the `bind-N-*` lines in `[Skyblock]`, migrated as-is from NoFrills (`/nofrills` commands rewritten to `/jeanaddons`). | +| `command-cooldown-ticks` | Skyblock | `60` | Minimum interval between two command-keybind sends, counted in game ticks (20 = 1 second). One global value, because what it rate-limits is the account's overall command rate. While on cooldown the press is blocked and the remaining seconds are shown in local chat. 0 disables. | +| `middle-click-fix` | Skyblock | `true` | Lets the middle mouse button send its container click in survival mode, exactly like the 1.8.9 client did. Modern vanilla gates the middle click's `CLONE` path behind creative-only `hasInfiniteMaterials()`, so in survival it sends nothing; this restores the original behavior everywhere — the feature carries no Skyblock gating of its own. | +| `middle-click-override` | Skyblock | `true` | In Skyblock menus whose intended action is the middle click (Pets, Bits Shop, shops with `Cost`/`Sell Price`/`Bazaar Price` lore, …), a plain left click is rewritten to that middle click (`CLONE`, button 2), so items never get picked up onto the cursor under latency. Blacklisted menus (chests, storage, anvils, auctions, …) keep vanilla behavior; inactive outside Skyblock. | +| `middle-click-override-debug` | Skyblock | `false` | Prints one local chat line for every click the override rewrites: which slot, what the original click was. | **Known limitation (remove-text-shadow):** mods that render text with their own font engine (for example NanoVG-based HUDs, like Odin's custom HUD) @@ -49,6 +60,30 @@ declares a 64x64 layer with `texScaleV` 0.5, which makes its effective UV space 64x32 — exactly the classic (1.8.9-era) cape format, so the file needs no re-layout. +**Notes (Skyblock features):** the keybind, refill, and middle-click +features are ported from [NoFrills](https://github.com/WhatYouThing/NoFrills). +What makes them safe on Hypixel is that they only reproduce vanilla-client +behavior, never invent it: keybind commands go through the vanilla +`sendCommand` path — the same thing typing the command does; the middle +clicks produce exactly the `CLONE` (button 2) container packet a 1.8.9 client +sent unconditionally; and the override only rewrites the user's own real +click on the *currently open* screen, so `containerId`, `stateId`, carried +item, and changed slots all come from vanilla's own `slotClicked → +handleContainerInput` chain — byte-identical to a 1.8.9 player pressing +middle click. There is no auto-clicking anywhere in the mod; nothing ever +touches a UI the server has replaced (Skyblock covers old container UIs with +new ones instead of closing them — interacting with a stale one is what gets +accounts banned), no menu/screen reference outlives an UI change, and no +`ServerboundContainerClickPacket` is ever constructed by hand. All automated +command sends are rate-limited in game ticks to match the server's per-tick +processing: one shared configurable cooldown for keybind sends +(`command-cooldown-ticks`, default 60 = 3 s) and a hard-coded 60-tick +cooldown on the `/gfs` refill send — a keybind running `/jeanaddons refill` +passes through both, each guarding its own packets. The command itself is +`/jeanaddons refill <item> [amount]` (alias `/ja`), topping an item up from +your sacks via `/gfs` (default amount 64), matching items by Skyblock id or +plain item name with underscores read as spaces. + ## Directory layout | Path | Purpose | @@ -56,7 +91,8 @@ no re-layout. | `src/` | Sources and resources, standard Gradle layout. Kotlin (`src/main/kotlin`) holds game logic; Java (`src/main/java`) holds mixin classes. | | `bin/` | Binary output. `copyJarToBin` copies the built mod jar here on every `build`. Disposable: git-ignored, safe to delete, refreshed by the next build. | | `conf/` | Human-readable review copy of the default config. `syncDefaultConf` re-copies `src/main/resources/assets/jeanaddons/default.conf` to `conf/jeanaddons.conf` on every `build`, so the review copy can never drift from the template that ships in the jar. The jar template stays the single source of truth. | -| `reference/` | Read-only reference projects ([Odin](https://github.com/odtheking/odin) for the Gradle/mixin setup, [chat-utils](https://github.com/tierrif/chat-utils) for the chat features' approach). Not part of the build; git-ignored; never modified. | +| `reference/` | Read-only reference projects ([Odin](https://github.com/odtheking/odin) for the Gradle/mixin setup, [chat-utils](https://github.com/tierrif/chat-utils) for the chat features' approach, [NoFrills](https://github.com/WhatYouThing/NoFrills) for this round's three Skyblock features). Not part of the build; git-ignored; never modified. | +| `reference/nofrills_conf/` | The user's real NoFrills configuration, the source the migrated `[Skyblock]` keybind defaults were taken from verbatim. Read-only like the rest of `reference/`. | | `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`, `gradle/wrapper/` | Gradle build. All identity values (mod id, name, version, group) and pinned dependency versions live in `gradle.properties`. | ## Building and running @@ -105,6 +141,11 @@ Three steps: `src/main/resources/assets/jeanaddons/default.conf` (under the right `[Section]`, with its valid values documented). +The command keybinds are the one structured entry: each bind is four flat +`bind-N-*` lines in `[Skyblock]`, parsed by `KeybindRegistry` — still the +same two steps of a commented block in `default.conf` plus one registry, just +a registry of binds instead of features. + ## Toolchain | Component | Version | @@ -1,5 +1,5 @@ # ============================================================= -# JeanAddons - client-side rendering and chat conveniences +# JeanAddons - client-side rendering, chat and Skyblock conveniences # ------------------------------------------------------------- # This file was generated automatically on first launch and is # safe to edit by hand. Restart the game to apply changes. @@ -70,3 +70,154 @@ copy-chat-line = true # at the cost of some memory. # Valid values: whole numbers from 100 to 10000 chat-history-size = 1000 + +[Skyblock] + +# Feature: Command Keybinds +# Press a key (or mouse button) to send a chat command. Binds only +# fire while no screen is open. Sending is rate-limited by the +# cooldown below. Keybinds are the bind-N lines further down. +# Valid values: true | false +command-keybinds = true + +# Option: Command Cooldown (ticks) +# Minimum interval between two command keybind sends, counted in +# game ticks (20 ticks = 1 second). While on cooldown the press is +# blocked and the remaining time is shown in chat. 0 disables. +# Valid values: whole numbers from 0 to 1200 +command-cooldown-ticks = 60 + +# Feature: Middle Click Fix +# Lets the middle mouse button send its container click in survival, +# exactly like 1.8.9 did. Modern vanilla only sends it in creative. +# Valid values: true | false +middle-click-fix = true + +# Feature: Middle Click Override +# In Skyblock menus where middle-click is the intended action, +# replaces plain left clicks with the 1.8.9-style middle click, so +# items never get picked up onto the cursor under latency. +# Valid values: true | false +middle-click-override = true + +# Feature: Middle Click Override Debug +# Prints one local chat line for every click the override rewrites. +# Valid values: true | false +middle-click-override-debug = false + +# ---- Command keybinds -------------------------------------------- +# Each keybind is four lines: +# bind-N-key = GLFW key code (e.g. 77 = M), or a mouse +# button code (e.g. 3 / 4 = side buttons) +# bind-N-modifier = none | any | shift | ctrl | alt +# bind-N-command = command/message to send +# bind-N-enabled = true | false +# Lower N matches first; keep the order when editing. + +# Keybind 1: Pet +bind-1-key = 77 +bind-1-modifier = none +bind-1-command = /pet +bind-1-enabled = true + +# Keybind 2: farming_sethome (keypad 0) +bind-2-key = 320 +bind-2-modifier = none +bind-2-command = /sethome +bind-2-enabled = false + +# Keybind 3: farming_warpgarden (keypad .) +bind-3-key = 330 +bind-3-modifier = none +bind-3-command = /warp garden +bind-3-enabled = false + +# Keybind 4: wd_default (B) +bind-4-key = 66 +bind-4-modifier = none +bind-4-command = /wd +bind-4-enabled = false + +# Keybind 5: eq (X) +bind-5-key = 88 +bind-5-modifier = none +bind-5-command = /stats +bind-5-enabled = true + +# Keybind 6: refill_enderpearl (7) +bind-6-key = 55 +bind-6-modifier = none +bind-6-command = /odin ep +bind-6-enabled = true + +# Keybind 7: refill_superbombtnt (6) +bind-7-key = 54 +bind-7-modifier = any +bind-7-command = /jeanaddons refill SUPERBOOM_TNT 64 +bind-7-enabled = true + +# Keybind 8: hotm (H) +bind-8-key = 72 +bind-8-modifier = any +bind-8-command = /hotm +bind-8-enabled = false + +# Keybind 9: jeanaddons_quickopen (\) +bind-9-key = 92 +bind-9-modifier = any +bind-9-command = /jeanaddons +bind-9-enabled = false + +# Keybind 10: SSRS (;) +bind-10-key = 59 +bind-10-modifier = any +bind-10-command = /pc SSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOT +bind-10-enabled = true + +# Keybind 11: pot (0) +bind-11-key = 48 +bind-11-modifier = any +bind-11-command = /potionbag +bind-11-enabled = true + +# Keybind 12: refill_twilight_64 (8) +bind-12-key = 56 +bind-12-modifier = none +bind-12-command = /jeanaddons refill TWILIGHT_ARROW_POISON 64 +bind-12-enabled = true + +# Keybind 13: refill_toxic_1 (mouse side button 4) +bind-13-key = 4 +bind-13-modifier = any +bind-13-command = /gfs TOXIC_ARROW_POISON 1 +bind-13-enabled = true + +# Keybind 14: refill_toxic_64 (9) +bind-14-key = 57 +bind-14-modifier = none +bind-14-command = /jeanaddons refill TOXIC_ARROW_POISON 64 +bind-14-enabled = true + +# Keybind 15: wd_swaphaba (mouse side button 4; shadowed by 13) +bind-15-key = 4 +bind-15-modifier = any +bind-15-command = /wd +bind-15-enabled = false + +# Keybind 16: tlightarrow4 (mouse side button 3) +bind-16-key = 3 +bind-16-modifier = none +bind-16-command = /gfs TWILIGHT_ARROW_POISON 1 +bind-16-enabled = true + +# Keybind 17: loadout (B) +bind-17-key = 66 +bind-17-modifier = any +bind-17-command = /loadout +bind-17-enabled = true + +# Keybind 18: loadout_i4 (D) +bind-18-key = 68 +bind-18-modifier = any +bind-18-command = /loadout +bind-18-enabled = false @@ -13,6 +13,6 @@ kotlin_version=2.4.20 # Mod identity mod_id=jeanaddons mod_name=JeanAddons -mod_version=0.3.0 +mod_version=0.4.0 maven_group=dev.chatfatigue archives_base_name=jeanaddons @@ -0,0 +1,81 @@ +package dev.chatfatigue.mixin; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; + +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.inventory.Slot; + +import org.lwjgl.glfw.GLFW; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; + +import dev.chatfatigue.feature.FeatureAccess; + +/** + * Both middle-click features live on the live screen's own click path, so + * everything the server validates — containerId, stateId, carried item, + * changed slots — is produced by vanilla's own + * {@code slotClicked -> handleContainerInput} chain; neither feature ever + * constructs a container-click packet, holds a menu/screen reference across + * a UI change, or clicks anything on its own. + * + * <ul> + * <li><b>Middle Click Fix</b>: the one {@code hasInfiniteMaterials()} call + * inside {@code mouseClicked} gates the middle button's 1.8.9-era + * {@code CLONE} click behind creative mode; when enabled it answers + * {@code true} there, restoring that click for survival.</li> + * <li><b>Middle Click Override</b>: the second {@code slotClicked} call + * inside {@code mouseClicked} (ordinal 1 — the empty-hand PICKUP/QUICK_MOVE/ + * THROW branch, so it never fires while dragging or carrying) is rewritten + * to a middle-button {@code CLONE} click when the feature's menu lists say + * so. The {@code doubleclick} flag is cleared after a rewrite so the next + * click can't trigger vanilla's PICKUP_ALL collect.</li> + * </ul> + */ +@Mixin(AbstractContainerScreen.class) +public abstract class AbstractContainerScreenMixin { + + @Shadow + protected abstract void slotClicked(Slot slot, int slotId, int button, ContainerInput actionType); + + @Shadow + private boolean doubleclick; + + @ModifyExpressionValue( + method = "mouseClicked(Lnet/minecraft/client/input/MouseButtonEvent;Z)Z", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/player/LocalPlayer;hasInfiniteMaterials()Z" + ) + ) + private boolean jeanaddons$middleClickFix(boolean original) { + if (FeatureAccess.isMiddleClickFixEnabled()) { + return true; + } + return original; + } + + @WrapOperation( + method = "mouseClicked(Lnet/minecraft/client/input/MouseButtonEvent;Z)Z", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/gui/screens/inventory/AbstractContainerScreen;slotClicked(Lnet/minecraft/world/inventory/Slot;IILnet/minecraft/world/inventory/ContainerInput;)V", + ordinal = 1 + ) + ) + private void jeanaddons$middleClickOverride( + AbstractContainerScreen<?> instance, Slot slot, int slotId, int button, ContainerInput actionType, + Operation<Void> original + ) { + if (FeatureAccess.shouldMiddleClickOverride(slot, button, actionType)) { + this.slotClicked(slot, slotId, GLFW.GLFW_MOUSE_BUTTON_3, ContainerInput.CLONE); + this.doubleclick = false; + } else { + original.call(instance, slot, slotId, button, actionType); + } + } +} @@ -0,0 +1,34 @@ +package dev.chatfatigue.mixin; + +import net.minecraft.client.KeyboardHandler; +import net.minecraft.client.input.KeyEvent; + +import org.lwjgl.glfw.GLFW; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import dev.chatfatigue.feature.FeatureAccess; + +/** + * Command Keybinds, keyboard half: every non-unknown key event is offered to + * the bind list before vanilla sees it; a consumed event never reaches + * vanilla's key handling, which is what keeps a bound key from also + * triggering its vanilla behavior (hotbar switching, debug keys, ...). + */ +@Mixin(KeyboardHandler.class) +public abstract class KeyboardHandlerMixin { + + @Inject( + method = "keyPress(JILnet/minecraft/client/input/KeyEvent;)V", + at = @At("HEAD"), + cancellable = true + ) + private void jeanaddons$commandKeybinds(long window, int action, KeyEvent input, CallbackInfo ci) { + if (input.key() == GLFW.GLFW_KEY_UNKNOWN) return; + if (FeatureAccess.handleCommandKeybind(input.key(), input.modifiers(), action)) { + ci.cancel(); + } + } +} @@ -0,0 +1,32 @@ +package dev.chatfatigue.mixin; + +import net.minecraft.client.MouseHandler; +import net.minecraft.client.input.MouseButtonInfo; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import dev.chatfatigue.feature.FeatureAccess; + +/** + * Command Keybinds, mouse half: mouse buttons share GLFW's numeric key space + * with keyboard keys (buttons 0-7 sit below the keyboard range), so the same + * bind list that the keyboard mixin consults also covers side buttons. A + * consumed event never reaches vanilla's mouse handling. + */ +@Mixin(MouseHandler.class) +public abstract class MouseHandlerMixin { + + @Inject( + method = "onButton(JLnet/minecraft/client/input/MouseButtonInfo;I)V", + at = @At("HEAD"), + cancellable = true + ) + private void jeanaddons$commandKeybinds(long handle, MouseButtonInfo rawButtonInfo, int action, CallbackInfo ci) { + if (FeatureAccess.handleCommandKeybind(rawButtonInfo.button(), rawButtonInfo.modifiers(), action)) { + ci.cancel(); + } + } +} @@ -1,6 +1,7 @@ package dev.chatfatigue import dev.chatfatigue.chat.CopyFeedbackHud +import dev.chatfatigue.command.JeanAddonsCommand import dev.chatfatigue.feature.FeatureManager import net.fabricmc.api.ClientModInitializer import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry @@ -11,9 +12,13 @@ import org.slf4j.LoggerFactory /** * Client entrypoint of JeanAddons, a collection of client-side rendering - * tweaks and chat-screen conveniences. The mod never alters packets, - * movement, or gameplay behavior; it changes how things are drawn and adds - * conveniences inside the chat screen. + * tweaks, chat-screen conveniences, and Hypixel Skyblock QoL. Still a pure + * client mod (`environment: client`, never loads on a dedicated server) that + * never touches movement, input, or gameplay behavior — with exactly two + * deliberate exceptions, both patterns Hypixel allows: chat commands sent + * through the vanilla `sendCommand` path under a tick cooldown, and real + * clicks on the currently open screen rewritten to their 1.8.9-equivalent + * middle-click form before vanilla builds the packet. */ object JeanAddonsMod : ClientModInitializer { @@ -25,6 +30,7 @@ object JeanAddonsMod : ClientModInitializer { override fun onInitializeClient() { FeatureManager.init() + JeanAddonsCommand.register() // The "Copied to clipboard!" confirmation must draw above the chat it annotates. HudElementRegistry.attachElementAfter( @@ -0,0 +1,106 @@ +package dev.chatfatigue.command + +import com.mojang.brigadier.Command +import com.mojang.brigadier.arguments.StringArgumentType +import dev.chatfatigue.skyblock.SkyblockUtils +import dev.chatfatigue.skyblock.TickCooldown +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback +import net.fabricmc.fabric.api.client.command.v2.ClientCommands +import net.minecraft.client.Minecraft + +/** + * The mod's client command: `/jeanaddons` (alias `/ja`), with + * `/jeanaddons refill <query…> [amount]` topping an item back up from the + * player's sacks with Hypixel's own `/gfs` command — the calculation is + * ported from NoFrills' `/nofrills refill` (`NoFrillsCommand` + + * `Utils.refillItem`). Bare `/jeanaddons` only prints a local usage line. + */ +object JeanAddonsCommand { + + /** + * Hard-coded 60-tick (3 second) cooldown on the `/gfs` send — + * deliberately not configurable: it exists to keep the mod safely inside + * Hypixel's command rate limit, not to be tuned. Independent from the + * command-keybind cooldown: each guards its own outgoing packets, so a + * keybind running `/jeanaddons refill …` passes through both. + */ + private const val REFILL_COOLDOWN_TICKS = 60 + + private val refillCooldown: TickCooldown = TickCooldown.create() + + /** The usage line, shown locally for bare `/jeanaddons` and bare `refill`. */ + private const val USAGE = "Usage: /jeanaddons refill <item> [amount] - refills the item from your sacks." + + /** Registers the command tree; called once from `onInitializeClient`. */ + fun register() { + ClientCommandRegistrationCallback.EVENT.register { dispatcher, _ -> + val refill = ClientCommands.literal("refill") + .executes { + SkyblockUtils.localNotice(USAGE) + Command.SINGLE_SUCCESS + } + .then(ClientCommands.argument("query", StringArgumentType.greedyString()).executes { context -> + // NoFrills argument split: if the token after the last + // space parses as an integer it is the amount, otherwise + // the whole string is the query and the amount is 64. + val args = StringArgumentType.getString(context, "query") + val lastSpace = args.lastIndexOf(' ') + val amount = if (lastSpace != -1) args.substring(lastSpace).trim().toIntOrNull() else null + val query = if (amount != null) args.substring(0, lastSpace) else args + refillItem(query, amount ?: 64) + Command.SINGLE_SUCCESS + }) + for (name in listOf("jeanaddons", "ja")) { + dispatcher.register( + ClientCommands.literal(name) + .executes { + SkyblockUtils.localNotice(USAGE) + Command.SINGLE_SUCCESS + } + .then(refill), + ) + } + } + } + + /** + * Counts how much of the item the player already holds and asks `/gfs` + * for the difference. Matching (NoFrills `Utils.refillItem`): slots 0–35 + * only (hotbar + main inventory, no armor/offhand), case-insensitive on + * either the Skyblock id or the plain hover name, with underscores + * normalized to spaces on *both* sides of the comparison; the `/gfs` send + * itself keeps the query exactly as typed. Neither sack contents nor + * inventory space are checked — the shortfall is the server's to handle. + */ + private fun refillItem(query: String, amount: Int) { + val inventory = Minecraft.getInstance().player?.inventory ?: return + val normalizedQuery = query.replace('_', ' ') + + var total = 0 + for (slot in 0..35) { + val stack = inventory.getItem(slot) + if (stack.isEmpty) continue + val id = SkyblockUtils.getSkyblockId(stack).replace('_', ' ') + val name = SkyblockUtils.toPlain(stack.hoverName).replace('_', ' ') + if (normalizedQuery.equals(id, ignoreCase = true) || normalizedQuery.equals(name, ignoreCase = true)) { + total += stack.count + } + } + + if (total >= amount) { + // Enough already held: not a single packet goes out, and the + // cooldown is left untouched. + SkyblockUtils.localNotice("Already holding $total × $query, nothing to refill.") + return + } + + if (refillCooldown.isReady()) { + Minecraft.getInstance().player?.connection?.sendCommand("gfs $query ${amount - total}") + refillCooldown.start(REFILL_COOLDOWN_TICKS) + } else { + SkyblockUtils.localNotice( + "Refill on cooldown: %.1fs remaining.".format(refillCooldown.remainingSeconds()), + ) + } + } +} @@ -3,12 +3,17 @@ package dev.chatfatigue.feature import dev.chatfatigue.JeanAddonsMod import dev.chatfatigue.feature.impl.chat.ChatHistorySize import dev.chatfatigue.feature.impl.chat.CopyChatLine +import dev.chatfatigue.feature.impl.skyblock.CommandKeybinds +import dev.chatfatigue.feature.impl.skyblock.MiddleClickFix +import dev.chatfatigue.feature.impl.skyblock.MiddleClickOverride import dev.chatfatigue.feature.impl.visual.ForceCape import dev.chatfatigue.feature.impl.visual.OldCapeMovement import dev.chatfatigue.feature.impl.visual.OptifineCapeFlow import dev.chatfatigue.feature.impl.visual.RemoveTextShadow import net.minecraft.core.ClientAsset import net.minecraft.resources.Identifier +import net.minecraft.world.inventory.ContainerInput +import net.minecraft.world.inventory.Slot /** * Typed static bridge from Java mixin code into [FeatureManager]. Every method @@ -56,4 +61,26 @@ object FeatureAccess { @JvmStatic fun forcedCapeTexture(): ClientAsset.Texture? = if (FeatureManager.isEnabled(ForceCape.id)) FORCED_CAPE else null + + /** + * Offers one raw keyboard or mouse input to the command-keybind list. + * @return `true` when a bind consumed the input and it must be cancelled; + * always `false` before config load (no input is ever taken over). + */ + @JvmStatic + fun handleCommandKeybind(keyOrButton: Int, modifiers: Int, action: Int): Boolean = + CommandKeybinds.handle(keyOrButton, modifiers, action) + + /** @return whether Middle Click Fix is enabled; `false` before config load. */ + @JvmStatic + fun isMiddleClickFixEnabled(): Boolean = FeatureManager.isEnabled(MiddleClickFix.id) + + /** + * @return whether the live screen's click should be rewritten to a middle + * click; `false` before config load, leaving clicks untouched. + */ + @JvmStatic + fun shouldMiddleClickOverride(slot: Slot?, button: Int, actionType: ContainerInput): Boolean = + FeatureManager.isEnabled(MiddleClickOverride.id) && + MiddleClickOverride.shouldOverride(slot, button, actionType) } @@ -10,4 +10,7 @@ enum class FeatureCategory(val displayName: String) { /** Conveniences inside the chat screen. */ CHAT("Chat"), + + /** Hypixel Skyblock conveniences: command keybinds and container-click tweaks. */ + SKYBLOCK("Skyblock"), } @@ -4,6 +4,12 @@ import dev.chatfatigue.JeanAddonsMod import dev.chatfatigue.config.ConfigManager import dev.chatfatigue.feature.impl.chat.ChatHistorySize import dev.chatfatigue.feature.impl.chat.CopyChatLine +import dev.chatfatigue.feature.impl.skyblock.CommandCooldownTicks +import dev.chatfatigue.feature.impl.skyblock.CommandKeybinds +import dev.chatfatigue.feature.impl.skyblock.KeybindRegistry +import dev.chatfatigue.feature.impl.skyblock.MiddleClickFix +import dev.chatfatigue.feature.impl.skyblock.MiddleClickOverride +import dev.chatfatigue.feature.impl.skyblock.MiddleClickOverrideDebug import dev.chatfatigue.feature.impl.visual.ForceCape import dev.chatfatigue.feature.impl.visual.OldCapeMovement import dev.chatfatigue.feature.impl.visual.OptifineCapeFlow @@ -23,11 +29,16 @@ object FeatureManager { OldCapeMovement, ForceCape, CopyChatLine, + CommandKeybinds, + MiddleClickFix, + MiddleClickOverride, + MiddleClickOverrideDebug, ) /** Every numeric option the mod ships. */ private val intOptions: List<IntOption> = listOf( ChatHistorySize, + CommandCooldownTicks, ) /** Guard for class-load-order edge cases: lookups answer their fallback until [init] completed. */ @@ -43,6 +54,7 @@ object FeatureManager { fun init() { val parsed = ConfigManager.load() apply(parsed) + KeybindRegistry.finish() initialized = true JeanAddonsMod.logger.info( "Loaded {} feature(s): {}", @@ -93,6 +105,12 @@ object FeatureManager { continue } + // The mod's one structured entry: the Skyblock section's flat + // bind-N-key/modifier/command/enabled lines. + if (KeybindRegistry.tryApply(section, key, rawValue)) { + continue + } + JeanAddonsMod.logger.warn("Config: unknown entry '{}' in section [{}], ignored.", key, section) } } @@ -0,0 +1,20 @@ +package dev.chatfatigue.feature.impl.skyblock + +import dev.chatfatigue.feature.FeatureCategory +import dev.chatfatigue.feature.IntOption + +/** + * Command Cooldown (ticks): the minimum interval between two command-keybind + * sends, counted in game ticks (20 ticks = 1 second). One global value for + * all keybinds — what it rate-limits is the account's overall command packet + * rate, which is what Hypixel throttles, and a single knob keeps the + * hand-edited config manageable. Default 60 ticks = 3 seconds; 0 = no limit. + */ +object CommandCooldownTicks : IntOption( + id = "command-cooldown-ticks", + category = FeatureCategory.SKYBLOCK, + description = "Minimum interval between command keybind sends, in game ticks (20 = 1 second). 0 disables.", + defaultValue = 60, + minValue = 0, + maxValue = 1200, +) @@ -0,0 +1,112 @@ +package dev.chatfatigue.feature.impl.skyblock + +import dev.chatfatigue.feature.Feature +import dev.chatfatigue.feature.FeatureCategory +import dev.chatfatigue.skyblock.SkyblockUtils +import dev.chatfatigue.skyblock.TickCooldown +import net.minecraft.client.Minecraft +import org.lwjgl.glfw.GLFW + +/** + * Command Keybinds: pressing a bound key or mouse button sends one configured + * chat command straight through the vanilla `sendCommand` / `sendChat` path — + * never through the chat screen — gated by a global tick cooldown. Ported from + * NoFrills' `customKeybinds`, deliberately trimmed: binds only fire while no + * screen is open (no `allowInGui` — none of the migrated binds used it, and + * sending commands from inside open container UIs would couple this feature + * to Skyblock's cover-a-UI-with-a-new-UI chest behavior, at a risk far out of + * proportion), and there is no island filter (all migrated binds left it + * empty). The bind list itself comes from the `[Skyblock]` section's + * `bind-N-*` lines via [KeybindRegistry]. + */ +object CommandKeybinds : Feature( + id = "command-keybinds", + category = FeatureCategory.SKYBLOCK, + description = "Press a bound key or mouse button to send its chat command. Only fires while no screen is open.", + defaultEnabled = true, +) { + + /** Bits of the modifier field this feature compares: Shift | Ctrl | Alt. */ + private const val MODIFIER_MASK = 0x7 + + /** + * One shared cooldown for every keybind send: the limit being avoided is + * Hypixel's per-account command rate, so per-key timers would buy + * nothing. + */ + val cooldown: TickCooldown = TickCooldown.create() + + /** + * Offers one raw keyboard or mouse input to the bind list. Called from + * `KeyboardHandlerMixin` and `MouseHandlerMixin` with a shared numeric + * space: GLFW keyboard key codes (from 32 up) and GLFW mouse button + * codes (0–7) don't collide, so a stored `key` is either. + * + * Match rules, in order (NoFrills `CommandKeybinds.onKey`): + * 1. the feature is on and a player exists; + * 2. **no screen is open** — while chatting or in any GUI nothing matches + * and nothing is cancelled, so text input is never touched; + * 3. binds are walked in `bind-N` order and the first full hit wins; + * 4. a hit needs the bind enabled, the key equal, the modifiers equal, + * and a non-empty command; + * 5. modifier equality compares `modifiers & 0x7` exactly (deliberately + * masked, unlike NoFrills: GLFW 3.4+ also reports Caps/Num Lock bits, + * which would otherwise keep `none` binds from ever firing); + * 6. the command is sent on `GLFW_PRESS` only, but press, release and + * repeat are all cancelled — a bound key no longer triggers its + * vanilla behavior (e.g. hotbar switching on number keys). + * + * @return `true` when the input matched a bind and must be cancelled. + */ + fun handle(keyOrButton: Int, modifiers: Int, action: Int): Boolean { + if (!enabled) return false + val client = Minecraft.getInstance() + if (client.player == null) return false + if (client.screen != null) return false + + for (bind in KeybindRegistry.bindsInOrder) { + if (!bind.enabled) continue + if (bind.key == -1 || bind.key != keyOrButton) continue + if (bind.modifier != "any" && (modifiers and MODIFIER_MASK) != requiredModifier(bind.modifier)) continue + if (bind.command.isEmpty()) continue + + if (action == GLFW.GLFW_PRESS) { + if (cooldown.isReady()) { + send(bind.command) + cooldown.start(CommandCooldownTicks.value) + } else { + SkyblockUtils.localNotice( + "Cooldown: %.1fs until the next command keybind.".format(cooldown.remainingSeconds()), + ) + } + } + return true + } + return false + } + + /** Required Shift/Ctrl/Alt bitmask for a modifier name (not `any`). */ + private fun requiredModifier(modifier: String): Int = when (modifier) { + "none" -> 0 + "shift" -> GLFW.GLFW_MOD_SHIFT + "ctrl" -> GLFW.GLFW_MOD_CONTROL + "alt" -> GLFW.GLFW_MOD_ALT + else -> 0 + } + + /** + * Sends [message] through the vanilla chat path without opening the chat + * screen: `/command` via `sendCommand` (Fabric's client-command hook + * intercepts `/jeanaddons` there), anything else via `sendChat` + * (NoFrills `Utils.sendMessage`). + */ + private fun send(message: String) { + val player = Minecraft.getInstance().player ?: return + if (message.isBlank()) return + if (message.startsWith("/")) { + player.connection.sendCommand(message.substring(1)) + } else { + player.connection.sendChat(message) + } + } +} @@ -0,0 +1,129 @@ +package dev.chatfatigue.feature.impl.skyblock + +import dev.chatfatigue.JeanAddonsMod +import java.util.regex.Pattern + +/** + * Parses the flat `bind-N-field` lines of the `[Skyblock]` config section + * into the ordered list of command keybinds. This is the mod's only + * structured config entry — the grammar of the file itself stays the same + * plain INI, each keybind simply being four ordinary `key = value` lines + * (`bind-N-key`, `bind-N-modifier`, `bind-N-command`, `bind-N-enabled`). + * + * Tolerance follows the project philosophy: a bad value is replaced with the + * safe one and logged, never crashed on. Gaps in `N` are fine — binds take + * effect in ascending numeric order, and that order is the match priority. + */ +object KeybindRegistry { + + /** The Skyblock section name; `bind-` lines are only valid inside it. */ + private const val SECTION = "Skyblock" + + /** The four value kinds a bind line may carry. */ + private val LINE: Pattern = Pattern.compile("bind-(\\d+)-(key|modifier|command|enabled)") + + /** Modifier names accepted in `bind-N-modifier`, all with a bitmask. */ + private val MODIFIERS: Map<String, Int> = mapOf( + "none" to 0, + "shift" to 1, // GLFW_MOD_SHIFT + "ctrl" to 2, // GLFW_MOD_CONTROL + "alt" to 4, // GLFW_MOD_ALT + ) + + /** + * One keybind entry. Defaults describe a bind that can never match + * (unbound, disabled, empty command), so a partially edited block in the + * config file fails safe. + */ + data class Keybind( + val index: Int, + var key: Int = -1, + var modifier: String = "any", + var command: String = "", + var enabled: Boolean = false, + ) + + /** Parsed binds in match-priority order; empty until [finish]. */ + private var parsedBinds: List<Keybind> = emptyList() + + /** + * Offers one `(section, key, value)` config entry to the registry. + * + * @return `true` if the key is a `bind-N-*` line of the Skyblock section + * and was consumed — validly or with a warning; `false` when the key is + * not ours (so the caller reports it as unknown). + */ + fun tryApply(section: String, key: String, rawValue: String): Boolean { + if (section != SECTION) return false + val matcher = LINE.matcher(key) + if (!matcher.matches()) return false + val bind = binds.getOrPut(matcher.group(1).toInt()) { Keybind(matcher.group(1).toInt()) } + when (matcher.group(2)) { + "key" -> bind.key = parseKey(key, rawValue) + "modifier" -> bind.modifier = parseModifier(key, rawValue) + "command" -> bind.command = rawValue + "enabled" -> bind.enabled = parseEnabled(key, rawValue) + } + return true + } + + /** + * Freezes the parse result: binds are sorted into their match order and + * one summary line is logged. Called once at the end of config loading. + */ + fun finish() { + parsedBinds = binds.values.sortedBy { it.index } + val enabledCount = parsedBinds.count { it.enabled } + JeanAddonsMod.logger.info( + "Loaded {} command keybind(s), {} enabled.", + parsedBinds.size, enabledCount, + ) + } + + /** The binds in match-priority order. */ + val bindsInOrder: List<Keybind> + get() = parsedBinds + + /** Bind entries keyed by their `N`, while the file is being parsed. */ + private val binds = LinkedHashMap<Int, Keybind>() + + /** GLFW key codes are non-negative; only `-1` (unknown) is valid below. */ + private fun parseKey(key: String, rawValue: String): Int { + val value = rawValue.toIntOrNull() + if (value == null || value < -1) { + JeanAddonsMod.logger.warn( + "Config: invalid key code '{}' for '{}', using -1 (unbound); expected a GLFW key or mouse button code.", + rawValue, key, + ) + return -1 + } + return value + } + + /** `none | any | shift | ctrl | alt`; anything else falls back to `any`. */ + private fun parseModifier(key: String, rawValue: String): String { + val value = rawValue.trim().lowercase() + if (value != "any" && !MODIFIERS.containsKey(value)) { + JeanAddonsMod.logger.warn( + "Config: invalid modifier '{}' for '{}', using any; expected none, any, shift, ctrl or alt.", + rawValue, key, + ) + return "any" + } + return value + } + + private fun parseEnabled(key: String, rawValue: String): Boolean { + return when (rawValue.lowercase()) { + "true" -> true + "false" -> false + else -> { + JeanAddonsMod.logger.warn( + "Config: invalid value '{}' for '{}', expected true or false; using false.", + rawValue, key, + ) + false + } + } + } +} @@ -0,0 +1,22 @@ +package dev.chatfatigue.feature.impl.skyblock + +import dev.chatfatigue.feature.Feature +import dev.chatfatigue.feature.FeatureCategory + +/** + * Middle Click Fix: the 1.8.9 client sent a `ClickType.CLONE` (button 2) + * container click whenever the middle button was pressed in a container + * screen, unconditionally. Modern vanilla (1.20.5+, including 26.1) gates + * that path behind `LocalPlayer#hasInfiniteMaterials()`, which is only true + * in creative/spectator, so in survival the middle button sends nothing at + * all. This feature makes the real middle click take its 1.8.9 path again — + * it only flips that one vanilla check, everything else (stateId, carried + * item, changed slots, the packet itself) stays the vanilla pipeline's. No + * Skyblock gating: restoring vanilla behavior is harmless anywhere. + */ +object MiddleClickFix : Feature( + id = "middle-click-fix", + category = FeatureCategory.SKYBLOCK, + description = "Lets the middle mouse button send its container click in survival, like 1.8.9 did.", + defaultEnabled = true, +) @@ -0,0 +1,151 @@ +package dev.chatfatigue.feature.impl.skyblock + +import dev.chatfatigue.feature.Feature +import dev.chatfatigue.feature.FeatureCategory +import dev.chatfatigue.skyblock.SkyblockUtils +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.screens.inventory.ContainerScreen +import net.minecraft.world.inventory.ContainerInput +import net.minecraft.world.inventory.Slot +import org.lwjgl.glfw.GLFW + +/** + * Middle Click Override: in Skyblock menus whose intended action is the + * middle click, a plain left click is rewritten to the 1.8.9-style middle + * click (`CLONE`, button 2) instead of `PICKUP` — under latency a left click + * that lands on a screen the server has already replaced can pick an item up + * onto the cursor, which `CLONE` never does. The rewrite happens inside + * `AbstractContainerScreen#mouseClicked`, on the user's real click on the + * live screen: `containerId`, `stateId`, carried item and changed slots all + * come from the vanilla `slotClicked` -> `handleContainerInput` chain, so the + * outgoing packet is byte-identical to a 1.8.9 player pressing middle click. + * Nothing here constructs a container-click packet or holds a menu/screen + * reference past a UI change. + * + * Ported from NoFrills' `MiddleClickOverride`, title lists verbatim. + */ +object MiddleClickOverride : Feature( + id = "middle-click-override", + category = FeatureCategory.SKYBLOCK, + description = "Replaces plain left clicks with middle clicks in Skyblock menus that act on middle click.", + defaultEnabled = true, +) { + + /** Titles the override never applies to, matched whole (paginated-aware). */ + private val matchBlacklist = setOf( + "Attribute Fusion", + "Beacon", + "Chest", + "Large Chest", + "Anvil", + "Storage", + "Drill Anvil", + "Runic Pedestal", + "Rune Removal", + "Reforge Anvil", + "Reforge Item", + "Offer Pets", + "Exp Sharing", + "Convert to Dungeon Item", + "Upgrade Item", + "Salvage Items", + "Fishing Rod Parts", + "Stats Tuning", + "Pet Sitter", + "Transfer to Profile", + "Kuudra Armor Type Transfer", + "Composter", + "Midas Anvil", + "Hunting Box", + ) + + /** Titles the override always applies to, matched whole (paginated-aware). */ + private val matchWhitelist = setOf( + "Stats & Equipment", + "Accessory Bag Thaumaturgy", + "Pets", + "Community Shop", + ) + + /** Titles the override never applies to, matched by substring. */ + private val containBlacklist = setOf( + " Minion ", + "Abiphone", + "AⒷiphone", // Ⓑ = U+24B7, the bingo symbol + "The Hex", + "Enchant Item", + "Auction", + "Cosmetic", + "Trap", + "Gemstones", + "Heart of the ", + "Widgets", + ) + + /** Titles the override always applies to, matched by substring. */ + private val containWhitelist = setOf("Bits Shop") + + /** + * Whether the live screen's click (`slot`, [button], [actionType]) should + * be rewritten to a middle-click `CLONE`. Conditions in NoFrills + * `MiddleClickOverride.shouldOverride` order: + * + * 1. feature on, `mc.screen` a `ContainerScreen` (the chest-type class + * Skyblock uses), slot present, plain left click (`button 0` + `PICKUP` + * — the empty-hand branch of `mouseClicked`); + * 2. give up when the slot is empty, the title is blacklisted, we are not + * on Skyblock at all, or a loadout is being edited; + * 3. otherwise override when the item has no Skyblock id, the title is + * whitelisted, or the lore marks a transaction (`Cost` / `Sell Price` / + * `Bazaar Price`). + */ + fun shouldOverride(slot: Slot?, button: Int, actionType: ContainerInput): Boolean { + if (!enabled) return false + val screen = Minecraft.getInstance().screen as? ContainerScreen ?: return false + if (slot == null) return false + if (button != GLFW.GLFW_MOUSE_BUTTON_LEFT || actionType != ContainerInput.PICKUP) return false + + val title = screen.title.string + val stack = slot.item + if (stack.isEmpty || isBlacklisted(title) || !SkyblockUtils.isInSkyblock() || isInLoadoutEdit(screen)) { + return false + } + + if (SkyblockUtils.getSkyblockId(stack).isEmpty() || isWhitelisted(title) || isTransaction(stack)) { + if (MiddleClickOverrideDebug.enabled) { + SkyblockUtils.localNotice( + "Middle click override: slot ${slot.index}, replaced left PICKUP click with CLONE.", + ) + } + return true + } + return false + } + + private fun isBlacklisted(title: String): Boolean = + matchBlacklist.any { SkyblockUtils.isPaginatedMenu(title, it) } || containBlacklist.any { title.contains(it) } + + private fun isWhitelisted(title: String): Boolean = + matchWhitelist.any { SkyblockUtils.isPaginatedMenu(title, it) } || containWhitelist.any { title.contains(it) } + + /** A lore line naming a price marks the item as a buy/sell transaction. */ + private fun isTransaction(stack: net.minecraft.world.item.ItemStack): Boolean = + SkyblockUtils.getLoreLines(stack).any { it == "Cost" || it == "Sell Price" || it == "Bazaar Price" } + + /** Any non-empty slot literally named "Rename Loadout" means loadout editing. */ + private fun isInLoadoutEdit(screen: ContainerScreen): Boolean = + screen.menu.slots.any { slot -> + !slot.item.isEmpty && slot.item.hoverName.string == "Rename Loadout" + } +} + +/** + * Middle Click Override Debug: prints one local chat line for every click + * the override rewrites (slot, original button, original action type). + */ +object MiddleClickOverrideDebug : Feature( + id = "middle-click-override-debug", + category = FeatureCategory.SKYBLOCK, + description = "Prints one local chat line for every click the override rewrites.", + defaultEnabled = false, +) @@ -0,0 +1,90 @@ +package dev.chatfatigue.skyblock + +import net.minecraft.ChatFormatting +import net.minecraft.client.Minecraft +import net.minecraft.core.component.DataComponents +import net.minecraft.network.chat.Component +import net.minecraft.world.item.ItemStack +import net.minecraft.world.scores.DisplaySlot +import java.util.concurrent.ConcurrentHashMap +import java.util.regex.Pattern + +/** + * Skyblock data helpers, each mirroring the same-named logic in the NoFrills + * reference (`nofrills/misc/Utils.java` / `SkyblockData.java`). Everything here + * only *reads* client state; nothing sends packets. + */ +object SkyblockUtils { + + private val paginationPatternCache = ConcurrentHashMap<String, Pattern>() + + /** + * Whether the local player is currently on Hypixel Skyblock: the sidebar + * scoreboard objective's (display slot 1) plain-text display name contains + * "SKYBLOCK". Queried live on every call, never cached, like NoFrills' + * `SkyblockData.updateObjective` but without the cached flag. + */ + fun isInSkyblock(): Boolean { + val player = Minecraft.getInstance().player ?: return false + val objective = player.connection.scoreboard() + .getDisplayObjective(DisplaySlot.BY_ID.apply(1)) ?: return false + return toPlain(objective.displayName).contains("SKYBLOCK") + } + + /** + * The item's Skyblock id from its `CUSTOM_DATA` NBT `"id"` field, or an + * empty string when the item has none (NoFrills `Utils.getSkyblockId`). + */ + fun getSkyblockId(stack: ItemStack): String { + if (stack.isEmpty) return "" + val data = stack.get(DataComponents.CUSTOM_DATA) ?: return "" + return data.copyTag().getString("id").orElse("") + } + + /** + * Every line of the stack's `LORE` component as plain trimmed text, or an + * empty list (NoFrills `Utils.getLoreLines`). + */ + fun getLoreLines(stack: ItemStack): List<String> { + val lore = stack.get(DataComponents.LORE) ?: return emptyList() + return lore.lines().map { toPlain(it).trim() } + } + + /** + * [text] with legacy `§` formatting codes removed (NoFrills + * `Utils.toPlain`). + */ + fun toPlain(text: Component?): String = if (text != null) toPlain(text.string) else "" + + /** @see toPlain */ + fun toPlain(string: String): String = string.replace("(?i)§[0-9A-Z]".toRegex(), "") + + /** + * Whether [title] matches [match] as a whole, tolerating the `(1/2) ` + * pagination prefix Skyblock menus put in front of their title + * (NoFrills `Utils.isPaginatedMenu`). + */ + fun isPaginatedMenu(title: String, match: String): Boolean = + paginationPatternCache + .computeIfAbsent(match) { Pattern.compile("(?:|\\([0-9]*/[0-9]*\\) " + match) } + .matcher(title) + .matches() + + /** + * Shows one `[JeanAddons]`-prefixed line in the chat HUD, purely local: + * it goes through `ChatComponent#addClientSystemMessage`, which never + * produces an outgoing packet, so cooldown notices cannot themselves trip + * the server's rate limit. Safe to call from any thread (routes through + * `Minecraft#execute`). + */ + fun localNotice(message: String) { + val client = Minecraft.getInstance() + client.execute { + client.gui.chat.addClientSystemMessage( + Component.literal("[JeanAddons] ") + .withStyle(ChatFormatting.GRAY) + .append(Component.literal(message)), + ) + } + } +} @@ -0,0 +1,51 @@ +package dev.chatfatigue.skyblock + +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents + +/** + * A cooldown countered in game ticks, for rate-limiting outgoing chat + * commands. Ticks — not wall-clock or frame time — are used so the cooldown + * aligns with the server's own per-tick command processing, and so + * `remainingTicks / 20.0` is always the remaining seconds regardless of frame + * rate or clock drift. + * + * Instances are created through [create]; every instance's [remainingTicks] + * is decremented once per client tick from `ClientTickEvents.END_CLIENT_TICK` + * (the listener is registered by the companion, which constructing an + * instance always initializes first). + */ +class TickCooldown private constructor() { + + /** Ticks left until ready; `0` means ready. */ + var remainingTicks: Int = 0 + private set + + fun isReady(): Boolean = remainingTicks <= 0 + + /** Starts (or restarts) the cooldown; negative [ticks] is clamped to 0. */ + fun start(ticks: Int) { + remainingTicks = ticks.coerceAtLeast(0) + } + + /** Remaining time in seconds, for local chat notices. */ + fun remainingSeconds(): Double = remainingTicks / 20.0 + + companion object { + /** All live cooldowns; created and ticked on the client thread only. */ + private val all = mutableListOf<TickCooldown>() + + init { + ClientTickEvents.END_CLIENT_TICK.register { + for (cooldown in all) { + if (cooldown.remainingTicks > 0) cooldown.remainingTicks-- + } + } + } + + fun create(): TickCooldown { + val cooldown = TickCooldown() + all.add(cooldown) + return cooldown + } + } +} @@ -1,5 +1,5 @@ # ============================================================= -# JeanAddons - client-side rendering and chat conveniences +# JeanAddons - client-side rendering, chat and Skyblock conveniences # ------------------------------------------------------------- # This file was generated automatically on first launch and is # safe to edit by hand. Restart the game to apply changes. @@ -70,3 +70,154 @@ copy-chat-line = true # at the cost of some memory. # Valid values: whole numbers from 100 to 10000 chat-history-size = 1000 + +[Skyblock] + +# Feature: Command Keybinds +# Press a key (or mouse button) to send a chat command. Binds only +# fire while no screen is open. Sending is rate-limited by the +# cooldown below. Keybinds are the bind-N lines further down. +# Valid values: true | false +command-keybinds = true + +# Option: Command Cooldown (ticks) +# Minimum interval between two command keybind sends, counted in +# game ticks (20 ticks = 1 second). While on cooldown the press is +# blocked and the remaining time is shown in chat. 0 disables. +# Valid values: whole numbers from 0 to 1200 +command-cooldown-ticks = 60 + +# Feature: Middle Click Fix +# Lets the middle mouse button send its container click in survival, +# exactly like 1.8.9 did. Modern vanilla only sends it in creative. +# Valid values: true | false +middle-click-fix = true + +# Feature: Middle Click Override +# In Skyblock menus where middle-click is the intended action, +# replaces plain left clicks with the 1.8.9-style middle click, so +# items never get picked up onto the cursor under latency. +# Valid values: true | false +middle-click-override = true + +# Feature: Middle Click Override Debug +# Prints one local chat line for every click the override rewrites. +# Valid values: true | false +middle-click-override-debug = false + +# ---- Command keybinds -------------------------------------------- +# Each keybind is four lines: +# bind-N-key = GLFW key code (e.g. 77 = M), or a mouse +# button code (e.g. 3 / 4 = side buttons) +# bind-N-modifier = none | any | shift | ctrl | alt +# bind-N-command = command/message to send +# bind-N-enabled = true | false +# Lower N matches first; keep the order when editing. + +# Keybind 1: Pet +bind-1-key = 77 +bind-1-modifier = none +bind-1-command = /pet +bind-1-enabled = true + +# Keybind 2: farming_sethome (keypad 0) +bind-2-key = 320 +bind-2-modifier = none +bind-2-command = /sethome +bind-2-enabled = false + +# Keybind 3: farming_warpgarden (keypad .) +bind-3-key = 330 +bind-3-modifier = none +bind-3-command = /warp garden +bind-3-enabled = false + +# Keybind 4: wd_default (B) +bind-4-key = 66 +bind-4-modifier = none +bind-4-command = /wd +bind-4-enabled = false + +# Keybind 5: eq (X) +bind-5-key = 88 +bind-5-modifier = none +bind-5-command = /stats +bind-5-enabled = true + +# Keybind 6: refill_enderpearl (7) +bind-6-key = 55 +bind-6-modifier = none +bind-6-command = /odin ep +bind-6-enabled = true + +# Keybind 7: refill_superbombtnt (6) +bind-7-key = 54 +bind-7-modifier = any +bind-7-command = /jeanaddons refill SUPERBOOM_TNT 64 +bind-7-enabled = true + +# Keybind 8: hotm (H) +bind-8-key = 72 +bind-8-modifier = any +bind-8-command = /hotm +bind-8-enabled = false + +# Keybind 9: jeanaddons_quickopen (\) +bind-9-key = 92 +bind-9-modifier = any +bind-9-command = /jeanaddons +bind-9-enabled = false + +# Keybind 10: SSRS (;) +bind-10-key = 59 +bind-10-modifier = any +bind-10-command = /pc SSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOTSSRS/SLOW GO SAFESPOT +bind-10-enabled = true + +# Keybind 11: pot (0) +bind-11-key = 48 +bind-11-modifier = any +bind-11-command = /potionbag +bind-11-enabled = true + +# Keybind 12: refill_twilight_64 (8) +bind-12-key = 56 +bind-12-modifier = none +bind-12-command = /jeanaddons refill TWILIGHT_ARROW_POISON 64 +bind-12-enabled = tru …… diff 过长已截断 …… |
| JeanAddons 0.3.0: fix gray cape block, keep only the 2016 cape, add old cape movement - Bundle the original 64x32 (1.8.9-format) MineCon 2016 cape png as-is;
the vanilla cape model declares its layer 64x64 with texScaleV 0.5,
which makes the effective UV space 64x32, so the previous 64x64
padding sampled the appended transparent rows and rendered a gray
block over the lower half of the cape
- Slim the bundle to the single 2016 cape: drop the 2011-2015 textures
and the cape-style choice option together with its ChoiceOption
framework; the 2016 asset is a plain constant in FeatureAccess
- Add old-cape-movement (default off), ported from Animatium's
[1.12.2] Old Cape Movement and kept separate from optifine-cape-flow:
plain-lerp cloak-direction yaw, no elytra lean scaling, unclamped
cape side lean; the two options compose freely 查看变更README.md | 44 +++++++++++++++++++++----------------------- conf/jeanaddons.conf | 25 +++++++++++++++---------- gradle.properties | 2 +- src/main/java/dev/chatfatigue/mixin/AvatarRendererMixin.java | 51 +++++++++++++++++++++++++++++++++++++++++++++------ src/main/kotlin/dev/chatfatigue/cape/CapeTextures.kt | 39 --------------------------------------- src/main/kotlin/dev/chatfatigue/feature/ChoiceOption.kt | 29 ----------------------------- src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt | 32 ++++++++++++++++++++++---------- src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt | 44 +++++--------------------------------------- src/main/kotlin/dev/chatfatigue/feature/impl/visual/CapeStyle.kt | 18 ------------------ src/main/kotlin/dev/chatfatigue/feature/impl/visual/ForceCape.kt | 16 ++++++++-------- src/main/kotlin/dev/chatfatigue/feature/impl/visual/OldCapeMovement.kt | 20 ++++++++++++++++++++ src/main/resources/assets/jeanaddons/default.conf | 25 +++++++++++++++---------- src/main/resources/assets/jeanaddons/textures/cape/2011.png | Bin 541 -> 0 bytes src/main/resources/assets/jeanaddons/textures/cape/2012.png | Bin 599 -> 0 bytes src/main/resources/assets/jeanaddons/textures/cape/2013.png | Bin 673 -> 0 bytes src/main/resources/assets/jeanaddons/textures/cape/2015.png | Bin 678 -> 0 bytes src/main/resources/assets/jeanaddons/textures/cape/2016.png | Bin 551 -> 953 bytes 17 files changed, 152 insertions(+), 193 deletions(-) @@ -16,15 +16,11 @@ dedicated server (`"environment": "client"`). | --- | --- | --- | --- | | `remove-text-shadow` | Visual | `true` | Strips the drop shadow from all text drawn through the game's font renderer: GUIs, HUD, chat, tooltips, scoreboard sidebar, action bar, item name pop-ups, entity name tags, the F3 debug overlay, and other mods' HUD text drawn via the vanilla font classes. | | `optifine-cape-flow` | Visual | `true` | Restores OptiFine's cape movement. Vanilla clamps how far the cape may lean against the player (150 degrees), pinning it to the back during fast movement and long falls; removing the cap lets the cape lean freely for every player you see, exactly like OptiFine's cape physics. | -| `force-cape` | Visual | `true` | Always renders your own player with the cape selected by `cape-style`, even if your account has none; while enabled it also replaces a cape you already own. Only your own player is affected. The vanilla guards still apply: the cape hides for invisible players, hidden cape model parts, and a worn elytra. | +| `old-cape-movement` | Visual | `false` | Restores the cape movement of 1.12.x and earlier (Animatium's Old Cape Movement): the body yaw behind the cloak direction is interpolated with a plain lerp, the cape lean is not scaled down while elytra-flying, and the cape's side lean is left unclamped. Independent of `optifine-cape-flow`, which only removes the 150° forward-lean cap — both can be combined. | +| `force-cape` | Visual | `true` | Always renders your own player with the bundled 2016 MineCon cape, even if your account has none; while enabled it also replaces a cape you already own. Only your own view of your own player is affected — other players keep their own capes. The vanilla guards still apply: the cape hides for invisible players, hidden cape model parts, and a worn elytra. | | `copy-chat-line` | Chat | `true` | Right-click a chat line to copy the whole message it belongs to, as plain text, to the system clipboard. A green confirmation is briefly drawn in the bottom-right corner. Vanilla ignores right-clicks in the chat screen, so nothing vanilla is shadowed. | | `chat-history-size` | Chat | `1000` | How many chat messages the client keeps in memory and can scroll back through, counting the lines a long message wraps into. Vanilla hard-caps this at 100. | -Besides the toggles above, one choice option ships: `cape-style` (Visual, -default `2016`) selects which bundled cape `force-cape` draws — the MineCon-era -designs `2011 | 2012 | 2013 | 2015 | 2016`, bundled as -`assets/jeanaddons/textures/cape/<year>.png` inside the jar. - **Known limitation (remove-text-shadow):** mods that render text with their own font engine (for example NanoVG-based HUDs, like Odin's custom HUD) bypass `Font` entirely and cannot be affected. Everything using the vanilla @@ -36,19 +32,22 @@ and consumes the click. `chat-history-size` widens both vanilla caps (stored messages and display lines) to the same number, which is what lets scrolling reach the extra history; values beyond the newest `10000` are clamped. -**Notes (cape features):** both cape tweaks are ported from -[Animatium](https://github.com/mixces/Animatium) (its `[OF] Disable Cape -Lean` option) and stay purely client-side render changes. `optifine-cape-flow` -removes only the 150-degree bound on the cape lean computed in -`AvatarRenderer#extractCapeState`; every other part of the vanilla cape math -is untouched. `force-cape` swaps the local player's cape texture asset for the -selected bundled one inside `CapeLayer#submit` and restores it before the +**Notes (cape features):** the cape tweaks are ported from +[Animatium](https://github.com/mixces/Animatium) (its cape-movement options) +and stay purely client-side render changes. Both movement options act inside +`AvatarRenderer#extractCapeState` and are independent knobs that compose: +`optifine-cape-flow` removes only the 150-degree bound on the cape lean, while +`old-cape-movement` switches the body-yaw interpolation to a plain lerp, keeps +the cape lean unscaled while elytra-flying, and unclamps the side lean, like +1.12.x and earlier. `force-cape` swaps the local player's cape texture asset +for the single bundled one inside `CapeLayer#submit` and restores it before the method returns, so the skin object is never permanently modified — in -particular the mod never claims you own a cape anywhere outside rendering. The -bundled textures are the classic 64x32 MineCon-era cape exports from -`reference/cape/`, padded to 64x64 to match the UV space the vanilla cape -model declares (`PlayerCapeModel`); values outside the bundled set fall back -to `2016`. +particular the mod never claims you own a cape anywhere outside your own view +of your own player. The bundled texture is the original 64x32 MineCon 2016 +cape export from `reference/cape/`, used as-is: the vanilla cape model +declares a 64x64 layer with `texScaleV` 0.5, which makes its effective UV +space 64x32 — exactly the classic (1.8.9-era) cape format, so the file needs +no re-layout. ## Directory layout @@ -97,12 +96,11 @@ self-documenting text file: Three steps: 1. Create one new class under `src/main/kotlin/dev/chatfatigue/feature/impl/<category>/` - extending `Feature` (a toggle), `IntOption` (a numeric setting), or - `ChoiceOption` (one value out of a fixed set); pick a + extending `Feature` (a toggle) or `IntOption` (a numeric setting); pick a stable, unique kebab-case `id`. -2. Add one line to the `features`, `intOptions`, or `choiceOptions` list in - `FeatureManager` (plus, if the mixin side needs it, one `@JvmStatic` - accessor in `FeatureAccess`). +2. Add one line to the `features` or `intOptions` list in `FeatureManager` + (plus, if the mixin side needs it, one `@JvmStatic` accessor in + `FeatureAccess`). 3. Add one commented block for the feature to `src/main/resources/assets/jeanaddons/default.conf` (under the right `[Section]`, with its valid values documented). @@ -32,21 +32,26 @@ remove-text-shadow = true # Valid values: true | false optifine-cape-flow = true +# Feature: Old Cape Movement +# Changes the cape movement to how it was in 1.12.x and earlier: the +# body yaw behind the cloak direction is interpolated linearly, the +# cape lean is not scaled down while elytra-flying, and the cape's +# side lean is not clamped. This is independent of OptiFine Cape +# Flow above - that one only removes the 150-degree forward-lean +# cap; both can be enabled at once. +# Valid values: true | false +old-cape-movement = false + # Feature: Force Cape -# Always renders your own player with the cape selected by cape-style, +# Always renders your own player with the bundled 2016 MineCon cape, # even if your account has none; while enabled it also replaces a cape -# you already own. Only your own player is affected - everyone else -# keeps their own cape. The cape still hides when the player is -# invisible, has an elytra worn, or has the cape model part hidden. +# you already own. Only your own view of your own player is affected - +# everyone else keeps their own cape. The cape still hides when the +# player is invisible, has an elytra worn, or has the cape model part +# hidden. # Valid values: true | false force-cape = true -# Option: Cape Style -# Which bundled cape force-cape puts on your player. The bundled -# capes are the MineCon-era designs shipped with the mod. -# Valid values: 2011 | 2012 | 2013 | 2015 | 2016 -cape-style = 2016 - [Chat] # Feature: Copy Chat Line @@ -13,6 +13,6 @@ kotlin_version=2.4.20 # Mod identity mod_id=jeanaddons mod_name=JeanAddons -mod_version=0.2.0 +mod_version=0.3.0 maven_group=dev.chatfatigue archives_base_name=jeanaddons @@ -2,17 +2,28 @@ package dev.chatfatigue.mixin; import dev.chatfatigue.feature.FeatureAccess; import net.minecraft.client.renderer.entity.player.AvatarRenderer; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import net.minecraft.util.Mth; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.ModifyArg; +import org.spongepowered.asm.mixin.injection.Redirect; /** - * OptiFine Cape Flow: inside {@code AvatarRenderer#extractCapeState} vanilla - * clamps how far the cape may lean against the player to 150 degrees, which - * pins the cape to the player's back during fast movement and long falls. - * OptiFine leaves the lean unbounded; raising the clamp's upper bound to - * {@code Float.MAX_VALUE} restores that movement while leaving every other - * part of the cape math untouched. + * Two independent cape-movement restorations, both applied inside + * {@code AvatarRenderer#extractCapeState}: + * + * OptiFine cape flow: vanilla clamps how far the cape may lean against the + * player to 150 degrees, which pins the cape to the player's back during fast + * movement and long falls; OptiFine leaves the lean unbounded. Raising the + * clamp's upper bound restores that movement while leaving every other part of + * the cape math untouched. + * + * Old cape movement (Animatium's [1.12.2] Old Cape Movement): 1.12.x and + * earlier interpolated the body yaw behind the cloak direction with a plain + * lerp, never scaled the cape lean down while elytra-flying, and never clamped + * the cape's side lean. Each redirect hands off to the vanilla method when the + * feature is off, so the two options compose freely. */ @Mixin(AvatarRenderer.class) public class AvatarRendererMixin { @@ -26,4 +37,32 @@ public class AvatarRendererMixin { private float jeanaddons$uncapCapeLean(float upperBound) { return FeatureAccess.isOptifineCapeFlowEnabled() ? Float.MAX_VALUE : upperBound; } + + // <=1.12.x interpolated the yaw with a plain lerp instead of an angle-aware one. + @Redirect( + method = "extractCapeState", + at = @At(value = "INVOKE", target = "Lnet/minecraft/util/Mth;rotLerp(FFF)F") + ) + private float jeanaddons$plainYawLerp(float delta, float start, float end) { + return FeatureAccess.isOldCapeMovementEnabled() ? Mth.lerp(delta, start, end) : Mth.rotLerp(delta, start, end); + } + + // <=1.12.x did not scale the cape lean down while elytra-flying; a zero scale + // keeps the raw lean, exactly like skipping the re-assignment. + @Redirect( + method = "extractCapeState", + at = @At(value = "INVOKE", target = "Lnet/minecraft/client/renderer/entity/state/AvatarRenderState;fallFlyingScale()F") + ) + private float jeanaddons$noFallFlyingLeanScale(AvatarRenderState state) { + return FeatureAccess.isOldCapeMovementEnabled() ? 0.0F : state.fallFlyingScale(); + } + + // ordinal 2 is the capeLean2 clamp, Mth.clamp(lean2, -20, 20); <=1.12.x left it unclamped. + @Redirect( + method = "extractCapeState", + at = @At(value = "INVOKE", target = "Lnet/minecraft/util/Mth;clamp(FFF)F", ordinal = 2) + ) + private float jeanaddons$unclampedCapeLean2(float value, float min, float max) { + return FeatureAccess.isOldCapeMovementEnabled() ? value : Mth.clamp(value, min, max); + } } @@ -1,39 +0,0 @@ -package dev.chatfatigue.cape - -import dev.chatfatigue.JeanAddonsMod -import net.minecraft.core.ClientAsset -import net.minecraft.resources.Identifier -import java.util.concurrent.ConcurrentHashMap - -/** - * The capes bundled with the mod jar, one png per style under - * `assets/jeanaddons/textures/cape/`. [STYLES] is the single source of truth - * for the `cape-style` choice option and must stay in sync with the files in - * that folder. - * - * The bundled files are classic 64x32 MineCon-era cape exports, padded to - * 64x64 before shipping: the vanilla cape model (`PlayerCapeModel`) declares a - * 64x64 UV space, so the padding keeps every cape texel at the UV position the - * model actually samples. - */ -object CapeTextures { - - val STYLES: List<String> = listOf("2011", "2012", "2013", "2015", "2016") - - private val cache = ConcurrentHashMap<String, ClientAsset.Texture>() - - /** - * The skin-side texture asset for [style], or `null` if the style is not - * one of [STYLES]. Assets are immutable records, so they are built once - * and cached; the vanilla `ResourceTexture(id)` convention resolves to - * `textures/<path>.png`, which the texture manager lazy-loads from the - * mod's resources on first bind. - */ - @JvmStatic - fun texture(style: String): ClientAsset.Texture? { - if (style !in STYLES) return null - return cache.computeIfAbsent(style) { - ClientAsset.ResourceTexture(Identifier.fromNamespaceAndPath(JeanAddonsMod.MOD_ID, "cape/$it")) - } - } -} @@ -1,29 +0,0 @@ -package dev.chatfatigue.feature - -/** - * A single choice setting, for features that pick one of a fixed set of values - * rather than a toggle or a number. Parsed from the same INI format as - * [Feature] entries; values outside [allowedValues] log a warning and fall - * back to [defaultValue]. - * - * @property id stable, unique, kebab-case identifier used as the config key - * @property category grouping; its display name is the config section name - * @property description one human sentence, shown in the config file comment - * @property defaultValue value used when the config file has no entry for this option - * @property allowedValues every accepted value, compared case-insensitively - */ -abstract class ChoiceOption( - val id: String, - val category: FeatureCategory, - val description: String, - val defaultValue: String, - val allowedValues: Set<String>, -) { - - /** - * Effective state. Written exactly once by [FeatureManager] during config - * load; until then it holds [defaultValue]. - */ - var value: String = defaultValue - internal set -} @@ -1,13 +1,14 @@ package dev.chatfatigue.feature -import dev.chatfatigue.cape.CapeTextures +import dev.chatfatigue.JeanAddonsMod import dev.chatfatigue.feature.impl.chat.ChatHistorySize import dev.chatfatigue.feature.impl.chat.CopyChatLine -import dev.chatfatigue.feature.impl.visual.CapeStyle import dev.chatfatigue.feature.impl.visual.ForceCape +import dev.chatfatigue.feature.impl.visual.OldCapeMovement import dev.chatfatigue.feature.impl.visual.OptifineCapeFlow import dev.chatfatigue.feature.impl.visual.RemoveTextShadow import net.minecraft.core.ClientAsset +import net.minecraft.resources.Identifier /** * Typed static bridge from Java mixin code into [FeatureManager]. Every method @@ -17,6 +18,16 @@ import net.minecraft.core.ClientAsset */ object FeatureAccess { + /** + * The single cape bundled with the mod jar, at + * `assets/jeanaddons/textures/cape/2016.png`: the original 64x32 (1.8.9-era + * MineCon 2016 export), used as-is. The vanilla cape model declares a + * 64x64 layer with `texScaleV` 0.5, which makes its effective UV space + * 64x32 - exactly this classic cape format, so no re-layout is needed. + */ + private val FORCED_CAPE: ClientAsset.Texture = + ClientAsset.ResourceTexture(Identifier.fromNamespaceAndPath(JeanAddonsMod.MOD_ID, "cape/2016")) + /** @return whether Remove Text Shadow is enabled; `false` before config load. */ @JvmStatic fun isRemoveTextShadowEnabled(): Boolean = FeatureManager.isEnabled(RemoveTextShadow.id) @@ -33,15 +44,16 @@ object FeatureAccess { @JvmStatic fun isOptifineCapeFlowEnabled(): Boolean = FeatureManager.isEnabled(OptifineCapeFlow.id) + /** @return whether Old Cape Movement is enabled; `false` before config load. */ + @JvmStatic + fun isOldCapeMovementEnabled(): Boolean = FeatureManager.isEnabled(OldCapeMovement.id) + /** - * @return the cape texture asset force-cape should draw on the local - * player, or `null` when the feature is off (also before config load), in - * which case the vanilla skin cape is left untouched. + * @return the bundled cape force-cape should draw on the local player, or + * `null` when the feature is off (also before config load), in which case + * the vanilla skin cape is left untouched. */ @JvmStatic - fun forcedCapeTexture(): ClientAsset.Texture? { - if (!FeatureManager.isEnabled(ForceCape.id)) return null - val style = FeatureManager.getChoice(CapeStyle.id, CapeStyle.defaultValue) - return CapeTextures.texture(style) - } + fun forcedCapeTexture(): ClientAsset.Texture? = + if (FeatureManager.isEnabled(ForceCape.id)) FORCED_CAPE else null } @@ -4,16 +4,15 @@ import dev.chatfatigue.JeanAddonsMod import dev.chatfatigue.config.ConfigManager import dev.chatfatigue.feature.impl.chat.ChatHistorySize import dev.chatfatigue.feature.impl.chat.CopyChatLine -import dev.chatfatigue.feature.impl.visual.CapeStyle import dev.chatfatigue.feature.impl.visual.ForceCape +import dev.chatfatigue.feature.impl.visual.OldCapeMovement import dev.chatfatigue.feature.impl.visual.OptifineCapeFlow import dev.chatfatigue.feature.impl.visual.RemoveTextShadow /** - * Single registry of every feature, numeric option, and choice option. Adding - * a new entry is: one new class under `feature/impl/<category>/`, one new entry - * in [features], [intOptions], or [choiceOptions], and one commented block in - * `assets/jeanaddons/default.conf`. + * Single registry of every feature and numeric option. Adding a new entry is: + * one new class under `feature/impl/<category>/`, one new entry in [features] + * or [intOptions], and one commented block in `assets/jeanaddons/default.conf`. */ object FeatureManager { @@ -21,6 +20,7 @@ object FeatureManager { private val features: List<Feature> = listOf( RemoveTextShadow, OptifineCapeFlow, + OldCapeMovement, ForceCape, CopyChatLine, ) @@ -30,11 +30,6 @@ object FeatureManager { ChatHistorySize, ) - /** Every fixed-choice option the mod ships. */ - private val choiceOptions: List<ChoiceOption> = listOf( - CapeStyle, - ) - /** Guard for class-load-order edge cases: lookups answer their fallback until [init] completed. */ @Volatile private var initialized = false @@ -59,11 +54,6 @@ object FeatureManager { intOptions.size, intOptions.joinToString { "${it.id}=${it.value}" }, ) - JeanAddonsMod.logger.info( - "Loaded {} choice option(s): {}", - choiceOptions.size, - choiceOptions.joinToString { "${it.id}=${it.value}" }, - ) } private fun apply(parsed: Map<String, Map<String, String>>) { @@ -103,20 +93,6 @@ object FeatureManager { continue } - val choice = choiceOptions.find { it.category.displayName == section && it.id == key } - if (choice != null) { - val normalized = rawValue.trim().lowercase() - if (normalized in choice.allowedValues) { - choice.value = normalized - } else { - JeanAddonsMod.logger.warn( - "Config: invalid value '{}' for '{}', expected one of {}; using default ({}).", - rawValue, key, choice.allowedValues.joinToString(" | "), choice.defaultValue, - ) - } - continue - } - JeanAddonsMod.logger.warn("Config: unknown entry '{}' in section [{}], ignored.", key, section) } } @@ -141,14 +117,4 @@ object FeatureManager { if (!initialized) return fallback return intOptions.find { it.id == optionId }?.value ?: fallback } - - /** - * Cheap static lookup for mixin code. Returns [fallback] for unknown ids - * and for any call made before [init] completed. - */ - @JvmStatic - fun getChoice(optionId: String, fallback: String): String { - if (!initialized) return fallback - return choiceOptions.find { it.id == optionId }?.value ?: fallback - } } @@ -1,18 +0,0 @@ -package dev.chatfatigue.feature.impl.visual - -import dev.chatfatigue.cape.CapeTextures -import dev.chatfatigue.feature.ChoiceOption -import dev.chatfatigue.feature.FeatureCategory - -/** - * Which bundled cape `force-cape` puts on your player. The styles are the - * MineCon-era designs shipped with the mod; [CapeTextures.STYLES] is the - * single source of truth for the accepted values. - */ -object CapeStyle : ChoiceOption( - id = "cape-style", - category = FeatureCategory.VISUAL, - description = "Which bundled cape force-cape puts on your player.", - defaultValue = "2016", - allowedValues = CapeTextures.STYLES.toSet(), -) @@ -4,17 +4,17 @@ import dev.chatfatigue.feature.Feature import dev.chatfatigue.feature.FeatureCategory /** - * Force Cape: always renders your own player with the cape selected by - * `cape-style`, even if your account has none; while enabled it also replaces - * a cape you already own. Only the local player is affected. Vanilla's own - * checks keep working: the cape still hides for invisible players, hidden cape - * model parts, and a worn elytra. Implemented by - * `dev.chatfatigue.mixin.CapeLayerMixin`, drawing the textures from - * `dev.chatfatigue.cape.CapeTextures`. + * Force Cape: always renders your own player with the bundled 2016 MineCon + * cape, even if your account has none; while enabled it also replaces a cape + * you already own. Only the local player's rendering is affected - what other + * players see comes from their own clients, and everyone else in your view + * keeps their own cape. Vanilla's own guards keep working: the cape still hides + * for invisible players, hidden cape model parts, and a worn elytra. + * Implemented by `dev.chatfatigue.mixin.CapeLayerMixin`. */ object ForceCape : Feature( id = "force-cape", category = FeatureCategory.VISUAL, - description = "Always renders your own player with the cape selected by cape-style, even without one.", + description = "Always renders your own player with the bundled 2016 MineCon cape, even without one.", defaultEnabled = true, ) @@ -0,0 +1,20 @@ +package dev.chatfatigue.feature.impl.visual + +import dev.chatfatigue.feature.Feature +import dev.chatfatigue.feature.FeatureCategory + +/** + * Old Cape Movement (Animatium's [1.12.2] Old Cape Movement): changes the cape + * movement to how it was in 1.12.x and earlier - the body yaw behind the cloak + * direction is interpolated with a plain lerp instead of an angle-aware one, + * the cape lean is not scaled down while elytra-flying, and the cape's side + * lean is left unclamped. Independent of [OptifineCapeFlow], which only removes + * the 150-degree forward-lean cap; both options can be enabled at once. + * Implemented by `dev.chatfatigue.mixin.AvatarRendererMixin`. + */ +object OldCapeMovement : Feature( + id = "old-cape-movement", + category = FeatureCategory.VISUAL, + description = "Changes the cape movement to how it was in 1.12.x and earlier.", + defaultEnabled = false, +) @@ -32,21 +32,26 @@ remove-text-shadow = true # Valid values: true | false optifine-cape-flow = true +# Feature: Old Cape Movement +# Changes the cape movement to how it was in 1.12.x and earlier: the +# body yaw behind the cloak direction is interpolated linearly, the +# cape lean is not scaled down while elytra-flying, and the cape's +# side lean is not clamped. This is independent of OptiFine Cape +# Flow above - that one only removes the 150-degree forward-lean +# cap; both can be enabled at once. +# Valid values: true | false +old-cape-movement = false + # Feature: Force Cape -# Always renders your own player with the cape selected by cape-style, +# Always renders your own player with the bundled 2016 MineCon cape, # even if your account has none; while enabled it also replaces a cape -# you already own. Only your own player is affected - everyone else -# keeps their own cape. The cape still hides when the player is -# invisible, has an elytra worn, or has the cape model part hidden. +# you already own. Only your own view of your own player is affected - +# everyone else keeps their own cape. The cape still hides when the +# player is invisible, has an elytra worn, or has the cape model part +# hidden. # Valid values: true | false force-cape = true -# Option: Cape Style -# Which bundled cape force-cape puts on your player. The bundled -# capes are the MineCon-era designs shipped with the mod. -# Valid values: 2011 | 2012 | 2013 | 2015 | 2016 -cape-style = 2016 - [Chat] # Feature: Copy Chat Line |
optifine cape animation and add 2016 cape查看变更README.md | 30 ++++++++++++++++++++++++++---- conf/jeanaddons.conf | 24 ++++++++++++++++++++++++ src/main/java/dev/chatfatigue/mixin/AvatarRendererMixin.java | 29 +++++++++++++++++++++++++++++ src/main/java/dev/chatfatigue/mixin/CapeLayerMixin.java | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/cape/CapeTextures.kt | 39 +++++++++++++++++++++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/feature/ChoiceOption.kt | 29 +++++++++++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt | 21 +++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt | 48 ++++++++++++++++++++++++++++++++++++++++++++---- src/main/kotlin/dev/chatfatigue/feature/impl/visual/CapeStyle.kt | 18 ++++++++++++++++++ src/main/kotlin/dev/chatfatigue/feature/impl/visual/ForceCape.kt | 20 ++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/feature/impl/visual/OptifineCapeFlow.kt | 18 ++++++++++++++++++ src/main/resources/assets/jeanaddons/default.conf | 24 ++++++++++++++++++++++++ src/main/resources/assets/jeanaddons/textures/cape/2011.png | Bin 0 -> 541 bytes src/main/resources/assets/jeanaddons/textures/cape/2012.png | Bin 0 -> 599 bytes src/main/resources/assets/jeanaddons/textures/cape/2013.png | Bin 0 -> 673 bytes src/main/resources/assets/jeanaddons/textures/cape/2015.png | Bin 0 -> 678 bytes src/main/resources/assets/jeanaddons/textures/cape/2016.png | Bin 0 -> 551 bytes src/main/resources/jeanaddons.mixins.json | 2 ++ 18 files changed, 351 insertions(+), 8 deletions(-) @@ -15,9 +15,16 @@ dedicated server (`"environment": "client"`). | Feature | Category | Default | Effect | | --- | --- | --- | --- | | `remove-text-shadow` | Visual | `true` | Strips the drop shadow from all text drawn through the game's font renderer: GUIs, HUD, chat, tooltips, scoreboard sidebar, action bar, item name pop-ups, entity name tags, the F3 debug overlay, and other mods' HUD text drawn via the vanilla font classes. | +| `optifine-cape-flow` | Visual | `true` | Restores OptiFine's cape movement. Vanilla clamps how far the cape may lean against the player (150 degrees), pinning it to the back during fast movement and long falls; removing the cap lets the cape lean freely for every player you see, exactly like OptiFine's cape physics. | +| `force-cape` | Visual | `true` | Always renders your own player with the cape selected by `cape-style`, even if your account has none; while enabled it also replaces a cape you already own. Only your own player is affected. The vanilla guards still apply: the cape hides for invisible players, hidden cape model parts, and a worn elytra. | | `copy-chat-line` | Chat | `true` | Right-click a chat line to copy the whole message it belongs to, as plain text, to the system clipboard. A green confirmation is briefly drawn in the bottom-right corner. Vanilla ignores right-clicks in the chat screen, so nothing vanilla is shadowed. | | `chat-history-size` | Chat | `1000` | How many chat messages the client keeps in memory and can scroll back through, counting the lines a long message wraps into. Vanilla hard-caps this at 100. | +Besides the toggles above, one choice option ships: `cape-style` (Visual, +default `2016`) selects which bundled cape `force-cape` draws — the MineCon-era +designs `2011 | 2012 | 2013 | 2015 | 2016`, bundled as +`assets/jeanaddons/textures/cape/<year>.png` inside the jar. + **Known limitation (remove-text-shadow):** mods that render text with their own font engine (for example NanoVG-based HUDs, like Odin's custom HUD) bypass `Font` entirely and cannot be affected. Everything using the vanilla @@ -29,6 +36,20 @@ and consumes the click. `chat-history-size` widens both vanilla caps (stored messages and display lines) to the same number, which is what lets scrolling reach the extra history; values beyond the newest `10000` are clamped. +**Notes (cape features):** both cape tweaks are ported from +[Animatium](https://github.com/mixces/Animatium) (its `[OF] Disable Cape +Lean` option) and stay purely client-side render changes. `optifine-cape-flow` +removes only the 150-degree bound on the cape lean computed in +`AvatarRenderer#extractCapeState`; every other part of the vanilla cape math +is untouched. `force-cape` swaps the local player's cape texture asset for the +selected bundled one inside `CapeLayer#submit` and restores it before the +method returns, so the skin object is never permanently modified — in +particular the mod never claims you own a cape anywhere outside rendering. The +bundled textures are the classic 64x32 MineCon-era cape exports from +`reference/cape/`, padded to 64x64 to match the UV space the vanilla cape +model declares (`PlayerCapeModel`); values outside the bundled set fall back +to `2016`. + ## Directory layout | Path | Purpose | @@ -76,11 +97,12 @@ self-documenting text file: Three steps: 1. Create one new class under `src/main/kotlin/dev/chatfatigue/feature/impl/<category>/` - extending `Feature` (a toggle) or `IntOption` (a numeric setting); pick a + extending `Feature` (a toggle), `IntOption` (a numeric setting), or + `ChoiceOption` (one value out of a fixed set); pick a stable, unique kebab-case `id`. -2. Add one line to the `features` or `intOptions` list in `FeatureManager` - (plus, if the mixin side needs it, one `@JvmStatic` accessor in - `FeatureAccess`). +2. Add one line to the `features`, `intOptions`, or `choiceOptions` list in + `FeatureManager` (plus, if the mixin side needs it, one `@JvmStatic` + accessor in `FeatureAccess`). 3. Add one commented block for the feature to `src/main/resources/assets/jeanaddons/default.conf` (under the right `[Section]`, with its valid values documented). @@ -23,6 +23,30 @@ # Valid values: true | false remove-text-shadow = true +# Feature: OptiFine Cape Flow +# Restores OptiFine's cape movement. The vanilla game caps how far the +# cape may lean against the player at 150 degrees, which pins the cape +# to the player's back during fast movement and long falls; OptiFine +# leaves the lean unbounded so the cape flows freely. This removes +# that cap for every player you see. +# Valid values: true | false +optifine-cape-flow = true + +# Feature: Force Cape +# Always renders your own player with the cape selected by cape-style, +# even if your account has none; while enabled it also replaces a cape +# you already own. Only your own player is affected - everyone else +# keeps their own cape. The cape still hides when the player is +# invisible, has an elytra worn, or has the cape model part hidden. +# Valid values: true | false +force-cape = true + +# Option: Cape Style +# Which bundled cape force-cape puts on your player. The bundled +# capes are the MineCon-era designs shipped with the mod. +# Valid values: 2011 | 2012 | 2013 | 2015 | 2016 +cape-style = 2016 + [Chat] # Feature: Copy Chat Line @@ -0,0 +1,29 @@ +package dev.chatfatigue.mixin; + +import dev.chatfatigue.feature.FeatureAccess; +import net.minecraft.client.renderer.entity.player.AvatarRenderer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +/** + * OptiFine Cape Flow: inside {@code AvatarRenderer#extractCapeState} vanilla + * clamps how far the cape may lean against the player to 150 degrees, which + * pins the cape to the player's back during fast movement and long falls. + * OptiFine leaves the lean unbounded; raising the clamp's upper bound to + * {@code Float.MAX_VALUE} restores that movement while leaving every other + * part of the cape math untouched. + */ +@Mixin(AvatarRenderer.class) +public class AvatarRendererMixin { + + // ordinal 1 is the capeLean clamp, Mth.clamp(lean, 0, 150); index 2 is its upper bound. + @ModifyArg( + method = "extractCapeState", + at = @At(value = "INVOKE", target = "Lnet/minecraft/util/Mth;clamp(FFF)F", ordinal = 1), + index = 2 + ) + private float jeanaddons$uncapCapeLean(float upperBound) { + return FeatureAccess.isOptifineCapeFlowEnabled() ? Float.MAX_VALUE : upperBound; + } +} @@ -0,0 +1,57 @@ +package dev.chatfatigue.mixin; + +import com.mojang.blaze3d.vertex.PoseStack; +import dev.chatfatigue.feature.FeatureAccess; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.layers.CapeLayer; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import net.minecraft.core.ClientAsset; +import net.minecraft.world.entity.player.PlayerSkin; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Force Cape: while the cape layer renders the local player, swap the skin's + * cape asset for the configured one and restore the original before the method + * returns, so no other code observes the patched skin. Only the local player's + * render state (identified by entity id) is touched; other players keep their + * own capes. Vanilla's own guards keep working: the cape still hides for + * invisible players, hidden cape model parts, and a worn elytra. + */ +@Mixin(CapeLayer.class) +public class CapeLayerMixin { + + private static final String SUBMIT = + "submit(Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;ILnet/minecraft/client/renderer/entity/state/AvatarRenderState;FF)V"; + + @Unique + private PlayerSkin jeanaddons$originalSkin; + + @Inject(method = SUBMIT, at = @At("HEAD")) + private void jeanaddons$applyForcedCape(PoseStack poseStack, SubmitNodeCollector collector, int lightCoords, + AvatarRenderState state, float yRot, float xRot, CallbackInfo ci) { + PlayerSkin skin = state.skin; + if (skin == null) return; + var player = Minecraft.getInstance().player; + if (player == null || state.id != player.getId()) return; + ClientAsset.Texture forced = FeatureAccess.forcedCapeTexture(); + if (forced == null) return; + jeanaddons$originalSkin = skin; + state.skin = new PlayerSkin(skin.body(), forced, skin.elytra(), skin.model(), skin.secure()); + } + + // Every normal exit must restore the original skin; AvatarRenderState objects + // are recycled between frames, so a stale patched skin must never leak out. + @Inject(method = SUBMIT, at = @At("RETURN")) + private void jeanaddons$restoreSkin(PoseStack poseStack, SubmitNodeCollector collector, int lightCoords, + AvatarRenderState state, float yRot, float xRot, CallbackInfo ci) { + if (jeanaddons$originalSkin != null) { + state.skin = jeanaddons$originalSkin; + jeanaddons$originalSkin = null; + } + } +} @@ -0,0 +1,39 @@ +package dev.chatfatigue.cape + +import dev.chatfatigue.JeanAddonsMod +import net.minecraft.core.ClientAsset +import net.minecraft.resources.Identifier +import java.util.concurrent.ConcurrentHashMap + +/** + * The capes bundled with the mod jar, one png per style under + * `assets/jeanaddons/textures/cape/`. [STYLES] is the single source of truth + * for the `cape-style` choice option and must stay in sync with the files in + * that folder. + * + * The bundled files are classic 64x32 MineCon-era cape exports, padded to + * 64x64 before shipping: the vanilla cape model (`PlayerCapeModel`) declares a + * 64x64 UV space, so the padding keeps every cape texel at the UV position the + * model actually samples. + */ +object CapeTextures { + + val STYLES: List<String> = listOf("2011", "2012", "2013", "2015", "2016") + + private val cache = ConcurrentHashMap<String, ClientAsset.Texture>() + + /** + * The skin-side texture asset for [style], or `null` if the style is not + * one of [STYLES]. Assets are immutable records, so they are built once + * and cached; the vanilla `ResourceTexture(id)` convention resolves to + * `textures/<path>.png`, which the texture manager lazy-loads from the + * mod's resources on first bind. + */ + @JvmStatic + fun texture(style: String): ClientAsset.Texture? { + if (style !in STYLES) return null + return cache.computeIfAbsent(style) { + ClientAsset.ResourceTexture(Identifier.fromNamespaceAndPath(JeanAddonsMod.MOD_ID, "cape/$it")) + } + } +} @@ -0,0 +1,29 @@ +package dev.chatfatigue.feature + +/** + * A single choice setting, for features that pick one of a fixed set of values + * rather than a toggle or a number. Parsed from the same INI format as + * [Feature] entries; values outside [allowedValues] log a warning and fall + * back to [defaultValue]. + * + * @property id stable, unique, kebab-case identifier used as the config key + * @property category grouping; its display name is the config section name + * @property description one human sentence, shown in the config file comment + * @property defaultValue value used when the config file has no entry for this option + * @property allowedValues every accepted value, compared case-insensitively + */ +abstract class ChoiceOption( + val id: String, + val category: FeatureCategory, + val description: String, + val defaultValue: String, + val allowedValues: Set<String>, +) { + + /** + * Effective state. Written exactly once by [FeatureManager] during config + * load; until then it holds [defaultValue]. + */ + var value: String = defaultValue + internal set +} @@ -1,8 +1,13 @@ package dev.chatfatigue.feature +import dev.chatfatigue.cape.CapeTextures import dev.chatfatigue.feature.impl.chat.ChatHistorySize import dev.chatfatigue.feature.impl.chat.CopyChatLine +import dev.chatfatigue.feature.impl.visual.CapeStyle +import dev.chatfatigue.feature.impl.visual.ForceCape +import dev.chatfatigue.feature.impl.visual.OptifineCapeFlow import dev.chatfatigue.feature.impl.visual.RemoveTextShadow +import net.minecraft.core.ClientAsset /** * Typed static bridge from Java mixin code into [FeatureManager]. Every method @@ -23,4 +28,20 @@ object FeatureAccess { /** @return the chat history cap; the vanilla cap (100) before config load. */ @JvmStatic fun getChatHistorySize(): Int = FeatureManager.getInt(ChatHistorySize.id, ChatHistorySize.VANILLA) + + /** @return whether OptiFine Cape Flow is enabled; `false` before config load. */ + @JvmStatic + fun isOptifineCapeFlowEnabled(): Boolean = FeatureManager.isEnabled(OptifineCapeFlow.id) + + /** + * @return the cape texture asset force-cape should draw on the local + * player, or `null` when the feature is off (also before config load), in + * which case the vanilla skin cape is left untouched. + */ + @JvmStatic + fun forcedCapeTexture(): ClientAsset.Texture? { + if (!FeatureManager.isEnabled(ForceCape.id)) return null + val style = FeatureManager.getChoice(CapeStyle.id, CapeStyle.defaultValue) + return CapeTextures.texture(style) + } } @@ -4,26 +4,37 @@ import dev.chatfatigue.JeanAddonsMod import dev.chatfatigue.config.ConfigManager import dev.chatfatigue.feature.impl.chat.ChatHistorySize import dev.chatfatigue.feature.impl.chat.CopyChatLine +import dev.chatfatigue.feature.impl.visual.CapeStyle +import dev.chatfatigue.feature.impl.visual.ForceCape +import dev.chatfatigue.feature.impl.visual.OptifineCapeFlow import dev.chatfatigue.feature.impl.visual.RemoveTextShadow /** - * Single registry of every feature and numeric option. Adding a new entry is: - * one new class under `feature/impl/<category>/`, one new entry in [features] - * or [intOptions], and one commented block in `assets/jeanaddons/default.conf`. + * Single registry of every feature, numeric option, and choice option. Adding + * a new entry is: one new class under `feature/impl/<category>/`, one new entry + * in [features], [intOptions], or [choiceOptions], and one commented block in + * `assets/jeanaddons/default.conf`. */ object FeatureManager { /** Every toggleable feature the mod ships, in one visible list. */ private val features: List<Feature> = listOf( RemoveTextShadow, + OptifineCapeFlow, + ForceCape, CopyChatLine, ) - /** Every numeric option the mod ships, in one visible list. */ + /** Every numeric option the mod ships. */ private val intOptions: List<IntOption> = listOf( ChatHistorySize, ) + /** Every fixed-choice option the mod ships. */ + private val choiceOptions: List<ChoiceOption> = listOf( + CapeStyle, + ) + /** Guard for class-load-order edge cases: lookups answer their fallback until [init] completed. */ @Volatile private var initialized = false @@ -48,6 +59,11 @@ object FeatureManager { intOptions.size, intOptions.joinToString { "${it.id}=${it.value}" }, ) + JeanAddonsMod.logger.info( + "Loaded {} choice option(s): {}", + choiceOptions.size, + choiceOptions.joinToString { "${it.id}=${it.value}" }, + ) } private fun apply(parsed: Map<String, Map<String, String>>) { @@ -87,6 +103,20 @@ object FeatureManager { continue } + val choice = choiceOptions.find { it.category.displayName == section && it.id == key } + if (choice != null) { + val normalized = rawValue.trim().lowercase() + if (normalized in choice.allowedValues) { + choice.value = normalized + } else { + JeanAddonsMod.logger.warn( + "Config: invalid value '{}' for '{}', expected one of {}; using default ({}).", + rawValue, key, choice.allowedValues.joinToString(" | "), choice.defaultValue, + ) + } + continue + } + JeanAddonsMod.logger.warn("Config: unknown entry '{}' in section [{}], ignored.", key, section) } } @@ -111,4 +141,14 @@ object FeatureManager { if (!initialized) return fallback return intOptions.find { it.id == optionId }?.value ?: fallback } + + /** + * Cheap static lookup for mixin code. Returns [fallback] for unknown ids + * and for any call made before [init] completed. + */ + @JvmStatic + fun getChoice(optionId: String, fallback: String): String { + if (!initialized) return fallback + return choiceOptions.find { it.id == optionId }?.value ?: fallback + } } @@ -0,0 +1,18 @@ +package dev.chatfatigue.feature.impl.visual + +import dev.chatfatigue.cape.CapeTextures +import dev.chatfatigue.feature.ChoiceOption +import dev.chatfatigue.feature.FeatureCategory + +/** + * Which bundled cape `force-cape` puts on your player. The styles are the + * MineCon-era designs shipped with the mod; [CapeTextures.STYLES] is the + * single source of truth for the accepted values. + */ +object CapeStyle : ChoiceOption( + id = "cape-style", + category = FeatureCategory.VISUAL, + description = "Which bundled cape force-cape puts on your player.", + defaultValue = "2016", + allowedValues = CapeTextures.STYLES.toSet(), +) @@ -0,0 +1,20 @@ +package dev.chatfatigue.feature.impl.visual + +import dev.chatfatigue.feature.Feature +import dev.chatfatigue.feature.FeatureCategory + +/** + * Force Cape: always renders your own player with the cape selected by + * `cape-style`, even if your account has none; while enabled it also replaces + * a cape you already own. Only the local player is affected. Vanilla's own + * checks keep working: the cape still hides for invisible players, hidden cape + * model parts, and a worn elytra. Implemented by + * `dev.chatfatigue.mixin.CapeLayerMixin`, drawing the textures from + * `dev.chatfatigue.cape.CapeTextures`. + */ +object ForceCape : Feature( + id = "force-cape", + category = FeatureCategory.VISUAL, + description = "Always renders your own player with the cape selected by cape-style, even without one.", + defaultEnabled = true, +) @@ -0,0 +1,18 @@ +package dev.chatfatigue.feature.impl.visual + +import dev.chatfatigue.feature.Feature +import dev.chatfatigue.feature.FeatureCategory + +/** + * OptiFine Cape Flow: restores OptiFine's cape movement. Vanilla clamps how + * far the cape may lean against the player (150 degrees), which pins the cape + * to the player's back during fast movement and long falls; OptiFine leaves + * the lean unbounded so the cape flows freely. Implemented by + * `dev.chatfatigue.mixin.AvatarRendererMixin`. + */ +object OptifineCapeFlow : Feature( + id = "optifine-cape-flow", + category = FeatureCategory.VISUAL, + description = "Removes the vanilla cap on how far the cape may lean, restoring OptiFine's free-flowing cape movement.", + defaultEnabled = true, +) @@ -23,6 +23,30 @@ # Valid values: true | false remove-text-shadow = true +# Feature: OptiFine Cape Flow +# Restores OptiFine's cape movement. The vanilla game caps how far the +# cape may lean against the player at 150 degrees, which pins the cape +# to the player's back during fast movement and long falls; OptiFine +# leaves the lean unbounded so the cape flows freely. This removes +# that cap for every player you see. +# Valid values: true | false +optifine-cape-flow = true + +# Feature: Force Cape +# Always renders your own player with the cape selected by cape-style, +# even if your account has none; while enabled it also replaces a cape +# you already own. Only your own player is affected - everyone else +# keeps their own cape. The cape still hides when the player is +# invisible, has an elytra worn, or has the cape model part hidden. +# Valid values: true | false +force-cape = true + +# Option: Cape Style +# Which bundled cape force-cape puts on your player. The bundled +# capes are the MineCon-era designs shipped with the mod. +# Valid values: 2011 | 2012 | 2013 | 2015 | 2016 +cape-style = 2016 + [Chat] # Feature: Copy Chat Line @@ -7,6 +7,8 @@ "defaultRequire": 1 }, "client": [ + "AvatarRendererMixin", + "CapeLayerMixin", "ChatComponentAccessor", "ChatComponentMixin", "ChatScreenMixin", |
chat ui copy and history change查看变更README.md | 58 +++++++++++++++++++++++++++++++++++++---------------------
build.gradle.kts | 4 ++--
conf/{chataddons.conf => jeanaddons.conf} | 21 ++++++++++++++++++++-
gradle.properties | 8 ++++----
settings.gradle.kts | 2 +-
src/main/java/dev/chatfatigue/mixin/ChatComponentAccessor.java | 35 +++++++++++++++++++++++++++++++++++
src/main/java/dev/chatfatigue/mixin/ChatComponentMixin.java | 37 +++++++++++++++++++++++++++++++++++++
src/main/java/dev/chatfatigue/mixin/ChatScreenMixin.java | 37 +++++++++++++++++++++++++++++++++++++
src/main/java/dev/chatfatigue/mixin/FontMixin.java | 4 ++--
src/main/kotlin/dev/chatfatigue/ChatAddonsMod.kt | 25 -------------------------
src/main/kotlin/dev/chatfatigue/JeanAddonsMod.kt | 38 ++++++++++++++++++++++++++++++++++++++
src/main/kotlin/dev/chatfatigue/chat/ChatHudHitTester.kt | 49 +++++++++++++++++++++++++++++++++++++++++++++++++
src/main/kotlin/dev/chatfatigue/chat/ChatLineCopier.kt | 28 ++++++++++++++++++++++++++++
src/main/kotlin/dev/chatfatigue/chat/CopyFeedbackHud.kt | 43 +++++++++++++++++++++++++++++++++++++++++++
src/main/kotlin/dev/chatfatigue/config/ConfigManager.kt | 18 +++++++++---------
src/main/kotlin/dev/chatfatigue/config/ConfigParser.kt | 2 +-
src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt | 16 +++++++++++++---
src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt | 5 ++++-
src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
src/main/kotlin/dev/chatfatigue/feature/IntOption.kt | 30 ++++++++++++++++++++++++++++++
src/main/kotlin/dev/chatfatigue/feature/impl/chat/ChatHistorySize.kt | 22 ++++++++++++++++++++++
src/main/kotlin/dev/chatfatigue/feature/impl/chat/CopyChatLine.kt | 21 +++++++++++++++++++++
src/main/resources/assets/{chataddons => jeanaddons}/default.conf | 21 ++++++++++++++++++++-
src/main/resources/fabric.mod.json | 13 ++++++++++---
src/main/resources/{chataddons.mixins.json => jeanaddons.mixins.json} | 3 +++
25 files changed, 530 insertions(+), 93 deletions(-)@@ -1,22 +1,33 @@ -# ChatAddons +# JeanAddons A lightweight, personal-use **client-side** [Fabric](https://fabricmc.net/) mod for -Minecraft **26.1.2**: a small collection of visual tweaks. +Minecraft **26.1.2**: a small collection of rendering tweaks and chat-screen +conveniences. -**Core contract — visual-only.** Every feature is a purely cosmetic, client-side -rendering change. The mod **never** alters any packet the client sends, nor any -input, movement, or gameplay behavior. It changes how things are drawn, nothing -else, and it never loads on a dedicated server (`"environment": "client"`). +**Core contract — client-side only.** Every feature is a purely client-side +change: cosmetic rendering tweaks plus conveniences inside the chat screen. +The mod **never** alters any packet the client sends, nor any input outside +the chat screen, movement, or gameplay behavior, and it never loads on a +dedicated server (`"environment": "client"`). ## Features | Feature | Category | Default | Effect | | --- | --- | --- | --- | | `remove-text-shadow` | Visual | `true` | Strips the drop shadow from all text drawn through the game's font renderer: GUIs, HUD, chat, tooltips, scoreboard sidebar, action bar, item name pop-ups, entity name tags, the F3 debug overlay, and other mods' HUD text drawn via the vanilla font classes. | +| `copy-chat-line` | Chat | `true` | Right-click a chat line to copy the whole message it belongs to, as plain text, to the system clipboard. A green confirmation is briefly drawn in the bottom-right corner. Vanilla ignores right-clicks in the chat screen, so nothing vanilla is shadowed. | +| `chat-history-size` | Chat | `1000` | How many chat messages the client keeps in memory and can scroll back through, counting the lines a long message wraps into. Vanilla hard-caps this at 100. | -**Known limitation:** mods that render text with their own font engine (for -example NanoVG-based HUDs, like Odin's custom HUD) bypass `Font` entirely and -cannot be affected. Everything using the vanilla font pipeline is covered. +**Known limitation (remove-text-shadow):** mods that render text with their +own font engine (for example NanoVG-based HUDs, like Odin's custom HUD) +bypass `Font` entirely and cannot be affected. Everything using the vanilla +font pipeline is covered. + +**Notes (chat features):** `copy-chat-line` copies the *whole* logical message +under the cursor, not just the wrapped line, strips legacy `§` color codes, +and consumes the click. `chat-history-size` widens both vanilla caps (stored +messages and display lines) to the same number, which is what lets scrolling +reach the extra history; values beyond the newest `10000` are clamped. ## Directory layout @@ -24,14 +35,14 @@ cannot be affected. Everything using the vanilla font pipeline is covered. | --- | --- | | `src/` | Sources and resources, standard Gradle layout. Kotlin (`src/main/kotlin`) holds game logic; Java (`src/main/java`) holds mixin classes. | | `bin/` | Binary output. `copyJarToBin` copies the built mod jar here on every `build`. Disposable: git-ignored, safe to delete, refreshed by the next build. | -| `conf/` | Human-readable review copy of the default config. `syncDefaultConf` re-copies `src/main/resources/assets/chataddons/default.conf` to `conf/chataddons.conf` on every `build`, so the review copy can never drift from the template that ships in the jar. The jar template stays the single source of truth. | -| `reference/` | Read-only reference projects (e.g. a clone of [Odin](https://github.com/odtheking/odin)), used to mirror the Gradle/mixin setup. Not part of the build; git-ignored; never modified. | +| `conf/` | Human-readable review copy of the default config. `syncDefaultConf` re-copies `src/main/resources/assets/jeanaddons/default.conf` to `conf/jeanaddons.conf` on every `build`, so the review copy can never drift from the template that ships in the jar. The jar template stays the single source of truth. | +| `reference/` | Read-only reference projects ([Odin](https://github.com/odtheking/odin) for the Gradle/mixin setup, [chat-utils](https://github.com/tierrif/chat-utils) for the chat features' approach). Not part of the build; git-ignored; never modified. | | `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`, `gradle/wrapper/` | Gradle build. All identity values (mod id, name, version, group) and pinned dependency versions live in `gradle.properties`. | ## Building and running ```sh -./gradlew build # jar lands in bin/chataddons-<version>.jar +./gradlew build # jar lands in bin/jeanaddons-<version>.jar ./gradlew runClient # launches a dev client ``` @@ -44,8 +55,8 @@ transformed classes. There is **no config GUI**. The entire configuration is one hand-edited, self-documenting text file: -- On first launch the mod generates `<game directory>/config/chataddons.conf` - from the bundled template (`assets/chataddons/default.conf` inside the jar) +- On first launch the mod generates `<game directory>/config/jeanaddons.conf` + from the bundled template (`assets/jeanaddons/default.conf` inside the jar) and logs one line saying so. - Format: `[Section]` headers group features by category (the section name is the category display name, e.g. `[Visual]`); each feature toggle is one @@ -55,19 +66,23 @@ self-documenting text file: never rewrites the file — **restart the game to apply edits**. Deleting the file restores defaults on the next launch. - Unknown sections or keys log a warning and are ignored; missing keys fall - back to the feature's built-in default; a corrupt or unreadable file logs an - error and all defaults are used — the game never crashes over the config. + back to the feature's built-in default; numeric values outside a feature's + documented range log a warning and are clamped; a corrupt or unreadable file + logs an error and all defaults are used — the game never crashes over the + config. ## Adding a new feature Three steps: 1. Create one new class under `src/main/kotlin/dev/chatfatigue/feature/impl/<category>/` - extending `Feature` (pick a stable, unique kebab-case `id`). -2. Add one line to the `features` list in `FeatureManager` (plus, if the mixin - side needs it, one `@JvmStatic` accessor in `FeatureAccess`). + extending `Feature` (a toggle) or `IntOption` (a numeric setting); pick a + stable, unique kebab-case `id`. +2. Add one line to the `features` or `intOptions` list in `FeatureManager` + (plus, if the mixin side needs it, one `@JvmStatic` accessor in + `FeatureAccess`). 3. Add one commented block for the feature to - `src/main/resources/assets/chataddons/default.conf` (under the right + `src/main/resources/assets/jeanaddons/default.conf` (under the right `[Section]`, with its valid values documented). ## Toolchain @@ -94,7 +109,8 @@ loaders get routed onto legacy paths. Since 0.1.1 this mod declares **Historical note:** before 0.1.1 the build pinned Loom `1.16-SNAPSHOT` because the then-current Gradle `9.4.1` wrapper rejected Loom 1.17.x (built against Gradle plugin API 9.5.0). The wrapper was upgraded to `9.7.1`, which resolves -the deviation and allows following the Loom 1.17-SNAPSHOT line again. +the deviation and allows following the Loom 1.17-SNAPSHOT line again. The mod +was known as *ChatAddons* until 0.2.0. ## License @@ -69,8 +69,8 @@ tasks { val syncDefaultConf by registering(Copy::class) { description = "Syncs the bundled default config template into conf/ for review." - from("src/main/resources/assets/chataddons/default.conf") { - rename { "chataddons.conf" } + from("src/main/resources/assets/jeanaddons/default.conf") { + rename { "jeanaddons.conf" } } into(rootProject.file("conf")) } @@ -1,5 +1,5 @@ # ============================================================= -# ChatAddons - client-side visual tweaks +# JeanAddons - client-side rendering and chat conveniences # ------------------------------------------------------------- # This file was generated automatically on first launch and is # safe to edit by hand. Restart the game to apply changes. @@ -22,3 +22,22 @@ # affected - see the README.) # Valid values: true | false remove-text-shadow = true + +[Chat] + +# Feature: Copy Chat Line +# While the chat screen is open, right-click a chat line to copy +# the whole message it belongs to, as plain text, to the system +# clipboard. A short confirmation is drawn in the bottom-right +# corner of the screen. Vanilla ignores right-clicks in the chat +# screen, so no vanilla behavior is shadowed. +# Valid values: true | false +copy-chat-line = true + +# Option: Chat History Size +# How many chat messages the client keeps in memory and shows in +# the chat, including the lines a long message wraps into. The +# vanilla game keeps 100; raising it lets you scroll further back +# at the cost of some memory. +# Valid values: whole numbers from 100 to 10000 +chat-history-size = 1000 @@ -11,8 +11,8 @@ fabric_kotlin_version=1.14.1+kotlin.2.4.20 kotlin_version=2.4.20 # Mod identity -mod_id=chataddons -mod_name=ChatAddons -mod_version=0.1.1 +mod_id=jeanaddons +mod_name=JeanAddons +mod_version=0.2.0 maven_group=dev.chatfatigue -archives_base_name=chataddons +archives_base_name=jeanaddons @@ -1,4 +1,4 @@ -rootProject.name = "ChatAddons" +rootProject.name = "JeanAddons" pluginManagement { repositories { @@ -0,0 +1,35 @@ +package dev.chatfatigue.mixin; + +import java.util.List; + +import net.minecraft.client.gui.components.ChatComponent; +import net.minecraft.client.multiplayer.chat.GuiMessage; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +/** + * Read access to the private geometry the chat renderer (and therefore the + * hit-test in `ChatHudHitTester`) works with. The method names cannot contain + * the usual mixin {@code $} prefix: they are called from Kotlin, where + * {@code $} is not a valid identifier character. + */ +@Mixin(ChatComponent.class) +public interface ChatComponentAccessor { + + @Accessor("trimmedMessages") + List<GuiMessage.Line> getTrimmedMessages(); + + @Accessor("chatScrollbarPos") + int getChatScrollbarPos(); + + @Invoker("getScale") + double invokeGetScale(); + + @Invoker("getWidth") + int invokeGetWidth(); + + @Invoker("getLineHeight") + int invokeGetLineHeight(); +} @@ -0,0 +1,37 @@ +package dev.chatfatigue.mixin; + +import net.minecraft.client.gui.components.ChatComponent; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Constant; +import org.spongepowered.asm.mixin.injection.ModifyConstant; + +import dev.chatfatigue.feature.FeatureAccess; + +/** + * Chat History Size: vanilla trims both the stored messages and the wrapped + * display lines to the {@code MAX_CHAT_HISTORY} constant of 100, inlined at + * both trim loops, so the caps are widened here. Raising the line cap together + * with the message cap is what lets the scroll actually reach the extra + * messages. + */ +@Mixin(ChatComponent.class) +public abstract class ChatComponentMixin { + + @ModifyConstant( + method = "addMessageToQueue(Lnet/minecraft/client/multiplayer/chat/GuiMessage;)V", + constant = @Constant(intValue = 100) + ) + private int jeanaddons$historyCapMessages(int original) { + return FeatureAccess.getChatHistorySize(); + } + + @ModifyConstant( + method = "addMessageToDisplayQueue(Lnet/minecraft/client/multiplayer/chat/GuiMessage;)V", + constant = @Constant(intValue = 100) + ) + private int jeanaddons$historyCapLines(int original) { + return FeatureAccess.getChatHistorySize(); + } +} @@ -0,0 +1,37 @@ +package dev.chatfatigue.mixin; + +import net.minecraft.client.gui.screens.ChatScreen; +import net.minecraft.client.input.MouseButtonEvent; + +import org.lwjgl.glfw.GLFW; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import dev.chatfatigue.chat.ChatLineCopier; +import dev.chatfatigue.feature.FeatureAccess; + +/** + * Copy Chat Line: vanilla ignores every non-left click in the chat screen, so + * claiming the right button there cannot shadow any vanilla behavior. When + * the click lands on a chat line, the line's whole message is copied to the + * clipboard and the click is consumed. + */ +@Mixin(ChatScreen.class) +public abstract class ChatScreenMixin { + + @Inject( + method = "mouseClicked(Lnet/minecraft/client/input/MouseButtonEvent;Z)Z", + at = @At("HEAD"), + cancellable = true + ) + private void jeanaddons$copyChatLine(MouseButtonEvent event, boolean doubleClick, + CallbackInfoReturnable<Boolean> cir) { + if (event.button() != GLFW.GLFW_MOUSE_BUTTON_RIGHT) return; + if (!FeatureAccess.isCopyChatLineEnabled()) return; + if (ChatLineCopier.tryCopy(event.x(), event.y())) { + cir.setReturnValue(true); + } + } +} @@ -19,7 +19,7 @@ public class FontMixin { method = "prepareText(Ljava/lang/String;FFIZI)Lnet/minecraft/client/gui/Font$PreparedText;", at = @At("HEAD"), argsOnly = true, ordinal = 0 ) - private boolean chataddons$noShadowString(boolean dropShadow) { + private boolean jeanaddons$noShadowString(boolean dropShadow) { return FeatureAccess.isRemoveTextShadowEnabled() ? false : dropShadow; } @@ -28,7 +28,7 @@ public class FontMixin { method = "prepareText(Lnet/minecraft/util/FormattedCharSequence;FFIZZI)Lnet/minecraft/client/gui/Font$PreparedText;", at = @At("HEAD"), argsOnly = true, ordinal = 0 ) - private boolean chataddons$noShadowSequence(boolean dropShadow) { + private boolean jeanaddons$noShadowSequence(boolean dropShadow) { return FeatureAccess.isRemoveTextShadowEnabled() ? false : dropShadow; } } @@ -1,25 +0,0 @@ -package dev.chatfatigue - -import dev.chatfatigue.feature.FeatureManager -import net.fabricmc.api.ClientModInitializer -import org.slf4j.Logger -import org.slf4j.LoggerFactory - -/** - * Client entrypoint of ChatAddons, a collection of purely cosmetic, client-side - * rendering tweaks. The mod never alters packets, input, movement, or gameplay - * behavior; it only changes how things are drawn. - */ -object ChatAddonsMod : ClientModInitializer { - - /** Mod id, matching `fabric.mod.json` and `gradle.properties`. */ - const val MOD_ID = "chataddons" - - /** Shared logger for the whole mod. */ - val logger: Logger = LoggerFactory.getLogger("ChatAddons") - - override fun onInitializeClient() { - FeatureManager.init() - logger.info("ChatAddons initialized.") - } -} @@ -0,0 +1,38 @@ +package dev.chatfatigue + +import dev.chatfatigue.chat.CopyFeedbackHud +import dev.chatfatigue.feature.FeatureManager +import net.fabricmc.api.ClientModInitializer +import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry +import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements +import net.minecraft.resources.Identifier +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Client entrypoint of JeanAddons, a collection of client-side rendering + * tweaks and chat-screen conveniences. The mod never alters packets, + * movement, or gameplay behavior; it changes how things are drawn and adds + * conveniences inside the chat screen. + */ +object JeanAddonsMod : ClientModInitializer { + + /** Mod id, matching `fabric.mod.json` and `gradle.properties`. */ + const val MOD_ID = "jeanaddons" + + /** Shared logger for the whole mod. */ + val logger: Logger = LoggerFactory.getLogger("JeanAddons") + + override fun onInitializeClient() { + FeatureManager.init() + + // The "Copied to clipboard!" confirmation must draw above the chat it annotates. + HudElementRegistry.attachElementAfter( + VanillaHudElements.CHAT, + Identifier.fromNamespaceAndPath(MOD_ID, "copy_feedback"), + CopyFeedbackHud, + ) + + logger.info("JeanAddons initialized.") + } +} @@ -0,0 +1,49 @@ +package dev.chatfatigue.chat + +import dev.chatfatigue.mixin.ChatComponentAccessor +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.components.ChatComponent +import net.minecraft.client.multiplayer.chat.GuiMessage +import net.minecraft.util.Mth +import kotlin.math.min + +/** + * Finds the chat message under a mouse position by reproducing the chat + * area's geometry from `ChatComponent`'s renderer: the pose is scaled by the + * chat scale and shifted 4px right, and lines stack upwards from + * `floor((screenHeight - 40) / scale)` in `getLineHeight()` steps, the newest + * line at the bottom. + */ +object ChatHudHitTester { + + /** @return the message whose line is under GUI coordinates ([x], [y]), or `null`. */ + fun messageAt(chat: ChatComponent, x: Double, y: Double): GuiMessage? { + if (!chat.isChatFocused) return null + val accessor = chat as ChatComponentAccessor + + val scale = accessor.invokeGetScale() + if (scale <= 0.0) return null + + // Undo the renderer's pose: the scale, then the 4px left shift. + val chatX = x / scale - 4.0 + val chatY = y / scale + if (chatX < -4.0 || chatX > Mth.floor(accessor.invokeGetWidth() / scale)) return null + + val lineHeight = accessor.invokeGetLineHeight() + if (lineHeight <= 0) return null + + val screenHeight = Minecraft.getInstance().window.guiScaledHeight + val chatBottom = Mth.floor((screenHeight - 40) / scale) + + // Lines stack upwards from the bottom of the chat area; index 0 is the newest. + val lineIndex = Mth.floor((chatBottom - chatY) / lineHeight) + if (lineIndex < 0) return null + + val scrollPos = accessor.getChatScrollbarPos() + val trimmed = accessor.getTrimmedMessages() + val visibleLines = min(trimmed.size - scrollPos, chat.linesPerPage) + if (lineIndex >= visibleLines) return null + + return trimmed[lineIndex + scrollPos].parent() + } +} @@ -0,0 +1,28 @@ +package dev.chatfatigue.chat + +import net.minecraft.client.Minecraft +import net.minecraft.network.chat.Component + +/** + * Copies the chat message under the mouse to the system clipboard and starts + * the on-screen confirmation (see [CopyFeedbackHud]). + */ +object ChatLineCopier { + + /** Legacy `§x` codes some servers embed literally; stripped so the clipboard gets clean text. */ + private val LEGACY_FORMATTING = Regex("§[0-9a-fk-orxA-FK-ORX]") + + /** @return `true` if a chat line sat under ([x], [y]) and its text was copied. */ + @JvmStatic + fun tryCopy(x: Double, y: Double): Boolean { + val client = Minecraft.getInstance() + val message = ChatHudHitTester.messageAt(client.gui.chat, x, y) ?: return false + + client.keyboardHandler.setClipboard(plainText(message.content())) + CopyFeedbackHud.start() + return true + } + + private fun plainText(component: Component): String = + LEGACY_FORMATTING.replace(component.getString(), "").trim() +} @@ -0,0 +1,43 @@ +package dev.chatfatigue.chat + +import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElement +import net.minecraft.client.DeltaTracker +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.GuiGraphicsExtractor +import net.minecraft.client.gui.screens.ChatScreen +import net.minecraft.network.chat.Component + +/** + * Draws a short-lived "Copied to clipboard!" confirmation in the bottom-right + * corner while the chat screen is open, so a copy is never silent. Attached + * after the vanilla chat layer in `JeanAddonsMod`. + */ +object CopyFeedbackHud : HudElement { + + private const val DISPLAY_MILLIS = 2000L + private const val ARGB_GREEN = 0xFF00FF00.toInt() + + private val COPIED_TEXT: Component = Component.literal("Copied to clipboard!") + + @Volatile + private var deadline = 0L + + /** Called by [ChatLineCopier] right after a successful copy. */ + fun start() { + deadline = System.currentTimeMillis() + DISPLAY_MILLIS + } + + override fun extractRenderState(graphics: GuiGraphicsExtractor, deltaTracker: DeltaTracker) { + if (System.currentTimeMillis() >= deadline) return + val client = Minecraft.getInstance() + if (client.screen !is ChatScreen) return + + val textWidth = client.font.width(COPIED_TEXT) + graphics.text( + client.font, COPIED_TEXT, + graphics.guiWidth() - textWidth - 5, + graphics.guiHeight() - 32 - 5, + ARGB_GREEN, true, + ) + } +} @@ -1,14 +1,14 @@ package dev.chatfatigue.config -import dev.chatfatigue.ChatAddonsMod +import dev.chatfatigue.JeanAddonsMod import net.fabricmc.loader.api.FabricLoader import java.nio.file.Files import java.nio.file.Path /** - * Owns the on-disk config file, `<game directory>/config/chataddons.conf`. + * Owns the on-disk config file, `<game directory>/config/jeanaddons.conf`. * - * On first launch the bundled template `assets/chataddons/default.conf` is copied + * On first launch the bundled template `assets/jeanaddons/default.conf` is copied * out of the mod jar verbatim. The file is then parsed exactly once at startup: * there is no hot reload, no file watcher, and no write-back — the mod never * rewrites the user's file. Edits take effect on the next game restart; deleting @@ -16,8 +16,8 @@ import java.nio.file.Path */ object ConfigManager { - private const val FILE_NAME = "chataddons.conf" - private const val TEMPLATE_RESOURCE = "assets/chataddons/default.conf" + private const val FILE_NAME = "jeanaddons.conf" + private const val TEMPLATE_RESOURCE = "assets/jeanaddons/default.conf" /** Absolute path of the runtime config file. */ val configPath: Path @@ -39,9 +39,9 @@ object ConfigManager { generateDefault(path) } val lines = Files.readAllLines(path) - return ConfigParser.parse(lines) { ChatAddonsMod.logger.warn("Config: {}", it) } + return ConfigParser.parse(lines) { JeanAddonsMod.logger.warn("Config: {}", it) } } catch (e: Exception) { - ChatAddonsMod.logger.error("Config: could not read {}, falling back to all built-in defaults.", path, e) + JeanAddonsMod.logger.error("Config: could not read {}, falling back to all built-in defaults.", path, e) return emptyMap() } } @@ -52,10 +52,10 @@ object ConfigManager { * built jar. */ private fun generateDefault(path: Path) { - val container = FabricLoader.getInstance().getModContainer(ChatAddonsMod.MOD_ID).orElseThrow() + val container = FabricLoader.getInstance().getModContainer(JeanAddonsMod.MOD_ID).orElseThrow() val template = container.findPath(TEMPLATE_RESOURCE).orElseThrow() Files.createDirectories(path.parent) Files.copy(template, path) - ChatAddonsMod.logger.info("Config: generated default config at {}", path) + JeanAddonsMod.logger.info("Config: generated default config at {}", path) } } @@ -1,7 +1,7 @@ package dev.chatfatigue.config /** - * Hand-written parser for ChatAddons' minimal INI-style config format. + * Hand-written parser for JeanAddons' minimal INI-style config format. * * Grammar (line based): * ``` @@ -1,16 +1,26 @@ package dev.chatfatigue.feature +import dev.chatfatigue.feature.impl.chat.ChatHistorySize +import dev.chatfatigue.feature.impl.chat.CopyChatLine import dev.chatfatigue.feature.impl.visual.RemoveTextShadow /** * Typed static bridge from Java mixin code into [FeatureManager]. Every method - * answers `false` until the config has finished loading, guarding against - * class-load-order edge cases where mixin-transformed code runs before - * `ChatAddonsMod.onInitializeClient`. + * answers the unmodified-vanilla answer until the config has finished loading, + * guarding against class-load-order edge cases where mixin-transformed code + * runs before `JeanAddonsMod.onInitializeClient`. */ object FeatureAccess { /** @return whether Remove Text Shadow is enabled; `false` before config load. */ @JvmStatic fun isRemoveTextShadowEnabled(): Boolean = FeatureManager.isEnabled(RemoveTextShadow.id) + + /** @return whether Copy Chat Line is enabled; `false` before config load. */ + @JvmStatic + fun isCopyChatLineEnabled(): Boolean = FeatureManager.isEnabled(CopyChatLine.id) + + /** @return the chat history cap; the vanilla cap (100) before config load. */ + @JvmStatic + fun getChatHistorySize(): Int = FeatureManager.getInt(ChatHistorySize.id, ChatHistorySize.VANILLA) } @@ -2,9 +2,12 @@ package dev.chatfatigue.feature /** * Groups features for the config file. The [displayName] doubles as the INI - * section name in `chataddons.conf` (e.g. `[Visual]`). + * section name in `jeanaddons.conf` (e.g. `[Visual]`). */ enum class FeatureCategory(val displayName: String) { /** Purely cosmetic rendering tweaks. */ VISUAL("Visual"), + + /** Conveniences inside the chat screen. */ + CHAT("Chat"), } @@ -1,58 +1,93 @@ package dev.chatfatigue.feature -import dev.chatfatigue.ChatAddonsMod +import dev.chatfatigue.JeanAddonsMod import dev.chatfatigue.config.ConfigManager +import dev.chatfatigue.feature.impl.chat.ChatHistorySize +import dev.chatfatigue.feature.impl.chat.CopyChatLine import dev.chatfatigue.feature.impl.visual.RemoveTextShadow /** - * Single registry of every feature. Adding a new feature is: one new class - * under `feature/impl/<category>/`, one new entry in [features], and one - * commented block in `assets/chataddons/default.conf`. + * Single registry of every feature and numeric option. Adding a new entry is: + * one new class under `feature/impl/<category>/`, one new entry in [features] + * or [intOptions], and one commented block in `assets/jeanaddons/default.conf`. */ object FeatureManager { - /** Every feature the mod ships, in one visible list. */ + /** Every toggleable feature the mod ships, in one visible list. */ private val features: List<Feature> = listOf( RemoveTextShadow, + CopyChatLine, ) - /** Guard for class-load-order edge cases: lookups answer `false` until [init] completed. */ + /** Every numeric option the mod ships, in one visible list. */ + private val intOptions: List<IntOption> = listOf( + ChatHistorySize, + ) + + /** Guard for class-load-order edge cases: lookups answer their fallback until [init] completed. */ @Volatile private var initialized = false /** * Loads the config (generating the file on first launch) and applies every - * parsed `(section, key) -> value` to the matching feature. Unknown sections - * or keys log a warning and are ignored; missing keys keep the feature's - * built-in default. + * parsed `(section, key) -> value` to the matching feature or option. + * Unknown sections or keys log a warning and are ignored; missing keys + * keep the entry's built-in default. */ fun init() { val parsed = ConfigManager.load() apply(parsed) initialized = true - ChatAddonsMod.logger.info( + JeanAddonsMod.logger.info( "Loaded {} feature(s): {}", features.size, features.joinToString { "${it.id}=${if (it.enabled) "on" else "off"}" }, ) + JeanAddonsMod.logger.info( + "Loaded {} numeric option(s): {}", + intOptions.size, + intOptions.joinToString { "${it.id}=${it.value}" }, + ) } private fun apply(parsed: Map<String, Map<String, String>>) { for ((section, entries) in parsed) { for ((key, rawValue) in entries) { val feature = features.find { it.category.displayName == section && it.id == key } - if (feature == null) { - ChatAddonsMod.logger.warn("Config: unknown entry '{}' in section [{}], ignored.", key, section) + if (feature != null) { + when (rawValue.lowercase()) { + "true" -> feature.enabled = true + "false" -> feature.enabled = false + else -> JeanAddonsMod.logger.warn( + "Config: invalid value '{}' for '{}', expected true or false; using default ({}).", + rawValue, key, feature.defaultEnabled, + ) + } continue } - when (rawValue.lowercase()) { - "true" -> feature.enabled = true - "false" -> feature.enabled = false - else -> ChatAddonsMod.logger.warn( - "Config: invalid value '{}' for '{}', expected true or false; using default ({}).", - rawValue, key, feature.defaultEnabled, - ) + + val option = intOptions.find { it.category.displayName == section && it.id == key } + if (option != null) { + val parsedValue = rawValue.toIntOrNull() + if (parsedValue == null) { + JeanAddonsMod.logger.warn( + "Config: invalid value '{}' for '{}', expected a whole number; using default ({}).", + rawValue, key, option.defaultValue, + ) + } else if (parsedValue < option.minValue || parsedValue > option.maxValue) { + val clamped = parsedValue.coerceIn(option.minValue, option.maxValue) + option.value = clamped + JeanAddonsMod.logger.warn( + "Config: value '{}' for '{}' outside {}..{}; clamped to {}.", + rawValue, key, option.minValue, option.maxValue, clamped, + ) + } else { + option.value = parsedValue + } + continue } + + JeanAddonsMod.logger.warn("Config: unknown entry '{}' in section [{}], ignored.", key, section) } } } @@ -66,4 +101,14 @@ object FeatureManager { if (!initialized) return false return features.find { it.id == featureId }?.enabled ?: false } + + /** + * Cheap static lookup for mixin code. Returns [fallback] for unknown ids + * and for any call made before [init] completed. + */ + @JvmStatic + fun getInt(optionId: String, fallback: Int): Int { + if (!initialized) return fallback + return intOptions.find { it.id == optionId }?.value ?: fallback + } } @@ -0,0 +1,30 @@ +package dev.chatfatigue.feature + +/** + * A single numeric setting, for features that need a size or amount rather + * than a toggle. Parsed from the same INI format as [Feature] entries; values + * are clamped to [[minValue], [maxValue]]. + * + * @property id stable, unique, kebab-case identifier used as the config key + * @property category grouping; its display name is the config section name + * @property description one human sentence, shown in the config file comment + * @property defaultValue value used when the config file has no entry for this option + * @property minValue smallest accepted value, inclusive + * @property maxValue largest accepted value, inclusive + */ +abstract class IntOption( + val id: String, + val category: FeatureCategory, + val description: String, + val defaultValue: Int, + val minValue: Int, + val maxValue: Int, +) { + + /** + * Effective state. Written exactly once by [FeatureManager] during config + * load; until then it holds [defaultValue]. + */ + var value: Int = defaultValue + internal set +} @@ -0,0 +1,22 @@ +package dev.chatfatigue.feature.impl.chat + +import dev.chatfatigue.feature.FeatureCategory +import dev.chatfatigue.feature.IntOption + +/** + * Chat History Size: how much chat the client keeps in memory. Vanilla trims + * both the stored messages and the wrapped display lines to 100; raising the + * cap lets the scroll reach further back, at the cost of some memory. Widened + * by `dev.chatfatigue.mixin.ChatComponentMixin`. + */ +object ChatHistorySize : IntOption( + id = "chat-history-size", + category = FeatureCategory.CHAT, + description = "How many chat messages the client keeps, including wrapped lines. Vanilla keeps 100.", + defaultValue = 1000, + minValue = 100, + maxValue = 10000, +) { + /** The cap vanilla hard-codes; the safe answer before the config has loaded. */ + const val VANILLA = 100 +} @@ -0,0 +1,21 @@ +package dev.chatfatigue.feature.impl.chat + +import dev.chatfatigue.feature.Feature +import dev.chatfatigue.feature.FeatureCategory + +/** + * Copy Chat Line: while the chat screen is open, right-clicking a chat line + * copies the whole message it belongs to, as plain text, to the system + * clipboard. Vanilla ignores right-clicks in the chat screen, so no vanilla + * behavior is shadowed. The click itself is handled by + * `dev.chatfatigue.mixin.ChatScreenMixin` (with the geometry exposed by + * `ChatComponentAccessor`); the hit-test lives in + * `dev.chatfatigue.chat.ChatHudHitTester` and the on-screen confirmation in + * `dev.chatfatigue.chat.CopyFeedbackHud`. + */ +object CopyChatLine : Feature( + id = "copy-chat-line", + category = FeatureCategory.CHAT, + description = "Right-click a chat line to copy the whole message as plain text to the clipboard.", + defaultEnabled = true, +) @@ -1,5 +1,5 @@ # ============================================================= -# ChatAddons - client-side visual tweaks +# JeanAddons - client-side rendering and chat conveniences # ------------------------------------------------------------- # This file was generated automatically on first launch and is # safe to edit by hand. Restart the game to apply changes. @@ -22,3 +22,22 @@ # affected - see the README.) # Valid values: true | false remove-text-shadow = true + +[Chat] + +# Feature: Copy Chat Line +# While the chat screen is open, right-click a chat line to copy +# the whole message it belongs to, as plain text, to the system +# clipboard. A short confirmation is drawn in the bottom-right +# corner of the screen. Vanilla ignores right-clicks in the chat +# screen, so no vanilla behavior is shadowed. +# Valid values: true | false +copy-chat-line = true + +# Option: Chat History Size +# How many chat messages the client keeps in memory and shows in +# the chat, including the lines a long message wraps into. The +# vanilla game keeps 100; raising it lets you scroll further back +# at the cost of some memory. +# Valid values: whole numbers from 100 to 10000 +chat-history-size = 1000 @@ -3,19 +3,26 @@ "id": "${mod_id}", "version": "${mod_version}", "name": "${mod_name}", - "description": "Lightweight client-side visual tweaks for personal use. Purely cosmetic: never alters packets, input, movement, or any gameplay behavior.", + "description": "Lightweight client-side rendering and chat conveniences for personal use: visual tweaks, right-click chat copy, and a bigger chat history. Never alters packets, movement, or any gameplay behavior.", + "authors": [ + "jean" + ], + "contact": { + "homepage": "https://git.nageyqma.icu", + "sources": "https://git.nageyqma.icu" + }, "license": "MIT", "environment": "client", "entrypoints": { "client": [ { "adapter": "kotlin", - "value": "dev.chatfatigue.ChatAddonsMod" + "value": "dev.chatfatigue.JeanAddonsMod" } ] }, "mixins": [ - "chataddons.mixins.json" + "jeanaddons.mixins.json" ], "depends": { "fabricloader": ">=${loader_version}", @@ -7,6 +7,9 @@ "defaultRequire": 1 }, "client": [ + "ChatComponentAccessor", + "ChatComponentMixin", + "ChatScreenMixin", "FontMixin" ] } |
| ChatAddons 0.1.1: migrate to Fabric Loader 0.19.5 / Kotlin 2.4.20 stack - Update Loader 0.19.3 -> 0.19.5, Fabric API 0.155.2 -> 0.155.3+26.1.2,
FLK 1.13.13 -> 1.14.1+kotlin.2.4.20, Kotlin 2.4.10 -> 2.4.20
- Declare fabricloader >= 0.19.5 so the mod takes the new per-mod mixin
compat path introduced by loader 0.19.4's sponge-mixin 0.17.4 update
- Align mixin config compatibilityLevel with actual class files: JAVA_21 -> JAVA_25
- Upgrade Gradle wrapper 9.4.1 -> 9.7.1, resolving the Loom deviation:
the build now follows 1.17-SNAPSHOT as originally specified
- Verified: builds clean, dev client boots on Loader 0.19.5 / Mixin
0.17.4 / FLK 1.14.1 with the Font mixin applying and no errors 查看变更README.md | 29 ++++++++++------- gradle.properties | 14 ++++----- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 +- gradlew.bat | 186 +++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------- src/main/resources/chataddons.mixins.json | 2 +- 6 files changed, 121 insertions(+), 114 deletions(-) @@ -75,19 +75,26 @@ Three steps: | Component | Version | | --- | --- | | Minecraft | `26.1.2` (unobfuscated, official Mojang names — no mappings needed) | -| Fabric Loader | `0.19.3` | -| Fabric API | `0.155.2+26.1.2` | -| Fabric Language Kotlin | `1.13.13+kotlin.2.4.10` | -| Kotlin (Gradle plugin) | `2.4.10` | -| Fabric Loom | `1.16-SNAPSHOT` (deviation, see below) | -| Gradle wrapper | `9.4.1` | +| Fabric Loader | `0.19.5` | +| Fabric API | `0.155.3+26.1.2` | +| Fabric Language Kotlin | `1.14.1+kotlin.2.4.20` | +| Kotlin (Gradle plugin) | `2.4.20` | +| Fabric Loom | `1.17-SNAPSHOT` | +| Gradle wrapper | `9.7.1` | | Java toolchain / JVM target | `25` | -**Pinned-version deviation:** the spec pins Loom `1.17-SNAPSHOT`, but that snapshot -line (currently resolving to `1.17.20`) is built against Gradle plugin API `9.5.0` -and is rejected by the pinned Gradle `9.4.1` wrapper. Per the spec's documented -fallback, Loom `1.16-SNAPSHOT` (resolves to `1.16.3`, the version the Odin -reference build uses) is used instead; it builds and runs cleanly. +**Mixin compatibility note:** Fabric Loader `0.19.4` updated the bundled +sponge-mixin (0.17.3 → 0.17.4) and reworked how per-mod mixin compatibility is +selected: the loader derives a `fabric-compat` level from the *loader version +each mod declares in its own `fabric.mod.json`*. Mods built against older +loaders get routed onto legacy paths. Since 0.1.1 this mod declares +`fabricloader >= 0.19.5` (new behavior) and its mixin config declares +`JAVA_25`, matching the actual class files (JVM target 25). + +**Historical note:** before 0.1.1 the build pinned Loom `1.16-SNAPSHOT` because +the then-current Gradle `9.4.1` wrapper rejected Loom 1.17.x (built against +Gradle plugin API 9.5.0). The wrapper was upgraded to `9.7.1`, which resolves +the deviation and allows following the Loom 1.17-SNAPSHOT line again. ## License @@ -2,17 +2,17 @@ org.gradle.jvmargs=-Xmx2G org.gradle.parallel=true org.gradle.configuration-cache=false -# Fabric versions (pinned, verified 2026-09-01) +# Fabric versions (pinned, verified 2026-09-13) minecraft_version=26.1.2 -loader_version=0.19.3 -loom_version=1.16-SNAPSHOT -fabric_api_version=0.155.2+26.1.2 -fabric_kotlin_version=1.13.13+kotlin.2.4.10 -kotlin_version=2.4.10 +loader_version=0.19.5 +loom_version=1.17-SNAPSHOT +fabric_api_version=0.155.3+26.1.2 +fabric_kotlin_version=1.14.1+kotlin.2.4.20 +kotlin_version=2.4.20 # Mod identity mod_id=chataddons mod_name=ChatAddons -mod_version=0.1.0 +mod_version=0.1.1 maven_group=dev.chatfatigue archives_base_name=chataddons @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -1,93 +1,93 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega @@ -2,7 +2,7 @@ "required": true, "minVersion": "0.8", "package": "dev.chatfatigue.mixin", - "compatibilityLevel": "JAVA_21", + "compatibilityLevel": "JAVA_25", "injectors": { "defaultRequire": 1 }, |
Ignore prompt.md查看变更.gitignore | 3 +++ 1 file changed, 3 insertions(+) @@ -2,6 +2,9 @@ .gradle/ build/ +# Task spec file, regenerated from time to time +/prompt.md + # Build output directory (refreshed by every build) /bin/ |
ChatAddons 0.1.0 initial commit查看变更.gitignore | 27 +++++++++ LICENSE | 21 +++++++ README.md | 94 ++++++++++++++++++++++++++++++ build.gradle.kts | 87 ++++++++++++++++++++++++++++ conf/chataddons.conf | 24 ++++++++ gradle.properties | 18 ++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 7 +++ gradlew | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ gradlew.bat | 93 ++++++++++++++++++++++++++++++ settings.gradle.kts | 17 ++++++ src/main/java/dev/chatfatigue/mixin/FontMixin.java | 34 +++++++++++ src/main/kotlin/dev/chatfatigue/ChatAddonsMod.kt | 25 ++++++++ src/main/kotlin/dev/chatfatigue/config/ConfigManager.kt | 61 ++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/config/ConfigParser.kt | 68 ++++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/feature/Feature.kt | 24 ++++++++ src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt | 16 ++++++ src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt | 10 ++++ src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt | 69 ++++++++++++++++++++++ src/main/kotlin/dev/chatfatigue/feature/impl/visual/RemoveTextShadow.kt | 17 ++++++ src/main/resources/assets/chataddons/default.conf | 24 ++++++++ src/main/resources/chataddons.mixins.json | 12 ++++ src/main/resources/fabric.mod.json | 26 +++++++++ 23 files changed, 1022 insertions(+) @@ -0,0 +1,27 @@ +# Gradle +.gradle/ +build/ + +# Build output directory (refreshed by every build) +/bin/ + +# Loom dev runtime +/run/ + +# Read-only reference projects — never part of the build +/reference/ + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +.classpath +.project +.settings/ +/eclipse/ + +# Logs and crash reports +*.log +crash-reports/ @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 chatfatigue + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. @@ -0,0 +1,94 @@ +# ChatAddons + +A lightweight, personal-use **client-side** [Fabric](https://fabricmc.net/) mod for +Minecraft **26.1.2**: a small collection of visual tweaks. + +**Core contract — visual-only.** Every feature is a purely cosmetic, client-side +rendering change. The mod **never** alters any packet the client sends, nor any +input, movement, or gameplay behavior. It changes how things are drawn, nothing +else, and it never loads on a dedicated server (`"environment": "client"`). + +## Features + +| Feature | Category | Default | Effect | +| --- | --- | --- | --- | +| `remove-text-shadow` | Visual | `true` | Strips the drop shadow from all text drawn through the game's font renderer: GUIs, HUD, chat, tooltips, scoreboard sidebar, action bar, item name pop-ups, entity name tags, the F3 debug overlay, and other mods' HUD text drawn via the vanilla font classes. | + +**Known limitation:** mods that render text with their own font engine (for +example NanoVG-based HUDs, like Odin's custom HUD) bypass `Font` entirely and +cannot be affected. Everything using the vanilla font pipeline is covered. + +## Directory layout + +| Path | Purpose | +| --- | --- | +| `src/` | Sources and resources, standard Gradle layout. Kotlin (`src/main/kotlin`) holds game logic; Java (`src/main/java`) holds mixin classes. | +| `bin/` | Binary output. `copyJarToBin` copies the built mod jar here on every `build`. Disposable: git-ignored, safe to delete, refreshed by the next build. | +| `conf/` | Human-readable review copy of the default config. `syncDefaultConf` re-copies `src/main/resources/assets/chataddons/default.conf` to `conf/chataddons.conf` on every `build`, so the review copy can never drift from the template that ships in the jar. The jar template stays the single source of truth. | +| `reference/` | Read-only reference projects (e.g. a clone of [Odin](https://github.com/odtheking/odin)), used to mirror the Gradle/mixin setup. Not part of the build; git-ignored; never modified. | +| `build.gradle.kts`, `settings.gradle.kts`, `gradle.properties`, `gradle/wrapper/` | Gradle build. All identity values (mod id, name, version, group) and pinned dependency versions live in `gradle.properties`. | + +## Building and running + +```sh +./gradlew build # jar lands in bin/chataddons-<version>.jar +./gradlew runClient # launches a dev client +``` + +Mixin debugging escape hatch when something refuses to apply: add +`-Dmixin.debug.export=true` to the client run config's VM args to dump the +transformed classes. + +## Configuration + +There is **no config GUI**. The entire configuration is one hand-edited, +self-documenting text file: + +- On first launch the mod generates `<game directory>/config/chataddons.conf` + from the bundled template (`assets/chataddons/default.conf` inside the jar) + and logs one line saying so. +- Format: `[Section]` headers group features by category (the section name is + the category display name, e.g. `[Visual]`); each feature toggle is one + `key = value` line with a stable kebab-case key; `#` starts a comment; blank + lines are ignored; whitespace around keys and values is trimmed. +- The file is read **once at startup**. There is no hot reload and the mod + never rewrites the file — **restart the game to apply edits**. Deleting the + file restores defaults on the next launch. +- Unknown sections or keys log a warning and are ignored; missing keys fall + back to the feature's built-in default; a corrupt or unreadable file logs an + error and all defaults are used — the game never crashes over the config. + +## Adding a new feature + +Three steps: + +1. Create one new class under `src/main/kotlin/dev/chatfatigue/feature/impl/<category>/` + extending `Feature` (pick a stable, unique kebab-case `id`). +2. Add one line to the `features` list in `FeatureManager` (plus, if the mixin + side needs it, one `@JvmStatic` accessor in `FeatureAccess`). +3. Add one commented block for the feature to + `src/main/resources/assets/chataddons/default.conf` (under the right + `[Section]`, with its valid values documented). + +## Toolchain + +| Component | Version | +| --- | --- | +| Minecraft | `26.1.2` (unobfuscated, official Mojang names — no mappings needed) | +| Fabric Loader | `0.19.3` | +| Fabric API | `0.155.2+26.1.2` | +| Fabric Language Kotlin | `1.13.13+kotlin.2.4.10` | +| Kotlin (Gradle plugin) | `2.4.10` | +| Fabric Loom | `1.16-SNAPSHOT` (deviation, see below) | +| Gradle wrapper | `9.4.1` | +| Java toolchain / JVM target | `25` | + +**Pinned-version deviation:** the spec pins Loom `1.17-SNAPSHOT`, but that snapshot +line (currently resolving to `1.17.20`) is built against Gradle plugin API `9.5.0` +and is rejected by the pinned Gradle `9.4.1` wrapper. Per the spec's documented +fallback, Loom `1.16-SNAPSHOT` (resolves to `1.16.3`, the version the Odin +reference build uses) is used instead; it builds and runs cleanly. + +## License + +MIT — see [LICENSE](LICENSE). @@ -0,0 +1,87 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("net.fabricmc.fabric-loom") + kotlin("jvm") +} + +group = property("maven_group") as String +version = property("mod_version") as String + +val archivesBaseName = property("archives_base_name") as String +val modVersion = property("mod_version") as String + +base { + archivesName.set(archivesBaseName) +} + +repositories { + mavenCentral() +} + +dependencies { + minecraft("com.mojang:minecraft:${property("minecraft_version")}") + implementation("net.fabricmc:fabric-loader:${property("loader_version")}") + implementation("net.fabricmc:fabric-language-kotlin:${property("fabric_kotlin_version")}") + implementation("net.fabricmc.fabric-api:fabric-api:${property("fabric_api_version")}") +} + +loom { + runConfigs.named("client") { + isIdeConfigGenerated = true + } + + runConfigs.named("server") { + isIdeConfigGenerated = false + } +} + +tasks { + processResources { + val expandedProperties = getProperties() + filesMatching("fabric.mod.json") { + expand(expandedProperties) + } + } + + compileKotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_25 + } + } + + compileJava { + sourceCompatibility = "25" + targetCompatibility = "25" + options.encoding = "UTF-8" + } + + val copyJarToBin by registering(Copy::class) { + description = "Copies the built mod jar into bin/." + // No mappings in this build (Minecraft 26.x ships unobfuscated), so Loom + // registers no remapJar and 'jar' itself is the final artifact. + dependsOn("jar") + from(layout.buildDirectory.dir("libs")) { + include("$archivesBaseName-$modVersion.jar") + } + into(rootProject.file("bin")) + } + + val syncDefaultConf by registering(Copy::class) { + description = "Syncs the bundled default config template into conf/ for review." + from("src/main/resources/assets/chataddons/default.conf") { + rename { "chataddons.conf" } + } + into(rootProject.file("conf")) + } + + named("build") { + dependsOn(copyJarToBin, syncDefaultConf) + } +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } +} @@ -0,0 +1,24 @@ +# ============================================================= +# ChatAddons - client-side visual tweaks +# ------------------------------------------------------------- +# This file was generated automatically on first launch and is +# safe to edit by hand. Restart the game to apply changes. +# Delete the file to restore defaults. +# +# Format: +# - Lines starting with '#' are comments. +# - '[Name]' groups features by category. +# - Each feature is one 'key = value' line. +# - Valid values are listed in each feature's comment. +# ============================================================= + +[Visual] + +# Feature: Remove Text Shadow +# Removes the drop shadow behind rendered text. Applies to all +# text drawn through the game's font renderer: GUIs, HUD, chat, +# tooltips, scoreboards, name tags, and other mods' HUD text. +# (Text drawn by a mod's own custom font engine cannot be +# affected - see the README.) +# Valid values: true | false +remove-text-shadow = true @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx2G +org.gradle.parallel=true +org.gradle.configuration-cache=false + +# Fabric versions (pinned, verified 2026-09-01) +minecraft_version=26.1.2 +loader_version=0.19.3 +loom_version=1.16-SNAPSHOT +fabric_api_version=0.155.2+26.1.2 +fabric_kotlin_version=1.13.13+kotlin.2.4.10 +kotlin_version=2.4.10 + +# Mod identity +mod_id=chataddons +mod_name=ChatAddons +mod_version=0.1.0 +maven_group=dev.chatfatigue +archives_base_name=chataddons @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega @@ -0,0 +1,17 @@ +rootProject.name = "ChatAddons" + +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + maven("https://maven.fabricmc.net/") + } + + val loom_version: String by settings + val kotlin_version: String by settings + + plugins { + id("net.fabricmc.fabric-loom") version loom_version + kotlin("jvm") version kotlin_version + } +} @@ -0,0 +1,34 @@ +package dev.chatfatigue.mixin; + +import dev.chatfatigue.feature.FeatureAccess; +import net.minecraft.client.gui.Font; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +/** + * Strips the text drop shadow at the single point every text render path + * converges on: the two public {@code Font#prepareText} overloads. Forcing + * {@code dropShadow} to {@code false} here removes the shadow for the whole + * frame, before any glyph is emitted. + */ +@Mixin(Font.class) +public class FontMixin { + + @ModifyVariable( + method = "prepareText(Ljava/lang/String;FFIZI)Lnet/minecraft/client/gui/Font$PreparedText;", + at = @At("HEAD"), argsOnly = true, ordinal = 0 + ) + private boolean chataddons$noShadowString(boolean dropShadow) { + return FeatureAccess.isRemoveTextShadowEnabled() ? false : dropShadow; + } + + // dropShadow is ordinal 0 because includeEmpty is also boolean; do not touch includeEmpty. + @ModifyVariable( + method = "prepareText(Lnet/minecraft/util/FormattedCharSequence;FFIZZI)Lnet/minecraft/client/gui/Font$PreparedText;", + at = @At("HEAD"), argsOnly = true, ordinal = 0 + ) + private boolean chataddons$noShadowSequence(boolean dropShadow) { + return FeatureAccess.isRemoveTextShadowEnabled() ? false : dropShadow; + } +} @@ -0,0 +1,25 @@ +package dev.chatfatigue + +import dev.chatfatigue.feature.FeatureManager +import net.fabricmc.api.ClientModInitializer +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Client entrypoint of ChatAddons, a collection of purely cosmetic, client-side + * rendering tweaks. The mod never alters packets, input, movement, or gameplay + * behavior; it only changes how things are drawn. + */ +object ChatAddonsMod : ClientModInitializer { + + /** Mod id, matching `fabric.mod.json` and `gradle.properties`. */ + const val MOD_ID = "chataddons" + + /** Shared logger for the whole mod. */ + val logger: Logger = LoggerFactory.getLogger("ChatAddons") + + override fun onInitializeClient() { + FeatureManager.init() + logger.info("ChatAddons initialized.") + } +} @@ -0,0 +1,61 @@ +package dev.chatfatigue.config + +import dev.chatfatigue.ChatAddonsMod +import net.fabricmc.loader.api.FabricLoader +import java.nio.file.Files +import java.nio.file.Path + +/** + * Owns the on-disk config file, `<game directory>/config/chataddons.conf`. + * + * On first launch the bundled template `assets/chataddons/default.conf` is copied + * out of the mod jar verbatim. The file is then parsed exactly once at startup: + * there is no hot reload, no file watcher, and no write-back — the mod never + * rewrites the user's file. Edits take effect on the next game restart; deleting + * the file restores defaults on the next launch. + */ +object ConfigManager { + + private const val FILE_NAME = "chataddons.conf" + private const val TEMPLATE_RESOURCE = "assets/chataddons/default.conf" + + /** Absolute path of the runtime config file. */ + val configPath: Path + get() = FabricLoader.getInstance().configDir.resolve(FILE_NAME) + + /** + * Generates the config file if missing, then parses it. + * + * This method never throws: a corrupt or unreadable file logs an error and + * yields an empty map, so every feature falls back to its built-in default + * instead of crashing the game. + * + * @return parsed section name -> (key -> raw value) entries + */ + fun load(): Map<String, Map<String, String>> { + val path = configPath + try { + if (Files.notExists(path)) { + generateDefault(path) + } + val lines = Files.readAllLines(path) + return ConfigParser.parse(lines) { ChatAddonsMod.logger.warn("Config: {}", it) } + } catch (e: Exception) { + ChatAddonsMod.logger.error("Config: could not read {}, falling back to all built-in defaults.", path, e) + return emptyMap() + } + } + + /** + * Copies the bundled template into [path]. Reading it through the mod + * container's root path works both in dev (loose classes dir) and in a + * built jar. + */ + private fun generateDefault(path: Path) { + val container = FabricLoader.getInstance().getModContainer(ChatAddonsMod.MOD_ID).orElseThrow() + val template = container.findPath(TEMPLATE_RESOURCE).orElseThrow() + Files.createDirectories(path.parent) + Files.copy(template, path) + ChatAddonsMod.logger.info("Config: generated default config at {}", path) + } +} @@ -0,0 +1,68 @@ +package dev.chatfatigue.config + +/** + * Hand-written parser for ChatAddons' minimal INI-style config format. + * + * Grammar (line based): + * ``` + * file = *( line ) + * line = blank | comment | section | entry | garbage + * blank = *whitespace + * comment = *whitespace "#" *char ; whole-line comments only + * section = *whitespace "[" name "]" *whitespace + * entry = *whitespace key *whitespace "=" *whitespace value *whitespace + * garbage = anything else ; reported as malformed, skipped + * ``` + * + * The parser is purely syntactic: it does not know any section or key names and + * returns raw strings. Interpreting values (and rejecting unknown names) is the + * caller's job. Later duplicate entries overwrite earlier ones. + */ +object ConfigParser { + + /** + * Parses [lines] into a map of section name -> (key -> raw value). + * Lines before the first `[Section]` header are reported as malformed. + * + * @param lines config file content, already split into lines + * @param onWarning receives one human-readable message per malformed line + * @return parsed entries; never throws on bad input + */ + fun parse(lines: List<String>, onWarning: (String) -> Unit): Map<String, Map<String, String>> { + val result = LinkedHashMap<String, MutableMap<String, String>>() + var currentSection: String? = null + + for ((index, rawLine) in lines.withIndex()) { + val line = rawLine.trim() + val lineNumber = index + 1 + + when { + line.isEmpty() || line.startsWith("#") -> Unit + + line.startsWith("[") && line.endsWith("]") && line.length > 2 -> { + currentSection = line.substring(1, line.length - 1).trim() + result.getOrPut(currentSection) { LinkedHashMap() } + } + + line.contains("=") -> { + val section = currentSection + if (section == null) { + onWarning("line $lineNumber: entry outside of any [Section], skipped: $line") + continue + } + val key = line.substringBefore("=").trim() + val value = line.substringAfter("=").trim() + if (key.isEmpty()) { + onWarning("line $lineNumber: missing key before '=', skipped: $line") + continue + } + result.getValue(section)[key] = value + } + + else -> onWarning("line $lineNumber: malformed line, skipped: $line") + } + } + + return result + } +} @@ -0,0 +1,24 @@ +package dev.chatfatigue.feature + +/** + * A single toggleable tweak. + * + * @property id stable, unique, kebab-case identifier used as the config key + * @property category grouping; its display name is the config section name + * @property description one human sentence, shown in the config file comment + * @property defaultEnabled value used when the config file has no entry for this feature + */ +abstract class Feature( + val id: String, + val category: FeatureCategory, + val description: String, + val defaultEnabled: Boolean, +) { + + /** + * Effective state. Written exactly once by [FeatureManager] during config + * load; until then it holds [defaultEnabled]. + */ + var enabled: Boolean = defaultEnabled + internal set +} @@ -0,0 +1,16 @@ +package dev.chatfatigue.feature + +import dev.chatfatigue.feature.impl.visual.RemoveTextShadow + +/** + * Typed static bridge from Java mixin code into [FeatureManager]. Every method + * answers `false` until the config has finished loading, guarding against + * class-load-order edge cases where mixin-transformed code runs before + * `ChatAddonsMod.onInitializeClient`. + */ +object FeatureAccess { + + /** @return whether Remove Text Shadow is enabled; `false` before config load. */ + @JvmStatic + fun isRemoveTextShadowEnabled(): Boolean = FeatureManager.isEnabled(RemoveTextShadow.id) +} @@ -0,0 +1,10 @@ +package dev.chatfatigue.feature + +/** + * Groups features for the config file. The [displayName] doubles as the INI + * section name in `chataddons.conf` (e.g. `[Visual]`). + */ +enum class FeatureCategory(val displayName: String) { + /** Purely cosmetic rendering tweaks. */ + VISUAL("Visual"), +} @@ -0,0 +1,69 @@ +package dev.chatfatigue.feature + +import dev.chatfatigue.ChatAddonsMod +import dev.chatfatigue.config.ConfigManager +import dev.chatfatigue.feature.impl.visual.RemoveTextShadow + +/** + * Single registry of every feature. Adding a new feature is: one new class + * under `feature/impl/<category>/`, one new entry in [features], and one + * commented block in `assets/chataddons/default.conf`. + */ +object FeatureManager { + + /** Every feature the mod ships, in one visible list. */ + private val features: List<Feature> = listOf( + RemoveTextShadow, + ) + + /** Guard for class-load-order edge cases: lookups answer `false` until [init] completed. */ + @Volatile + private var initialized = false + + /** + * Loads the config (generating the file on first launch) and applies every + * parsed `(section, key) -> value` to the matching feature. Unknown sections + * or keys log a warning and are ignored; missing keys keep the feature's + * built-in default. + */ + fun init() { + val parsed = ConfigManager.load() + apply(parsed) + initialized = true + ChatAddonsMod.logger.info( + "Loaded {} feature(s): {}", + features.size, + features.joinToString { "${it.id}=${if (it.enabled) "on" else "off"}" }, + ) + } + + private fun apply(parsed: Map<String, Map<String, String>>) { + for ((section, entries) in parsed) { + for ((key, rawValue) in entries) { + val feature = features.find { it.category.displayName == section && it.id == key } + if (feature == null) { + ChatAddonsMod.logger.warn("Config: unknown entry '{}' in section [{}], ignored.", key, section) + continue + } + when (rawValue.lowercase()) { + "true" -> feature.enabled = true + "false" -> feature.enabled = false + else -> ChatAddonsMod.logger.warn( + "Config: invalid value '{}' for '{}', expected true or false; using default ({}).", + rawValue, key, feature.defaultEnabled, + ) + } + } + } + } + + /** + * Cheap static lookup for mixin code. Returns `false` for unknown ids and + * for any call made before [init] completed. + */ + @JvmStatic + fun isEnabled(featureId: String): Boolean { + if (!initialized) return false + return features.find { it.id == featureId }?.enabled ?: false + } +} @@ -0,0 +1,17 @@ +package dev.chatfatigue.feature.impl.visual + +import dev.chatfatigue.feature.Feature +import dev.chatfatigue.feature.FeatureCategory + +/** + * Remove Text Shadow: strips the drop shadow from all text rendered through the + * game's font pipeline (GUIs, HUD, chat, tooltips, scoreboards, name tags, and + * other mods' HUD text drawn via the vanilla font classes). Implemented by + * `dev.chatfatigue.mixin.FontMixin`. + */ +object RemoveTextShadow : Feature( + id = "remove-text-shadow", + category = FeatureCategory.VISUAL, + description = "Removes the drop shadow behind all text drawn through the game's font renderer.", + defaultEnabled = true, +) @@ -0,0 +1,24 @@ +# ============================================================= +# ChatAddons - client-side visual tweaks +# ------------------------------------------------------------- +# This file was generated automatically on first launch and is +# safe to edit by hand. Restart the game to apply changes. +# Delete the file to restore defaults. +# +# Format: +# - Lines starting with '#' are comments. +# - '[Name]' groups features by category. +# - Each feature is one 'key = value' line. +# - Valid values are listed in each feature's comment. +# ============================================================= + +[Visual] + +# Feature: Remove Text Shadow +# Removes the drop shadow behind rendered text. Applies to all +# text drawn through the game's font renderer: GUIs, HUD, chat, +# tooltips, scoreboards, name tags, and other mods' HUD text. +# (Text drawn by a mod's own custom font engine cannot be +# affected - see the README.) +# Valid values: true | false +remove-text-shadow = true @@ -0,0 +1,12 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "dev.chatfatigue.mixin", + "compatibilityLevel": "JAVA_21", + "injectors": { + "defaultRequire": 1 + }, + "client": [ + "FontMixin" + ] +} @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${mod_version}", + "name": "${mod_name}", + "description": "Lightweight client-side visual tweaks for personal use. Purely cosmetic: never alters packets, input, movement, or any gameplay behavior.", + "license": "MIT", + "environment": "client", + "entrypoints": { + "client": [ + { + "adapter": "kotlin", + "value": "dev.chatfatigue.ChatAddonsMod" + } + ] + }, + "mixins": [ + "chataddons.mixins.json" + ], + "depends": { + "fabricloader": ">=${loader_version}", + "minecraft": "~26.1", + "fabric-api": ">=${fabric_api_version}", + "fabric-language-kotlin": ">=${fabric_kotlin_version}" + } +} |