nageyqma·git
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.
nageyqma · 2026-09-16 · 1bcadc5
查看变更
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(-)
diff --git a/README.md b/README.md
index 371a3b1..799295c 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/conf/jeanaddons.conf b/conf/jeanaddons.conf
index df5a946..813a374 100644
--- a/conf/jeanaddons.conf
+++ b/conf/jeanaddons.conf
@@ -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
 
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index fd54462..c42672d 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickOverride.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickOverride.kt
index 02e2998..c02d675 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickOverride.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickOverride.kt
@@ -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,
 )
diff --git a/src/main/resources/assets/jeanaddons/default.conf b/src/main/resources/assets/jeanaddons/default.conf
index df5a946..813a374 100644
--- a/src/main/resources/assets/jeanaddons/default.conf
+++ b/src/main/resources/assets/jeanaddons/default.conf
@@ -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 fixnageyqma · 2026-09-15 · d4b18d3
查看变更
gradle/wrapper/gradle-wrapper.properties | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index c42672d..fd54462 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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,middleclickfixnageyqma · 2026-09-15 · 44690c7
查看变更
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(-)
diff --git a/README.md b/README.md
index 6eec2d4..371a3b1 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/conf/jeanaddons.conf b/conf/jeanaddons.conf
index 4c2dd19..df5a946 100644
--- a/conf/jeanaddons.conf
+++ b/conf/jeanaddons.conf
@@ -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
diff --git a/gradle.properties b/gradle.properties
index 049b589..cd02443 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -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
diff --git a/src/main/java/dev/chatfatigue/mixin/AbstractContainerScreenMixin.java b/src/main/java/dev/chatfatigue/mixin/AbstractContainerScreenMixin.java
new file mode 100644
index 0000000..d6b6693
--- /dev/null
+++ b/src/main/java/dev/chatfatigue/mixin/AbstractContainerScreenMixin.java
@@ -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);
+        }
+    }
+}
diff --git a/src/main/java/dev/chatfatigue/mixin/KeyboardHandlerMixin.java b/src/main/java/dev/chatfatigue/mixin/KeyboardHandlerMixin.java
new file mode 100644
index 0000000..0db6621
--- /dev/null
+++ b/src/main/java/dev/chatfatigue/mixin/KeyboardHandlerMixin.java
@@ -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();
+        }
+    }
+}
diff --git a/src/main/java/dev/chatfatigue/mixin/MouseHandlerMixin.java b/src/main/java/dev/chatfatigue/mixin/MouseHandlerMixin.java
new file mode 100644
index 0000000..47d5a62
--- /dev/null
+++ b/src/main/java/dev/chatfatigue/mixin/MouseHandlerMixin.java
@@ -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();
+        }
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/JeanAddonsMod.kt b/src/main/kotlin/dev/chatfatigue/JeanAddonsMod.kt
index 7751649..17c0638 100644
--- a/src/main/kotlin/dev/chatfatigue/JeanAddonsMod.kt
+++ b/src/main/kotlin/dev/chatfatigue/JeanAddonsMod.kt
@@ -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(
diff --git a/src/main/kotlin/dev/chatfatigue/command/JeanAddonsCommand.kt b/src/main/kotlin/dev/chatfatigue/command/JeanAddonsCommand.kt
new file mode 100644
index 0000000..9429c68
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/command/JeanAddonsCommand.kt
@@ -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()),
+            )
+        }
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
index 4993ecf..1f2e218 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
@@ -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)
 }
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt
index 45949e3..c48c7ed 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt
@@ -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"),
 }
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
index 63968a6..2f50005 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
@@ -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)
             }
         }
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/CommandCooldownTicks.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/CommandCooldownTicks.kt
new file mode 100644
index 0000000..dfe16eb
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/CommandCooldownTicks.kt
@@ -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,
+)
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/CommandKeybinds.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/CommandKeybinds.kt
new file mode 100644
index 0000000..514d262
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/CommandKeybinds.kt
@@ -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)
+        }
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/KeybindRegistry.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/KeybindRegistry.kt
new file mode 100644
index 0000000..195d1ea
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/KeybindRegistry.kt
@@ -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
+            }
+        }
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickFix.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickFix.kt
new file mode 100644
index 0000000..b39b522
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickFix.kt
@@ -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,
+)
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickOverride.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickOverride.kt
new file mode 100644
index 0000000..02e2998
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/skyblock/MiddleClickOverride.kt
@@ -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,
+)
diff --git a/src/main/kotlin/dev/chatfatigue/skyblock/SkyblockUtils.kt b/src/main/kotlin/dev/chatfatigue/skyblock/SkyblockUtils.kt
new file mode 100644
index 0000000..f477177
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/skyblock/SkyblockUtils.kt
@@ -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)),
+            )
+        }
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/skyblock/TickCooldown.kt b/src/main/kotlin/dev/chatfatigue/skyblock/TickCooldown.kt
new file mode 100644
index 0000000..b20c3ce
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/skyblock/TickCooldown.kt
@@ -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
+        }
+    }
+}
diff --git a/src/main/resources/assets/jeanaddons/default.conf b/src/main/resources/assets/jeanaddons/default.conf
index 4c2dd19..df5a946 100644
--- a/src/main/resources/assets/jeanaddons/default.conf
+++ b/src/main/resources/assets/jeanaddons/default.conf
@@ -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
nageyqma · 2026-09-15 · b97d464
查看变更
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(-)
diff --git a/README.md b/README.md
index 65e7255..6eec2d4 100644
--- a/README.md
+++ b/README.md
@@ -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).
diff --git a/conf/jeanaddons.conf b/conf/jeanaddons.conf
index aea888e..4c2dd19 100644
--- a/conf/jeanaddons.conf
+++ b/conf/jeanaddons.conf
@@ -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
diff --git a/gradle.properties b/gradle.properties
index 2648e9d..049b589 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -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
diff --git a/src/main/java/dev/chatfatigue/mixin/AvatarRendererMixin.java b/src/main/java/dev/chatfatigue/mixin/AvatarRendererMixin.java
index 7306d3c..a20dd0a 100644
--- a/src/main/java/dev/chatfatigue/mixin/AvatarRendererMixin.java
+++ b/src/main/java/dev/chatfatigue/mixin/AvatarRendererMixin.java
@@ -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);
+    }
 }
diff --git a/src/main/kotlin/dev/chatfatigue/cape/CapeTextures.kt b/src/main/kotlin/dev/chatfatigue/cape/CapeTextures.kt
deleted file mode 100644
index 15d5e15..0000000
--- a/src/main/kotlin/dev/chatfatigue/cape/CapeTextures.kt
+++ /dev/null
@@ -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"))
-        }
-    }
-}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/ChoiceOption.kt b/src/main/kotlin/dev/chatfatigue/feature/ChoiceOption.kt
deleted file mode 100644
index 0430874..0000000
--- a/src/main/kotlin/dev/chatfatigue/feature/ChoiceOption.kt
+++ /dev/null
@@ -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
-}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
index b2c17f0..4993ecf 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
@@ -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
 }
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
index 9e8050f..63968a6 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
@@ -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
-    }
 }
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/visual/CapeStyle.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/CapeStyle.kt
deleted file mode 100644
index 0d6aac7..0000000
--- a/src/main/kotlin/dev/chatfatigue/feature/impl/visual/CapeStyle.kt
+++ /dev/null
@@ -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(),
-)
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/visual/ForceCape.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/ForceCape.kt
index 4c3217e..de97a5d 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/impl/visual/ForceCape.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/ForceCape.kt
@@ -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,
 )
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/visual/OldCapeMovement.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/OldCapeMovement.kt
new file mode 100644
index 0000000..a23e118
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/OldCapeMovement.kt
@@ -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,
+)
diff --git a/src/main/resources/assets/jeanaddons/default.conf b/src/main/resources/assets/jeanaddons/default.conf
index aea888e..4c2dd19 100644
--- a/src/main/resources/assets/jeanaddons/default.conf
+++ b/src/main/resources/assets/jeanaddons/default.conf
@@ -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
diff --git a/src/main/resources/assets/jeanaddons/textures/cape/2011.png b/src/main/resources/assets/jeanaddons/textures/cape/2011.png
deleted file mode 100644
index c5c3652..0000000
Binary files a/src/main/resources/assets/jeanaddons/textures/cape/2011.png and /dev/null differ
diff --git a/src/main/resources/assets/jeanaddons/textures/cape/2012.png b/src/main/resources/assets/jeanaddons/textures/cape/2012.png
deleted file mode 100644
index fed7215..0000000
Binary files a/src/main/resources/assets/jeanaddons/textures/cape/2012.png and /dev/null differ
diff --git a/src/main/resources/assets/jeanaddons/textures/cape/2013.png b/src/main/resources/assets/jeanaddons/textures/cape/2013.png
deleted file mode 100644
index 9c332c6..0000000
Binary files a/src/main/resources/assets/jeanaddons/textures/cape/2013.png and /dev/null differ
diff --git a/src/main/resources/assets/jeanaddons/textures/cape/2015.png b/src/main/resources/assets/jeanaddons/textures/cape/2015.png
deleted file mode 100644
index 1338aff..0000000
Binary files a/src/main/resources/assets/jeanaddons/textures/cape/2015.png and /dev/null differ
diff --git a/src/main/resources/assets/jeanaddons/textures/cape/2016.png b/src/main/resources/assets/jeanaddons/textures/cape/2016.png
index 1127a7c..acc2645 100644
Binary files a/src/main/resources/assets/jeanaddons/textures/cape/2016.png and b/src/main/resources/assets/jeanaddons/textures/cape/2016.png differ
optifine cape animation and add 2016 capenageyqma · 2026-09-14 · 73933fe
查看变更
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(-)
diff --git a/README.md b/README.md
index 56297c0..65e7255 100644
--- a/README.md
+++ b/README.md
@@ -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).
diff --git a/conf/jeanaddons.conf b/conf/jeanaddons.conf
index be70bbc..aea888e 100644
--- a/conf/jeanaddons.conf
+++ b/conf/jeanaddons.conf
@@ -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
diff --git a/src/main/java/dev/chatfatigue/mixin/AvatarRendererMixin.java b/src/main/java/dev/chatfatigue/mixin/AvatarRendererMixin.java
new file mode 100644
index 0000000..7306d3c
--- /dev/null
+++ b/src/main/java/dev/chatfatigue/mixin/AvatarRendererMixin.java
@@ -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;
+    }
+}
diff --git a/src/main/java/dev/chatfatigue/mixin/CapeLayerMixin.java b/src/main/java/dev/chatfatigue/mixin/CapeLayerMixin.java
new file mode 100644
index 0000000..95e6b62
--- /dev/null
+++ b/src/main/java/dev/chatfatigue/mixin/CapeLayerMixin.java
@@ -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;
+        }
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/cape/CapeTextures.kt b/src/main/kotlin/dev/chatfatigue/cape/CapeTextures.kt
new file mode 100644
index 0000000..15d5e15
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/cape/CapeTextures.kt
@@ -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"))
+        }
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/ChoiceOption.kt b/src/main/kotlin/dev/chatfatigue/feature/ChoiceOption.kt
new file mode 100644
index 0000000..0430874
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/ChoiceOption.kt
@@ -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
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
index 17c2c28..b2c17f0 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
@@ -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)
+    }
 }
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
index fd72ed3..9e8050f 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
@@ -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
+    }
 }
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/visual/CapeStyle.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/CapeStyle.kt
new file mode 100644
index 0000000..0d6aac7
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/CapeStyle.kt
@@ -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(),
+)
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/visual/ForceCape.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/ForceCape.kt
new file mode 100644
index 0000000..4c3217e
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/ForceCape.kt
@@ -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,
+)
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/visual/OptifineCapeFlow.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/OptifineCapeFlow.kt
new file mode 100644
index 0000000..06a19fc
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/OptifineCapeFlow.kt
@@ -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,
+)
diff --git a/src/main/resources/assets/jeanaddons/default.conf b/src/main/resources/assets/jeanaddons/default.conf
index be70bbc..aea888e 100644
--- a/src/main/resources/assets/jeanaddons/default.conf
+++ b/src/main/resources/assets/jeanaddons/default.conf
@@ -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
diff --git a/src/main/resources/assets/jeanaddons/textures/cape/2011.png b/src/main/resources/assets/jeanaddons/textures/cape/2011.png
new file mode 100644
index 0000000..c5c3652
Binary files /dev/null and b/src/main/resources/assets/jeanaddons/textures/cape/2011.png differ
diff --git a/src/main/resources/assets/jeanaddons/textures/cape/2012.png b/src/main/resources/assets/jeanaddons/textures/cape/2012.png
new file mode 100644
index 0000000..fed7215
Binary files /dev/null and b/src/main/resources/assets/jeanaddons/textures/cape/2012.png differ
diff --git a/src/main/resources/assets/jeanaddons/textures/cape/2013.png b/src/main/resources/assets/jeanaddons/textures/cape/2013.png
new file mode 100644
index 0000000..9c332c6
Binary files /dev/null and b/src/main/resources/assets/jeanaddons/textures/cape/2013.png differ
diff --git a/src/main/resources/assets/jeanaddons/textures/cape/2015.png b/src/main/resources/assets/jeanaddons/textures/cape/2015.png
new file mode 100644
index 0000000..1338aff
Binary files /dev/null and b/src/main/resources/assets/jeanaddons/textures/cape/2015.png differ
diff --git a/src/main/resources/assets/jeanaddons/textures/cape/2016.png b/src/main/resources/assets/jeanaddons/textures/cape/2016.png
new file mode 100644
index 0000000..1127a7c
Binary files /dev/null and b/src/main/resources/assets/jeanaddons/textures/cape/2016.png differ
diff --git a/src/main/resources/jeanaddons.mixins.json b/src/main/resources/jeanaddons.mixins.json
index 68e2ba4..75d58ee 100644
--- a/src/main/resources/jeanaddons.mixins.json
+++ b/src/main/resources/jeanaddons.mixins.json
@@ -7,6 +7,8 @@
     "defaultRequire": 1
   },
   "client": [
+    "AvatarRendererMixin",
+    "CapeLayerMixin",
     "ChatComponentAccessor",
     "ChatComponentMixin",
     "ChatScreenMixin",
chat ui copy and history changenageyqma · 2026-09-14 · c39874c
查看变更
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(-)
diff --git a/README.md b/README.md
index a36fbd2..56297c0 100644
--- a/README.md
+++ b/README.md
@@ -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
 
diff --git a/build.gradle.kts b/build.gradle.kts
index c0c4ab6..89779d8 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -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"))
     }
diff --git a/conf/chataddons.conf b/conf/jeanaddons.conf
similarity index 54%
rename from conf/chataddons.conf
rename to conf/jeanaddons.conf
index a6042ec..be70bbc 100644
--- a/conf/chataddons.conf
+++ b/conf/jeanaddons.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
diff --git a/gradle.properties b/gradle.properties
index 9042929..2648e9d 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -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
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8230d47..84ae5cf 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,4 +1,4 @@
-rootProject.name = "ChatAddons"
+rootProject.name = "JeanAddons"
 
 pluginManagement {
     repositories {
diff --git a/src/main/java/dev/chatfatigue/mixin/ChatComponentAccessor.java b/src/main/java/dev/chatfatigue/mixin/ChatComponentAccessor.java
new file mode 100644
index 0000000..e373704
--- /dev/null
+++ b/src/main/java/dev/chatfatigue/mixin/ChatComponentAccessor.java
@@ -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();
+}
diff --git a/src/main/java/dev/chatfatigue/mixin/ChatComponentMixin.java b/src/main/java/dev/chatfatigue/mixin/ChatComponentMixin.java
new file mode 100644
index 0000000..173d8a6
--- /dev/null
+++ b/src/main/java/dev/chatfatigue/mixin/ChatComponentMixin.java
@@ -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();
+    }
+}
diff --git a/src/main/java/dev/chatfatigue/mixin/ChatScreenMixin.java b/src/main/java/dev/chatfatigue/mixin/ChatScreenMixin.java
new file mode 100644
index 0000000..e219c75
--- /dev/null
+++ b/src/main/java/dev/chatfatigue/mixin/ChatScreenMixin.java
@@ -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);
+        }
+    }
+}
diff --git a/src/main/java/dev/chatfatigue/mixin/FontMixin.java b/src/main/java/dev/chatfatigue/mixin/FontMixin.java
index 0beea32..82f7862 100644
--- a/src/main/java/dev/chatfatigue/mixin/FontMixin.java
+++ b/src/main/java/dev/chatfatigue/mixin/FontMixin.java
@@ -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;
     }
 }
diff --git a/src/main/kotlin/dev/chatfatigue/ChatAddonsMod.kt b/src/main/kotlin/dev/chatfatigue/ChatAddonsMod.kt
deleted file mode 100644
index b0c42dc..0000000
--- a/src/main/kotlin/dev/chatfatigue/ChatAddonsMod.kt
+++ /dev/null
@@ -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.")
-    }
-}
diff --git a/src/main/kotlin/dev/chatfatigue/JeanAddonsMod.kt b/src/main/kotlin/dev/chatfatigue/JeanAddonsMod.kt
new file mode 100644
index 0000000..7751649
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/JeanAddonsMod.kt
@@ -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.")
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/chat/ChatHudHitTester.kt b/src/main/kotlin/dev/chatfatigue/chat/ChatHudHitTester.kt
new file mode 100644
index 0000000..8e4fd9b
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/chat/ChatHudHitTester.kt
@@ -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()
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/chat/ChatLineCopier.kt b/src/main/kotlin/dev/chatfatigue/chat/ChatLineCopier.kt
new file mode 100644
index 0000000..0cbfc23
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/chat/ChatLineCopier.kt
@@ -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()
+}
diff --git a/src/main/kotlin/dev/chatfatigue/chat/CopyFeedbackHud.kt b/src/main/kotlin/dev/chatfatigue/chat/CopyFeedbackHud.kt
new file mode 100644
index 0000000..374309a
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/chat/CopyFeedbackHud.kt
@@ -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,
+        )
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/config/ConfigManager.kt b/src/main/kotlin/dev/chatfatigue/config/ConfigManager.kt
index d48dca9..a1386b3 100644
--- a/src/main/kotlin/dev/chatfatigue/config/ConfigManager.kt
+++ b/src/main/kotlin/dev/chatfatigue/config/ConfigManager.kt
@@ -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)
     }
 }
diff --git a/src/main/kotlin/dev/chatfatigue/config/ConfigParser.kt b/src/main/kotlin/dev/chatfatigue/config/ConfigParser.kt
index 6fef57f..75b7a2e 100644
--- a/src/main/kotlin/dev/chatfatigue/config/ConfigParser.kt
+++ b/src/main/kotlin/dev/chatfatigue/config/ConfigParser.kt
@@ -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):
  * ```
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
index 142441f..17c2c28 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
@@ -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)
 }
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt
index bd58159..45949e3 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt
@@ -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"),
 }
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
index fc3fa4d..fd72ed3 100644
--- a/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
@@ -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
+    }
 }
diff --git a/src/main/kotlin/dev/chatfatigue/feature/IntOption.kt b/src/main/kotlin/dev/chatfatigue/feature/IntOption.kt
new file mode 100644
index 0000000..011a5a9
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/IntOption.kt
@@ -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
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/chat/ChatHistorySize.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/chat/ChatHistorySize.kt
new file mode 100644
index 0000000..5b05c9a
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/chat/ChatHistorySize.kt
@@ -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
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/chat/CopyChatLine.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/chat/CopyChatLine.kt
new file mode 100644
index 0000000..4fe36da
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/chat/CopyChatLine.kt
@@ -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,
+)
diff --git a/src/main/resources/assets/chataddons/default.conf b/src/main/resources/assets/jeanaddons/default.conf
similarity index 54%
rename from src/main/resources/assets/chataddons/default.conf
rename to src/main/resources/assets/jeanaddons/default.conf
index a6042ec..be70bbc 100644
--- a/src/main/resources/assets/chataddons/default.conf
+++ b/src/main/resources/assets/jeanaddons/default.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
diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
index ce26109..5ba955a 100644
--- a/src/main/resources/fabric.mod.json
+++ b/src/main/resources/fabric.mod.json
@@ -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}",
diff --git a/src/main/resources/chataddons.mixins.json b/src/main/resources/jeanaddons.mixins.json
similarity index 71%
rename from src/main/resources/chataddons.mixins.json
rename to src/main/resources/jeanaddons.mixins.json
index 1231dda..68e2ba4 100644
--- a/src/main/resources/chataddons.mixins.json
+++ b/src/main/resources/jeanaddons.mixins.json
@@ -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
nageyqma · 2026-09-13 · a6a4470
查看变更
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(-)
diff --git a/README.md b/README.md
index 22246ea..a36fbd2 100644
--- a/README.md
+++ b/README.md
@@ -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
 
diff --git a/gradle.properties b/gradle.properties
index f2a0fe6..9042929 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -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
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index c61a118..c42672d 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/gradlew b/gradlew
index 0262dcb..739907d 100755
--- a/gradlew
+++ b/gradlew
@@ -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/.
diff --git a/gradlew.bat b/gradlew.bat
index c4bdd3a..e509b2d 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -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
diff --git a/src/main/resources/chataddons.mixins.json b/src/main/resources/chataddons.mixins.json
index 9389c30..1231dda 100644
--- a/src/main/resources/chataddons.mixins.json
+++ b/src/main/resources/chataddons.mixins.json
@@ -2,7 +2,7 @@
   "required": true,
   "minVersion": "0.8",
   "package": "dev.chatfatigue.mixin",
-  "compatibilityLevel": "JAVA_21",
+  "compatibilityLevel": "JAVA_25",
   "injectors": {
     "defaultRequire": 1
   },
Ignore prompt.mdnageyqma · 2026-09-01 · 9ff9df2
查看变更
.gitignore | 3 +++
 1 file changed, 3 insertions(+)
diff --git a/.gitignore b/.gitignore
index ca3c2ee..53e8216 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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 commitnageyqma · 2026-09-01 · 8d9e40b
查看变更
.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(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ca3c2ee
--- /dev/null
+++ b/.gitignore
@@ -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/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d3e97f5
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..22246ea
--- /dev/null
+++ b/README.md
@@ -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).
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..c0c4ab6
--- /dev/null
+++ b/build.gradle.kts
@@ -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))
+    }
+}
diff --git a/conf/chataddons.conf b/conf/chataddons.conf
new file mode 100644
index 0000000..a6042ec
--- /dev/null
+++ b/conf/chataddons.conf
@@ -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
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f2a0fe6
--- /dev/null
+++ b/gradle.properties
@@ -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
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..d997cfc
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..c61a118
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..0262dcb
--- /dev/null
+++ b/gradlew
@@ -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" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..c4bdd3a
--- /dev/null
+++ b/gradlew.bat
@@ -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
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..8230d47
--- /dev/null
+++ b/settings.gradle.kts
@@ -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
+    }
+}
diff --git a/src/main/java/dev/chatfatigue/mixin/FontMixin.java b/src/main/java/dev/chatfatigue/mixin/FontMixin.java
new file mode 100644
index 0000000..0beea32
--- /dev/null
+++ b/src/main/java/dev/chatfatigue/mixin/FontMixin.java
@@ -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;
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/ChatAddonsMod.kt b/src/main/kotlin/dev/chatfatigue/ChatAddonsMod.kt
new file mode 100644
index 0000000..b0c42dc
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/ChatAddonsMod.kt
@@ -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.")
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/config/ConfigManager.kt b/src/main/kotlin/dev/chatfatigue/config/ConfigManager.kt
new file mode 100644
index 0000000..d48dca9
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/config/ConfigManager.kt
@@ -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)
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/config/ConfigParser.kt b/src/main/kotlin/dev/chatfatigue/config/ConfigParser.kt
new file mode 100644
index 0000000..6fef57f
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/config/ConfigParser.kt
@@ -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
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/Feature.kt b/src/main/kotlin/dev/chatfatigue/feature/Feature.kt
new file mode 100644
index 0000000..0812e4d
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/Feature.kt
@@ -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
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
new file mode 100644
index 0000000..142441f
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureAccess.kt
@@ -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)
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt
new file mode 100644
index 0000000..bd58159
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureCategory.kt
@@ -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"),
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt b/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
new file mode 100644
index 0000000..fc3fa4d
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/FeatureManager.kt
@@ -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
+    }
+}
diff --git a/src/main/kotlin/dev/chatfatigue/feature/impl/visual/RemoveTextShadow.kt b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/RemoveTextShadow.kt
new file mode 100644
index 0000000..2336286
--- /dev/null
+++ b/src/main/kotlin/dev/chatfatigue/feature/impl/visual/RemoveTextShadow.kt
@@ -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,
+)
diff --git a/src/main/resources/assets/chataddons/default.conf b/src/main/resources/assets/chataddons/default.conf
new file mode 100644
index 0000000..a6042ec
--- /dev/null
+++ b/src/main/resources/assets/chataddons/default.conf
@@ -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
diff --git a/src/main/resources/chataddons.mixins.json b/src/main/resources/chataddons.mixins.json
new file mode 100644
index 0000000..9389c30
--- /dev/null
+++ b/src/main/resources/chataddons.mixins.json
@@ -0,0 +1,12 @@
+{
+  "required": true,
+  "minVersion": "0.8",
+  "package": "dev.chatfatigue.mixin",
+  "compatibilityLevel": "JAVA_21",
+  "injectors": {
+    "defaultRequire": 1
+  },
+  "client": [
+    "FontMixin"
+  ]
+}
diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..ce26109
--- /dev/null
+++ b/src/main/resources/fabric.mod.json
@@ -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}"
+  }
+}