瀏覽代碼

introduce nation based renderers

djeada 1 月之前
父節點
當前提交
c93bf025f3
共有 100 個文件被更改,包括 12271 次插入354 次删除
  1. 2 1
      CMakeLists.txt
  2. 29 1
      README.md
  3. 28 0
      app/core/game_engine.cpp
  4. 2 0
      app/core/game_engine.h
  5. 21 1
      assets.qrc
  6. 148 0
      assets/data/nations/carthage.json
  7. 148 0
      assets/data/nations/kingdom_of_iron.json
  8. 148 0
      assets/data/nations/roman_republic.json
  9. 144 0
      assets/data/troops/base.json
  10. 4 4
      assets/maps/map_forest.json
  11. 4 4
      assets/maps/map_mountain.json
  12. 158 0
      assets/shaders/archer_carthage.frag
  13. 154 0
      assets/shaders/archer_kingdom_of_iron.frag
  14. 178 0
      assets/shaders/archer_roman_republic.frag
  15. 179 0
      assets/shaders/knight_carthage.frag
  16. 179 0
      assets/shaders/knight_kingdom_of_iron.frag
  17. 177 0
      assets/shaders/knight_roman_republic.frag
  18. 353 0
      assets/shaders/mounted_knight_carthage.frag
  19. 353 0
      assets/shaders/mounted_knight_kingdom_of_iron.frag
  20. 352 0
      assets/shaders/mounted_knight_roman_republic.frag
  21. 326 0
      assets/shaders/spearman_carthage.frag
  22. 327 0
      assets/shaders/spearman_kingdom_of_iron.frag
  23. 324 0
      assets/shaders/spearman_roman_republic.frag
  24. 二進制
      assets/visuals/emblems/cartaghe.png
  25. 二進制
      assets/visuals/emblems/rome.png
  26. 13 1
      assets/visuals/unit_visuals.json
  27. 43 0
      docs/renderer_modernization_plan.md
  28. 6 2
      game/CMakeLists.txt
  29. 17 21
      game/audio/AudioConstants.h
  30. 2 1
      game/audio/AudioEventHandler.cpp
  31. 1 1
      game/audio/AudioEventHandler.h
  32. 18 9
      game/audio/AudioSystem.cpp
  33. 10 5
      game/audio/AudioSystem.h
  34. 25 17
      game/audio/MiniaudioBackend.cpp
  35. 2 1
      game/audio/MiniaudioBackend.h
  36. 4 3
      game/audio/Music.cpp
  37. 10 4
      game/audio/MusicPlayer.cpp
  38. 6 3
      game/audio/MusicPlayer.h
  39. 2 0
      game/core/component.h
  40. 10 0
      game/core/serialization.cpp
  41. 37 9
      game/map/level_loader.cpp
  42. 9 0
      game/map/map_transformer.cpp
  43. 37 1
      game/map/skirmish_loader.cpp
  44. 22 4
      game/systems/capture_system.cpp
  45. 359 0
      game/systems/nation_loader.cpp
  46. 21 0
      game/systems/nation_loader.h
  47. 56 45
      game/systems/nation_registry.cpp
  48. 33 0
      game/systems/nation_registry.h
  49. 38 2
      game/systems/production_service.cpp
  50. 37 3
      game/systems/production_system.cpp
  51. 143 0
      game/systems/troop_profile_service.cpp
  52. 41 0
      game/systems/troop_profile_service.h
  53. 29 17
      game/units/archer.cpp
  54. 9 3
      game/units/barracks.cpp
  55. 2 2
      game/units/factory.cpp
  56. 29 17
      game/units/mounted_knight.cpp
  57. 6 5
      game/units/spawn_type.h
  58. 29 17
      game/units/spearman.cpp
  59. 36 23
      game/units/swordsman.cpp
  60. 3 3
      game/units/swordsman.h
  61. 171 0
      game/units/troop_catalog.cpp
  62. 82 0
      game/units/troop_catalog.h
  63. 229 0
      game/units/troop_catalog_loader.cpp
  64. 14 0
      game/units/troop_catalog_loader.h
  65. 24 24
      game/units/troop_config.h
  66. 6 5
      game/units/troop_type.h
  67. 19 0
      game/units/unit.cpp
  68. 3 0
      game/units/unit.h
  69. 24 6
      render/CMakeLists.txt
  70. 0 9
      render/entity/archer_renderer.h
  71. 0 1
      render/entity/arrow_vfx_renderer.cpp
  72. 3 70
      render/entity/horse_renderer.h
  73. 0 9
      render/entity/knight_renderer.h
  74. 824 0
      render/entity/nations/carthage/archer_renderer.cpp
  75. 15 0
      render/entity/nations/carthage/archer_renderer.h
  76. 39 0
      render/entity/nations/carthage/archer_style.cpp
  77. 30 0
      render/entity/nations/carthage/archer_style.h
  78. 978 0
      render/entity/nations/carthage/knight_renderer.cpp
  79. 15 0
      render/entity/nations/carthage/knight_renderer.h
  80. 34 0
      render/entity/nations/carthage/knight_style.cpp
  81. 27 0
      render/entity/nations/carthage/knight_style.h
  82. 807 0
      render/entity/nations/carthage/mounted_knight_renderer.cpp
  83. 9 0
      render/entity/nations/carthage/mounted_knight_renderer.h
  84. 578 0
      render/entity/nations/carthage/spearman_renderer.cpp
  85. 14 0
      render/entity/nations/carthage/spearman_renderer.h
  86. 31 0
      render/entity/nations/carthage/spearman_style.cpp
  87. 23 0
      render/entity/nations/carthage/spearman_style.h
  88. 816 0
      render/entity/nations/kingdom/archer_renderer.cpp
  89. 15 0
      render/entity/nations/kingdom/archer_renderer.h
  90. 35 0
      render/entity/nations/kingdom/archer_style.cpp
  91. 30 0
      render/entity/nations/kingdom/archer_style.h
  92. 978 0
      render/entity/nations/kingdom/knight_renderer.cpp
  93. 15 0
      render/entity/nations/kingdom/knight_renderer.h
  94. 33 0
      render/entity/nations/kingdom/knight_style.cpp
  95. 27 0
      render/entity/nations/kingdom/knight_style.h
  96. 807 0
      render/entity/nations/kingdom/mounted_knight_renderer.cpp
  97. 9 0
      render/entity/nations/kingdom/mounted_knight_renderer.h
  98. 578 0
      render/entity/nations/kingdom/spearman_renderer.cpp
  99. 16 0
      render/entity/nations/kingdom/spearman_renderer.h
  100. 30 0
      render/entity/nations/kingdom/spearman_style.cpp

+ 2 - 1
CMakeLists.txt

@@ -195,7 +195,8 @@ if(QT_VERSION_MAJOR EQUAL 6)
             assets/maps/map_forest.json
             assets/maps/map_rivers.json
             assets/maps/map_mountain.json
-            assets/visuals/unit_visuals.json
+            assets/visuals/emblems/rome.png
+            assets/visuals/emblems/cartaghe.png
             translations/app_en.qm
             translations/app_de.qm
             translations/app_pt_br.qm

+ 29 - 1
README.md

@@ -6,10 +6,12 @@ A modern real-time strategy (RTS) game built with C++20, Qt 6, and OpenGL 3.3 Co
 
 ### Core Gameplay
 - **Unit Production**: Build archers from barracks with production queues
+- **Distinct Nations**: Choose between the Kingdom of Iron, Roman Republic, and Carthaginian Empire
 - **Rally Points**: Set spawn locations for newly produced units (visual yellow flags)
 - **Combat System**: Ranged archer combat with health bars and visual arrow projectiles
 - **Barrack Capture**: Take control of neutral or enemy barracks with 3× troop advantage
 - **AI Opponents**: Computer-controlled enemy that produces units and attacks your base
+- **Skirmish Setup**: Pick teams, colors, and nations before launching into battle
 - **Victory/Defeat**: Win by destroying the enemy barracks, lose if yours is destroyed
 - **Team Colors**: Visual distinction between player (blue) and enemy (red) units
 
@@ -401,6 +403,33 @@ Quick start for contributors:
 3. Run `make format` before committing changes
 4. Open a Pull Request
 
+## Nation System Migration Plan
+
+This roadmap replaces the single “Kingdom of Iron” template with a scalable civilization layer that allows Romans, Carthage, and future nations to share troop classes but diverge on stats, formations, and visuals.
+
+### Phase 1 — Core Data Foundations
+- Introduce a `TroopClass` catalog describing baseline stats/metadata for each `Game::Units::TroopType` (health, speed, damage, default renderer, individuals per unit, etc.).
+- Refactor existing unit constructors (`game/units/*.cpp`) to hydrate components from the catalog instead of hard-coded literals; keep overrides minimal to validate the abstraction.
+- Extend `Nation` (`game/systems/nation_registry.h`) with a `NationTroopVariant` map that captures per-nation overrides (stat deltas, formation preference, renderer id).
+- Persist current “Kingdom of Iron” values into `assets/data/troops/base.json` plus `assets/data/nations/kingdom_of_iron.json` so runtime data mirrors today’s behavior.
+
+### Phase 2 — Loading & Profiles
+- Add a JSON loader (`game/systems/nation_loader.*`) that builds `Nation` objects from disk and registers them through `NationRegistry::initializeDefaults`.
+- Create `TroopProfileService` to merge `TroopClass` defaults with `NationTroopVariant` overrides and expose `get_profile(nationId, TroopType)`.
+- Thread the owning nation id through production: extend `SpawnParams` and update `ProductionSystem`, `UnitFactoryRegistry`, and AI spawners so units receive the correct profile at creation.
+- Update `TroopConfig` accessors to read formation spacing/individual counts from profiles, falling back to catalog defaults when overrides are absent.
+
+### Phase 3 — Multi-Nation Support
+- Author Roman and Carthaginian JSON definitions with differentiated stats, formations, and renderer ids; set the default nation in `NationRegistry` to one of them.
+- Rename the shared melee infantry profile to `Swordsman` so nations can share core assets while still tuning stats in their override files.
+- Audit gameplay systems (AI build orders, UI panels, tutorials) to resolve troop data via `NationRegistry::getNationForPlayer` instead of assuming Kingdom of Iron.
+- Register renderer variants (e.g., `render/entity/roman_archer_renderer.cpp`) keyed by the profile’s renderer id, with graceful fallbacks to baseline assets.
+- Add hooks for balance levers (passive modifiers, tech prerequisites) inside `NationTroopVariant` so future expansions require data changes rather than engine rewrites.
+
+### Validation & Rollout
+- Unit tests or integration checks should confirm: data loading succeeds, profiles are applied per player, production counts respect nation-specific `individualsPerUnit`, and renderers switch with the nation.
+- Ship the migration behind a feature flag or debug toggle if needed, then remove the legacy hard-coded nation data once parity tests pass.
+
 ## Development Status
 
 ### Completed Features ✅
@@ -445,4 +474,3 @@ This game uses the **Qt framework** (https://www.qt.io), which is licensed under
 ## Acknowledgments
 
 Built with modern C++20, Qt 6, and OpenGL 3.3 Core. Special thanks to the open-source community for excellent documentation and tools.
-

+ 28 - 0
app/core/game_engine.cpp

@@ -23,6 +23,7 @@
 #include <QQuickWindow>
 #include <QSize>
 #include <QVariant>
+#include <QVariantMap>
 #include <memory>
 #include <optional>
 #include <qbuffer.h>
@@ -199,6 +200,7 @@ GameEngine::GameEngine(QObject *parent)
 
     m_audioEventHandler->loadUnitVoiceMapping("archer", "archer_voice");
     m_audioEventHandler->loadUnitVoiceMapping("knight", "knight_voice");
+    m_audioEventHandler->loadUnitVoiceMapping("swordsman", "knight_voice");
     m_audioEventHandler->loadUnitVoiceMapping("spearman", "spearman_voice");
 
     m_audioEventHandler->loadAmbientMusic(Engine::Core::AmbientState::PEACEFUL,
@@ -1040,6 +1042,32 @@ auto GameEngine::availableMaps() const -> QVariantList {
   return m_availableMaps;
 }
 
+auto GameEngine::availableNations() const -> QVariantList {
+  QVariantList nations;
+  const auto &registry = Game::Systems::NationRegistry::instance();
+  const auto &all = registry.getAllNations();
+  QList<QVariantMap> ordered;
+  ordered.reserve(static_cast<int>(all.size()));
+  for (const auto &nation : all) {
+    QVariantMap entry;
+    entry.insert(QStringLiteral("id"), QString::fromStdString(nation.id));
+    entry.insert(QStringLiteral("name"),
+                 QString::fromStdString(nation.displayName));
+    ordered.append(entry);
+  }
+  std::sort(ordered.begin(), ordered.end(),
+            [](const QVariantMap &a, const QVariantMap &b) {
+              return a.value(QStringLiteral("name"))
+                         .toString()
+                         .localeAwareCompare(
+                             b.value(QStringLiteral("name")).toString()) < 0;
+            });
+  for (const auto &entry : ordered) {
+    nations.append(entry);
+  }
+  return nations;
+}
+
 void GameEngine::startSkirmish(const QString &map_path,
                                const QVariantList &playerConfigs) {
 

+ 2 - 0
app/core/game_engine.h

@@ -100,6 +100,7 @@ public:
   Q_PROPERTY(
       QVariantList availableMaps READ availableMaps NOTIFY availableMapsChanged)
   Q_PROPERTY(bool mapsLoading READ mapsLoading NOTIFY mapsLoadingChanged)
+  Q_PROPERTY(QVariantList availableNations READ availableNations CONSTANT)
   Q_PROPERTY(int enemyTroopsDefeated READ enemyTroopsDefeated NOTIFY
                  enemyTroopsDefeatedChanged)
   Q_PROPERTY(QVariantList ownerInfo READ getOwnerInfo NOTIFY ownerInfoChanged)
@@ -175,6 +176,7 @@ public:
   Q_INVOKABLE [[nodiscard]] QString getSelectedUnitsCommandMode() const;
   Q_INVOKABLE void setRallyAtScreen(qreal sx, qreal sy);
   Q_INVOKABLE [[nodiscard]] QVariantList availableMaps() const;
+  [[nodiscard]] QVariantList availableNations() const;
   [[nodiscard]] bool mapsLoading() const { return m_mapsLoading; }
   Q_INVOKABLE void
   startSkirmish(const QString &map_path,

+ 21 - 1
assets.qrc

@@ -3,6 +3,9 @@
         <!-- Shader files -->
         <file>assets/shaders/archer.frag</file>
         <file>assets/shaders/archer.vert</file>
+        <file>assets/shaders/archer_kingdom_of_iron.frag</file>
+        <file>assets/shaders/archer_roman_republic.frag</file>
+        <file>assets/shaders/archer_carthage.frag</file>
         <file>assets/shaders/basic.frag</file>
         <file>assets/shaders/basic.vert</file>
         <file>assets/shaders/bridge.frag</file>
@@ -20,8 +23,14 @@
         <file>assets/shaders/ground_plane.vert</file>
         <file>assets/shaders/knight.frag</file>
         <file>assets/shaders/knight.vert</file>
+        <file>assets/shaders/knight_kingdom_of_iron.frag</file>
+        <file>assets/shaders/knight_roman_republic.frag</file>
+        <file>assets/shaders/knight_carthage.frag</file>
         <file>assets/shaders/mounted_knight.frag</file>
         <file>assets/shaders/mounted_knight.vert</file>
+        <file>assets/shaders/mounted_knight_kingdom_of_iron.frag</file>
+        <file>assets/shaders/mounted_knight_roman_republic.frag</file>
+        <file>assets/shaders/mounted_knight_carthage.frag</file>
         <file>assets/shaders/pine_instanced.frag</file>
         <file>assets/shaders/pine_instanced.vert</file>
         <file>assets/shaders/plant_instanced.frag</file>
@@ -32,6 +41,9 @@
         <file>assets/shaders/riverbank.vert</file>
         <file>assets/shaders/spearman.frag</file>
         <file>assets/shaders/spearman.vert</file>
+        <file>assets/shaders/spearman_kingdom_of_iron.frag</file>
+        <file>assets/shaders/spearman_roman_republic.frag</file>
+        <file>assets/shaders/spearman_carthage.frag</file>
         <file>assets/shaders/stone_instanced.frag</file>
         <file>assets/shaders/stone_instanced.vert</file>
         <file>assets/shaders/terrain_chunk.frag</file>
@@ -41,8 +53,16 @@
         <file>assets/maps/map_forest.json</file>
         <file>assets/maps/map_rivers.json</file>
         <file>assets/maps/map_mountain.json</file>
-        
+
         <!-- Visual config -->
         <file>assets/visuals/unit_visuals.json</file>
+        <file>assets/visuals/emblems/rome.png</file>
+        <file>assets/visuals/emblems/cartaghe.png</file>
+
+        <!-- Gameplay data -->
+        <file>assets/data/troops/base.json</file>
+        <file>assets/data/nations/kingdom_of_iron.json</file>
+        <file>assets/data/nations/roman_republic.json</file>
+        <file>assets/data/nations/carthage.json</file>
     </qresource>
 </RCC>

+ 148 - 0
assets/data/nations/carthage.json

@@ -0,0 +1,148 @@
+{
+  "id": "carthage",
+  "display_name": "Carthaginian Empire",
+  "primary_building": "barracks",
+  "formation_type": "barbarian",
+  "troops": [
+    {
+      "id": "archer",
+      "display_name": "Libyan Archer",
+      "production": {
+        "cost": 50,
+        "build_time": 5.0,
+        "priority": 9,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 75,
+        "max_health": 75,
+        "speed": 3.2,
+        "vision_range": 16.5,
+        "ranged_range": 6.6,
+        "ranged_damage": 12,
+        "ranged_cooldown": 1.15,
+        "melee_range": 1.5,
+        "melee_damage": 4,
+        "melee_cooldown": 0.9,
+        "can_ranged": true,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.5,
+        "selection_ring_size": 1.2,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/carthage/archer"
+      },
+      "formation": {
+        "individuals_per_unit": 22,
+        "max_units_per_row": 6
+      }
+    },
+    {
+      "id": "swordsman",
+      "display_name": "Citizen Infantry",
+      "production": {
+        "cost": 95,
+        "build_time": 6.8,
+        "priority": 10,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 145,
+        "max_health": 145,
+        "speed": 2.3,
+        "vision_range": 14.0,
+        "ranged_range": 1.5,
+        "ranged_damage": 6,
+        "ranged_cooldown": 1.9,
+        "melee_range": 1.6,
+        "melee_damage": 20,
+        "melee_cooldown": 0.6,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.6,
+        "selection_ring_size": 1.1,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/carthage/swordsman"
+      },
+      "formation": {
+        "individuals_per_unit": 16,
+        "max_units_per_row": 5
+      }
+    },
+    {
+      "id": "spearman",
+      "display_name": "Liby-Phoenician Spear",
+      "production": {
+        "cost": 85,
+        "build_time": 6.0,
+        "priority": 11,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 125,
+        "max_health": 125,
+        "speed": 2.6,
+        "vision_range": 15.0,
+        "ranged_range": 2.9,
+        "ranged_damage": 9,
+        "ranged_cooldown": 1.3,
+        "melee_range": 2.8,
+        "melee_damage": 19,
+        "melee_cooldown": 0.72,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.57,
+        "selection_ring_size": 1.48,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/carthage/spearman"
+      },
+      "formation": {
+        "individuals_per_unit": 28,
+        "max_units_per_row": 7
+      }
+    },
+    {
+      "id": "mounted_knight",
+      "display_name": "Numidian Cavalry",
+      "production": {
+        "cost": 150,
+        "build_time": 9.4,
+        "priority": 16,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 195,
+        "max_health": 195,
+        "speed": 8.6,
+        "vision_range": 18.0,
+        "ranged_range": 1.5,
+        "ranged_damage": 5,
+        "ranged_cooldown": 1.7,
+        "melee_range": 2.0,
+        "melee_damage": 26,
+        "melee_cooldown": 0.7,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.82,
+        "selection_ring_size": 2.1,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 1.35,
+        "renderer_id": "troops/carthage/mounted_knight"
+      },
+      "formation": {
+        "individuals_per_unit": 9,
+        "max_units_per_row": 3
+      }
+    }
+  ]
+}

+ 148 - 0
assets/data/nations/kingdom_of_iron.json

@@ -0,0 +1,148 @@
+{
+  "id": "kingdom_of_iron",
+  "display_name": "Kingdom of Iron",
+  "primary_building": "barracks",
+  "formation_type": "roman",
+  "troops": [
+    {
+      "id": "archer",
+      "display_name": "Archer",
+      "production": {
+        "cost": 50,
+        "build_time": 5.0,
+        "priority": 10,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 80,
+        "max_health": 80,
+        "speed": 3.0,
+        "vision_range": 16.0,
+        "ranged_range": 6.0,
+        "ranged_damage": 12,
+        "ranged_cooldown": 1.2,
+        "melee_range": 1.5,
+        "melee_damage": 5,
+        "melee_cooldown": 0.8,
+        "can_ranged": true,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.5,
+        "selection_ring_size": 1.2,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/kingdom/archer"
+      },
+      "formation": {
+        "individuals_per_unit": 20,
+        "max_units_per_row": 5
+      }
+    },
+    {
+      "id": "swordsman",
+      "display_name": "Swordsman",
+      "production": {
+        "cost": 100,
+        "build_time": 8.0,
+        "priority": 10,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 150,
+        "max_health": 150,
+        "speed": 2.0,
+        "vision_range": 14.0,
+        "ranged_range": 1.5,
+        "ranged_damage": 5,
+        "ranged_cooldown": 2.0,
+        "melee_range": 1.5,
+        "melee_damage": 20,
+        "melee_cooldown": 0.6,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.6,
+        "selection_ring_size": 1.1,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/kingdom/swordsman"
+      },
+      "formation": {
+        "individuals_per_unit": 15,
+        "max_units_per_row": 5
+      }
+    },
+    {
+      "id": "spearman",
+      "display_name": "Spearman",
+      "production": {
+        "cost": 75,
+        "build_time": 6.0,
+        "priority": 5,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 120,
+        "max_health": 120,
+        "speed": 2.5,
+        "vision_range": 15.0,
+        "ranged_range": 2.5,
+        "ranged_damage": 8,
+        "ranged_cooldown": 1.5,
+        "melee_range": 2.5,
+        "melee_damage": 18,
+        "melee_cooldown": 0.8,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.55,
+        "selection_ring_size": 1.4,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/kingdom/spearman"
+      },
+      "formation": {
+        "individuals_per_unit": 24,
+        "max_units_per_row": 6
+      }
+    },
+    {
+      "id": "mounted_knight",
+      "display_name": "Mounted Knight",
+      "production": {
+        "cost": 150,
+        "build_time": 10.0,
+        "priority": 15,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 200,
+        "max_health": 200,
+        "speed": 8.0,
+        "vision_range": 16.0,
+        "ranged_range": 1.5,
+        "ranged_damage": 5,
+        "ranged_cooldown": 2.0,
+        "melee_range": 2.0,
+        "melee_damage": 25,
+        "melee_cooldown": 0.8,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.8,
+        "selection_ring_size": 2.0,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 1.35,
+        "renderer_id": "troops/kingdom/mounted_knight"
+      },
+      "formation": {
+        "individuals_per_unit": 9,
+        "max_units_per_row": 3
+      }
+    }
+  ]
+}

+ 148 - 0
assets/data/nations/roman_republic.json

@@ -0,0 +1,148 @@
+{
+  "id": "roman_republic",
+  "display_name": "Roman Republic",
+  "primary_building": "barracks",
+  "formation_type": "roman",
+  "troops": [
+    {
+      "id": "archer",
+      "display_name": "Auxiliary Archer",
+      "production": {
+        "cost": 55,
+        "build_time": 5.2,
+        "priority": 10,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 85,
+        "max_health": 85,
+        "speed": 3.1,
+        "vision_range": 17.0,
+        "ranged_range": 6.4,
+        "ranged_damage": 13,
+        "ranged_cooldown": 1.1,
+        "melee_range": 1.5,
+        "melee_damage": 5,
+        "melee_cooldown": 0.8,
+        "can_ranged": true,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.5,
+        "selection_ring_size": 1.2,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/roman/archer"
+      },
+      "formation": {
+        "individuals_per_unit": 24,
+        "max_units_per_row": 6
+      }
+    },
+    {
+      "id": "swordsman",
+      "display_name": "Legionary",
+      "production": {
+        "cost": 110,
+        "build_time": 7.0,
+        "priority": 12,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 170,
+        "max_health": 170,
+        "speed": 2.1,
+        "vision_range": 15.0,
+        "ranged_range": 1.6,
+        "ranged_damage": 6,
+        "ranged_cooldown": 1.8,
+        "melee_range": 1.7,
+        "melee_damage": 22,
+        "melee_cooldown": 0.55,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.62,
+        "selection_ring_size": 1.15,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/roman/swordsman"
+      },
+      "formation": {
+        "individuals_per_unit": 18,
+        "max_units_per_row": 6
+      }
+    },
+    {
+      "id": "spearman",
+      "display_name": "Triarius",
+      "production": {
+        "cost": 90,
+        "build_time": 6.5,
+        "priority": 9,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 130,
+        "max_health": 130,
+        "speed": 2.4,
+        "vision_range": 15.5,
+        "ranged_range": 2.7,
+        "ranged_damage": 8,
+        "ranged_cooldown": 1.4,
+        "melee_range": 2.6,
+        "melee_damage": 19,
+        "melee_cooldown": 0.75,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.56,
+        "selection_ring_size": 1.45,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/roman/spearman"
+      },
+      "formation": {
+        "individuals_per_unit": 26,
+        "max_units_per_row": 7
+      }
+    },
+    {
+      "id": "mounted_knight",
+      "display_name": "Equites",
+      "production": {
+        "cost": 165,
+        "build_time": 10.2,
+        "priority": 15,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 210,
+        "max_health": 210,
+        "speed": 8.2,
+        "vision_range": 17.5,
+        "ranged_range": 1.5,
+        "ranged_damage": 5,
+        "ranged_cooldown": 1.9,
+        "melee_range": 2.1,
+        "melee_damage": 27,
+        "melee_cooldown": 0.75,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.8,
+        "selection_ring_size": 2.05,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 1.35,
+        "renderer_id": "troops/roman/mounted_knight"
+      },
+      "formation": {
+        "individuals_per_unit": 9,
+        "max_units_per_row": 3
+      }
+    }
+  ]
+}

+ 144 - 0
assets/data/troops/base.json

@@ -0,0 +1,144 @@
+{
+  "troops": [
+    {
+      "id": "archer",
+      "display_name": "Archer",
+      "production": {
+        "cost": 50,
+        "build_time": 5.0,
+        "priority": 10,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 80,
+        "max_health": 80,
+        "speed": 3.0,
+        "vision_range": 16.0,
+        "ranged_range": 6.0,
+        "ranged_damage": 12,
+        "ranged_cooldown": 1.2,
+        "melee_range": 1.5,
+        "melee_damage": 5,
+        "melee_cooldown": 0.8,
+        "can_ranged": true,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.5,
+        "selection_ring_size": 1.2,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/kingdom/archer"
+      },
+      "formation": {
+        "individuals_per_unit": 20,
+        "max_units_per_row": 5
+      }
+    },
+    {
+      "id": "swordsman",
+      "display_name": "Swordsman",
+      "production": {
+        "cost": 90,
+        "build_time": 7.0,
+        "priority": 10,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 140,
+        "max_health": 140,
+        "speed": 2.1,
+        "vision_range": 14.0,
+        "ranged_range": 1.6,
+        "ranged_damage": 6,
+        "ranged_cooldown": 1.9,
+        "melee_range": 1.6,
+        "melee_damage": 18,
+        "melee_cooldown": 0.6,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.6,
+        "selection_ring_size": 1.1,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/kingdom/swordsman"
+      },
+      "formation": {
+        "individuals_per_unit": 15,
+        "max_units_per_row": 5
+      }
+    },
+    {
+      "id": "spearman",
+      "display_name": "Spearman",
+      "production": {
+        "cost": 75,
+        "build_time": 6.0,
+        "priority": 5,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 120,
+        "max_health": 120,
+        "speed": 2.5,
+        "vision_range": 15.0,
+        "ranged_range": 2.5,
+        "ranged_damage": 8,
+        "ranged_cooldown": 1.5,
+        "melee_range": 2.5,
+        "melee_damage": 18,
+        "melee_cooldown": 0.8,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.55,
+        "selection_ring_size": 1.4,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/kingdom/spearman"
+      },
+      "formation": {
+        "individuals_per_unit": 24,
+        "max_units_per_row": 6
+      }
+    },
+    {
+      "id": "mounted_knight",
+      "display_name": "Mounted Knight",
+      "production": {
+        "cost": 150,
+        "build_time": 10.0,
+        "priority": 15,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 200,
+        "max_health": 200,
+        "speed": 8.0,
+        "vision_range": 16.0,
+        "ranged_range": 1.5,
+        "ranged_damage": 5,
+        "ranged_cooldown": 2.0,
+        "melee_range": 2.0,
+        "melee_damage": 25,
+        "melee_cooldown": 0.8,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.8,
+        "selection_ring_size": 2.0,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 1.35,
+        "renderer_id": "troops/kingdom/mounted_knight"
+      },
+      "formation": {
+        "individuals_per_unit": 9,
+        "max_units_per_row": 3
+      }
+    }
+  ]
+}

+ 4 - 4
assets/maps/map_forest.json

@@ -44,8 +44,8 @@
     },
     { "type": "archer", "x": 58, "z": 62, "playerId": 1 },
     { "type": "archer", "x": 62, "z": 58, "playerId": 1 },
-    { "type": "knight", "x": 55, "z": 60, "playerId": 1 },
-    { "type": "knight", "x": 60, "z": 55, "playerId": 1 },
+    { "type": "swordsman", "x": 55, "z": 60, "playerId": 1 },
+    { "type": "swordsman", "x": 60, "z": 55, "playerId": 1 },
     { "type": "spearman", "x": 58, "z": 58, "playerId": 1 },
     { "type": "spearman", "x": 62, "z": 62, "playerId": 1 },
     {
@@ -74,8 +74,8 @@
       "playerId": 3,
       "maxPopulation": 150
     },
-    { "type": "knight", "x": 58, "z": 192, "playerId": 3 },
-    { "type": "knight", "x": 62, "z": 188, "playerId": 3 },
+    { "type": "swordsman", "x": 58, "z": 192, "playerId": 3 },
+    { "type": "swordsman", "x": 62, "z": 188, "playerId": 3 },
     { "type": "archer", "x": 55, "z": 190, "playerId": 3 },
     {
       "type": "barracks",

+ 4 - 4
assets/maps/map_mountain.json

@@ -45,8 +45,8 @@
     { "type": "archer", "x": 73, "z": 77, "playerId": 1 },
     { "type": "archer", "x": 77, "z": 73, "playerId": 1 },
     { "type": "archer", "x": 75, "z": 79, "playerId": 1 },
-    { "type": "knight", "x": 71, "z": 75, "playerId": 1 },
-    { "type": "knight", "x": 79, "z": 75, "playerId": 1 },
+    { "type": "swordsman", "x": 71, "z": 75, "playerId": 1 },
+    { "type": "swordsman", "x": 79, "z": 75, "playerId": 1 },
     { "type": "spearman", "x": 73, "z": 73, "playerId": 1 },
     { "type": "spearman", "x": 77, "z": 77, "playerId": 1 },
     {
@@ -75,8 +75,8 @@
       "playerId": 3,
       "maxPopulation": 150
     },
-    { "type": "knight", "x": 73, "z": 227, "playerId": 3 },
-    { "type": "knight", "x": 77, "z": 223, "playerId": 3 },
+    { "type": "swordsman", "x": 73, "z": 227, "playerId": 3 },
+    { "type": "swordsman", "x": 77, "z": 223, "playerId": 3 },
     { "type": "archer", "x": 71, "z": 225, "playerId": 3 },
     { "type": "spearman", "x": 75, "z": 221, "playerId": 3 },
     {

+ 158 - 0
assets/shaders/archer_carthage.frag

@@ -0,0 +1,158 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+// Roman chainmail (lorica hamata) ring pattern
+float chainmailRings(vec2 p) {
+  vec2 grid = fract(p * 32.0) - 0.5;
+  float ring = length(grid);
+  float ringPattern =
+      smoothstep(0.38, 0.32, ring) - smoothstep(0.28, 0.22, ring);
+
+  // Offset rows for interlocking
+  vec2 offsetGrid = fract(p * 32.0 + vec2(0.5, 0.0)) - 0.5;
+  float offsetRing = length(offsetGrid);
+  float offsetPattern =
+      smoothstep(0.38, 0.32, offsetRing) - smoothstep(0.28, 0.22, offsetRing);
+
+  return (ringPattern + offsetPattern) * 0.14;
+}
+
+// Leather pteruges strips (hanging skirt/shoulder guards)
+float pterugesStrips(vec2 p, float y) {
+  // Vertical leather strips
+  float stripX = fract(p.x * 9.0);
+  float strip = smoothstep(0.15, 0.20, stripX) - smoothstep(0.80, 0.85, stripX);
+
+  // Add leather texture to strips
+  float leatherTex = noise(p * 18.0) * 0.35;
+
+  // Strips hang and curve
+  float hang = smoothstep(0.65, 0.45, y);
+
+  return strip * leatherTex * hang;
+}
+
+void main() {
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 normal = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 4.5;
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+
+  // Detect bronze vs steel by color warmth
+  bool isBronze =
+      (color.r > color.g * 1.02 && color.r > color.b * 1.10 && avgColor > 0.48);
+  bool isSeaCloak = (color.g > color.r * 1.2 && color.b > color.r * 1.3);
+
+  // === CARTHAGINIAN MARINE ARCHER MATERIALS ===
+
+  // SUN-DULLED BRONZE WITH SEA SALT PATINA
+  if (isBronze) {
+    float saltPatina = noise(uv * 9.0) * 0.16;
+    float verdigris = noise(uv * 12.0) * 0.10;
+    float viewAngle = abs(dot(normal, normalize(vec3(0.1, 1.0, 0.2))));
+    float bronzeSheen = pow(viewAngle, 6.5) * 0.22;
+    float bronzeFresnel = pow(1.0 - viewAngle, 2.0) * 0.20;
+    color += vec3(bronzeSheen + bronzeFresnel);
+    color -= vec3(saltPatina * 0.4 + verdigris * 0.35);
+  }
+  // DARKENED IRON MAIL
+  else if (avgColor > 0.35 && avgColor <= 0.58 && !isSeaCloak) {
+    float rings = chainmailRings(v_worldPos.xz);
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.4))));
+    float chainSheen = pow(viewAngle, 5.5) * 0.18;
+    float brineWear = noise(uv * 11.0) * 0.07;
+
+    color += vec3(rings * 0.9 + chainSheen);
+    color -= vec3(brineWear * 0.3);
+  }
+  // TEAL SEA CLOAK
+  else if (isSeaCloak) {
+    float weaveX = sin(v_worldPos.x * 48.0);
+    float weaveZ = sin(v_worldPos.z * 52.0);
+    float weave = weaveX * weaveZ * 0.040;
+    float woolFuzz = noise(uv * 19.0) * 0.08;
+    float folds = noise(uv * 7.0) * 0.10 - 0.05;
+    float capeSheen =
+        pow(1.0 - abs(dot(normal, vec3(0.0, 1.0, 0.2))), 7.5) * 0.07;
+
+    color *= 1.0 + woolFuzz - 0.05 + folds;
+    color += vec3(weave + capeSheen);
+  }
+  // LEATHER PTERUGES & ARMOR STRIPS (tan/brown leather strips)
+  else if (avgColor > 0.35) {
+    // Thick leather with visible grain
+    float leatherGrain = noise(uv * 10.0) * 0.16;
+    float leatherPores = noise(uv * 22.0) * 0.08;
+
+    // Pteruges strip pattern
+    float strips = pterugesStrips(v_worldPos.xz, v_worldPos.y);
+
+    // Worn leather edges
+    float wear = noise(uv * 4.0) * 0.10 - 0.05;
+
+    // Leather has subtle sheen
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float leatherSheen = pow(1.0 - viewAngle, 4.5) * 0.10;
+
+    color *= 1.0 + leatherGrain + leatherPores - 0.08 + wear;
+    color += vec3(strips * 0.15 + leatherSheen);
+  }
+  // DARK ELEMENTS (cingulum belt, straps, manicae)
+  else {
+    float leatherDetail = noise(uv * 8.0) * 0.14;
+    float tooling = noise(uv * 16.0) * 0.06; // Decorative tooling
+    float darkening = noise(uv * 2.5) * 0.08;
+
+    color *= 1.0 + leatherDetail - 0.07 + tooling - darkening;
+  }
+
+  color = clamp(color, 0.0, 1.0);
+
+  // Lighting model - soft wrap for leather/fabric, harder for metal
+  vec3 lightDir = normalize(vec3(1.0, 1.15, 1.0));
+  float nDotL = dot(normal, lightDir);
+
+  // Metal = harder shadows, Fabric/leather = soft wrap
+  float wrapAmount = isBronze ? 0.18 : 0.40;
+  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.22);
+
+  // Enhance contrast for bronze
+  if (isBronze) {
+    diff = pow(diff, 0.90);
+  }
+
+  color *= diff;
+  FragColor = vec4(color, u_alpha);
+}

+ 154 - 0
assets/shaders/archer_kingdom_of_iron.frag

@@ -0,0 +1,154 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+// Roman chainmail (lorica hamata) ring pattern
+float chainmailRings(vec2 p) {
+  vec2 grid = fract(p * 32.0) - 0.5;
+  float ring = length(grid);
+  float ringPattern =
+      smoothstep(0.38, 0.32, ring) - smoothstep(0.28, 0.22, ring);
+
+  // Offset rows for interlocking
+  vec2 offsetGrid = fract(p * 32.0 + vec2(0.5, 0.0)) - 0.5;
+  float offsetRing = length(offsetGrid);
+  float offsetPattern =
+      smoothstep(0.38, 0.32, offsetRing) - smoothstep(0.28, 0.22, offsetRing);
+
+  return (ringPattern + offsetPattern) * 0.14;
+}
+
+// Leather pteruges strips (hanging skirt/shoulder guards)
+float pterugesStrips(vec2 p, float y) {
+  // Vertical leather strips
+  float stripX = fract(p.x * 9.0);
+  float strip = smoothstep(0.15, 0.20, stripX) - smoothstep(0.80, 0.85, stripX);
+
+  // Add leather texture to strips
+  float leatherTex = noise(p * 18.0) * 0.35;
+
+  // Strips hang and curve
+  float hang = smoothstep(0.65, 0.45, y);
+
+  return strip * leatherTex * hang;
+}
+
+void main() {
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 normal = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 4.5;
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+
+  // Detect bronze vs steel by color warmth
+  bool isBronze =
+      (color.r > color.g * 1.02 && color.r > color.b * 1.06 && avgColor > 0.45);
+  bool isRoyalCape = (color.b > color.g * 1.25 && color.b > color.r * 1.4);
+
+  // === KINGDOM OF IRON STANDARD ISSUE MATERIALS ===
+
+  // BRUSHED STEEL WITH WARM ACCENTS
+  if (isBronze) {
+    float steelBrush = noise(uv * 20.0) * 0.08;
+    float steelSpec =
+        pow(abs(dot(normal, normalize(vec3(0.1, 1.0, 0.3)))), 8.0) * 0.35;
+    float steelFresnel = pow(1.0 - abs(normal.y), 2.4) * 0.22;
+    color += vec3(steelBrush * 0.4 + steelSpec + steelFresnel);
+  }
+  // STEEL CHAINMAIL
+  else if (avgColor > 0.38 && avgColor <= 0.65 && !isRoyalCape) {
+    float rings = chainmailRings(v_worldPos.xz);
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.6))));
+    float chainSheen = pow(viewAngle, 6.0) * 0.20;
+    float patina = noise(uv * 14.0) * 0.05;
+
+    color += vec3(rings * 0.8 + chainSheen);
+    color -= vec3(patina * 0.3);
+  }
+  // NAVY BATTLE CAPE
+  else if (isRoyalCape) {
+    float weaveX = sin(v_worldPos.x * 45.0);
+    float weaveZ = sin(v_worldPos.z * 48.0);
+    float weave = weaveX * weaveZ * 0.035;
+    float woolFuzz = noise(uv * 18.0) * 0.08;
+    float folds = noise(uv * 5.0) * 0.10 - 0.05;
+    float capeSheen = pow(1.0 - abs(normal.y), 6.0) * 0.06;
+
+    color *= 1.0 + woolFuzz - 0.04 + folds;
+    color += vec3(weave * 0.7 + capeSheen);
+  }
+  // LEATHER PTERUGES & ARMOR STRIPS (tan/brown leather strips)
+  else if (avgColor > 0.35) {
+    // Thick leather with visible grain
+    float leatherGrain = noise(uv * 10.0) * 0.16;
+    float leatherPores = noise(uv * 22.0) * 0.08;
+
+    // Pteruges strip pattern
+    float strips = pterugesStrips(v_worldPos.xz, v_worldPos.y);
+
+    // Worn leather edges
+    float wear = noise(uv * 4.0) * 0.10 - 0.05;
+
+    // Leather has subtle sheen
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float leatherSheen = pow(1.0 - viewAngle, 4.5) * 0.10;
+
+    color *= 1.0 + leatherGrain + leatherPores - 0.08 + wear;
+    color += vec3(strips * 0.15 + leatherSheen);
+  }
+  // DARK ELEMENTS (cingulum belt, straps, manicae)
+  else {
+    float leatherDetail = noise(uv * 8.0) * 0.14;
+    float tooling = noise(uv * 16.0) * 0.06; // Decorative tooling
+    float darkening = noise(uv * 2.5) * 0.08;
+
+    color *= 1.0 + leatherDetail - 0.07 + tooling - darkening;
+  }
+
+  color = clamp(color, 0.0, 1.0);
+
+  // Lighting model - soft wrap for leather/fabric, harder for metal
+  vec3 lightDir = normalize(vec3(1.0, 1.15, 1.0));
+  float nDotL = dot(normal, lightDir);
+
+  float wrapAmount = isBronze ? 0.20 : 0.40;
+  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.22);
+
+  // Enhance contrast for bronze
+  if (isBronze) {
+    diff = pow(diff, 0.88);
+  }
+
+  color *= diff;
+  FragColor = vec4(color, u_alpha);
+}

+ 178 - 0
assets/shaders/archer_roman_republic.frag

@@ -0,0 +1,178 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+// Roman chainmail (lorica hamata) ring pattern
+float chainmailRings(vec2 p) {
+  vec2 grid = fract(p * 32.0) - 0.5;
+  float ring = length(grid);
+  float ringPattern =
+      smoothstep(0.38, 0.32, ring) - smoothstep(0.28, 0.22, ring);
+
+  // Offset rows for interlocking
+  vec2 offsetGrid = fract(p * 32.0 + vec2(0.5, 0.0)) - 0.5;
+  float offsetRing = length(offsetGrid);
+  float offsetPattern =
+      smoothstep(0.38, 0.32, offsetRing) - smoothstep(0.28, 0.22, offsetRing);
+
+  return (ringPattern + offsetPattern) * 0.14;
+}
+
+// Leather pteruges strips (hanging skirt/shoulder guards)
+float pterugesStrips(vec2 p, float y) {
+  // Vertical leather strips
+  float stripX = fract(p.x * 9.0);
+  float strip = smoothstep(0.15, 0.20, stripX) - smoothstep(0.80, 0.85, stripX);
+
+  // Add leather texture to strips
+  float leatherTex = noise(p * 18.0) * 0.35;
+
+  // Strips hang and curve
+  float hang = smoothstep(0.65, 0.45, y);
+
+  return strip * leatherTex * hang;
+}
+
+void main() {
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 normal = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 4.5;
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+
+  // Detect bronze vs steel by color warmth
+  bool isBronze =
+      (color.r > color.g * 1.08 && color.r > color.b * 1.15 && avgColor > 0.50);
+  bool isRedCape = (color.r > color.g * 1.3 && color.r > color.b * 1.4);
+
+  // === ROMAN ARCHER (SAGITTARIUS) MATERIALS ===
+
+  // BRONZE GALEA HELMET & PHALERAE (warm golden metal)
+  if (isBronze) {
+    // Ancient bronze patina and wear
+    float bronzePatina = noise(uv * 8.0) * 0.12;
+    float verdigris = noise(uv * 15.0) * 0.08; // Green oxidation
+
+    // Bronze is less reflective than polished steel
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float bronzeSheen = pow(viewAngle, 7.0) * 0.25;
+    float bronzeFresnel = pow(1.0 - viewAngle, 2.2) * 0.18;
+
+    // Hammer marks from forging
+    float hammerMarks = noise(uv * 25.0) * 0.035;
+
+    color += vec3(bronzeSheen + bronzeFresnel);
+    color -= vec3(bronzePatina * 0.4 + verdigris * 0.3);
+    color += vec3(hammerMarks * 0.5);
+  }
+  // STEEL CHAINMAIL (lorica hamata - grey-blue tint)
+  else if (avgColor > 0.40 && avgColor <= 0.60 && !isRedCape) {
+    // Interlocked iron rings
+    float rings = chainmailRings(v_worldPos.xz);
+
+    // Chainmail has dull metallic sheen
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float chainSheen = pow(viewAngle, 5.0) * 0.16;
+
+    // Iron rust spots
+    float rust = noise(uv * 10.0) * 0.08;
+
+    color += vec3(rings + chainSheen);
+    color -= vec3(rust * 0.4);              // Darken with age
+    color *= 1.0 - noise(uv * 18.0) * 0.06; // Shadow between rings
+  }
+  // RED SAGUM CAPE (bright red woolen cloak)
+  else if (isRedCape) {
+    // Thick woolen weave
+    float weaveX = sin(v_worldPos.x * 55.0);
+    float weaveZ = sin(v_worldPos.z * 55.0);
+    float weave = weaveX * weaveZ * 0.045;
+
+    // Wool texture (fuzzy)
+    float woolFuzz = noise(uv * 20.0) * 0.10;
+
+    // Fabric folds and draping
+    float folds = noise(uv * 6.0) * 0.12 - 0.06;
+
+    // Soft fabric sheen
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float capeSheen = pow(1.0 - viewAngle, 8.0) * 0.08;
+
+    color *= 1.0 + woolFuzz - 0.05 + folds;
+    color += vec3(weave + capeSheen);
+  }
+  // LEATHER PTERUGES & ARMOR STRIPS (tan/brown leather strips)
+  else if (avgColor > 0.35) {
+    // Thick leather with visible grain
+    float leatherGrain = noise(uv * 10.0) * 0.16;
+    float leatherPores = noise(uv * 22.0) * 0.08;
+
+    // Pteruges strip pattern
+    float strips = pterugesStrips(v_worldPos.xz, v_worldPos.y);
+
+    // Worn leather edges
+    float wear = noise(uv * 4.0) * 0.10 - 0.05;
+
+    // Leather has subtle sheen
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float leatherSheen = pow(1.0 - viewAngle, 4.5) * 0.10;
+
+    color *= 1.0 + leatherGrain + leatherPores - 0.08 + wear;
+    color += vec3(strips * 0.15 + leatherSheen);
+  }
+  // DARK ELEMENTS (cingulum belt, straps, manicae)
+  else {
+    float leatherDetail = noise(uv * 8.0) * 0.14;
+    float tooling = noise(uv * 16.0) * 0.06; // Decorative tooling
+    float darkening = noise(uv * 2.5) * 0.08;
+
+    color *= 1.0 + leatherDetail - 0.07 + tooling - darkening;
+  }
+
+  color = clamp(color, 0.0, 1.0);
+
+  // Lighting model - soft wrap for leather/fabric, harder for metal
+  vec3 lightDir = normalize(vec3(1.0, 1.15, 1.0));
+  float nDotL = dot(normal, lightDir);
+
+  // Metal = harder shadows, Fabric/leather = soft wrap
+  float wrapAmount = isBronze ? 0.15 : 0.38;
+  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.22);
+
+  // Enhance contrast for bronze
+  if (isBronze) {
+    diff = pow(diff, 0.90);
+  }
+
+  color *= diff;
+  FragColor = vec4(color, u_alpha);
+}

+ 179 - 0
assets/shaders/knight_carthage.frag

@@ -0,0 +1,179 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+// Medieval plate armor articulation lines
+float armorPlates(vec2 p, float y) {
+  // Horizontal articulation lines (overlapping plates)
+  float plateY = fract(y * 6.5);
+  float plateLine = smoothstep(0.90, 0.98, plateY) * 0.12;
+
+  // Brass rivet decorations
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  float rivetPattern = rivet * step(0.92, plateY) * 0.25; // Brass is brighter
+
+  return plateLine + rivetPattern;
+}
+
+// Chainmail texture pattern
+float chainmailRings(vec2 p) {
+  vec2 grid = fract(p * 35.0) - 0.5;
+  float ring = length(grid);
+  float ringPattern =
+      smoothstep(0.35, 0.30, ring) - smoothstep(0.25, 0.20, ring);
+
+  // Offset every other row for interlinked appearance
+  vec2 offsetGrid = fract(p * 35.0 + vec2(0.5, 0.0)) - 0.5;
+  float offsetRing = length(offsetGrid);
+  float offsetPattern =
+      smoothstep(0.35, 0.30, offsetRing) - smoothstep(0.25, 0.20, offsetRing);
+
+  return (ringPattern + offsetPattern) * 0.15;
+}
+
+void main() {
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 normal = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+
+  // Detect material type by color tone
+  float colorHue =
+      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
+  bool isBrass =
+      (color.r > color.g * 1.10 && color.r > color.b * 1.10 && avgColor > 0.48);
+
+  // === MEDIEVAL KNIGHT MATERIALS ===
+
+  // POLISHED STEEL PLATE (Great Helm, cuirass, pauldrons, rerebraces) - bright
+  // silvery
+  if (avgColor > 0.60 && !isBrass) {
+    // Mirror-polished steel finish
+    float brushedMetal = abs(sin(v_worldPos.y * 88.0)) * 0.024;
+
+    // Battle wear: scratches and dents
+    float scratches = noise(uv * 33.0) * 0.019;
+    float dents = noise(uv * 7.5) * 0.024;
+
+    // Plate articulation lines and rivets
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // Strong specular reflections (polished metal)
+    float viewAngle = abs(dot(normal, normalize(vec3(0.1, 1.0, 0.3))));
+    float fresnel = pow(1.0 - viewAngle, 1.9) * 0.33;
+    float specular = pow(viewAngle, 11.5) * 0.50;
+
+    // Environmental reflections (sky dome)
+    float skyReflection = (normal.y * 0.5 + 0.5) * 0.10;
+
+    color += vec3(fresnel + skyReflection + specular * 1.8);
+    color += vec3(plates);
+    color += vec3(brushedMetal);
+    color -= vec3(scratches + dents * 0.4);
+  }
+  // BRASS ACCENTS (rivets, buckles, crosses, decorations) - golden
+  else if (isBrass) {
+    // Warm metallic brass
+    float brassNoise = noise(uv * 22.0) * 0.025;
+    float patina = noise(uv * 6.0) * 0.08; // Age darkening
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float brassSheen = pow(viewAngle, 8.5) * 0.32;
+    float brassFresnel = pow(1.0 - viewAngle, 2.6) * 0.18;
+
+    color += vec3(brassSheen + brassFresnel);
+    color += vec3(brassNoise);
+    color -= vec3(patina * 0.5); // Darker in recesses
+  }
+  // CHAINMAIL AVENTAIL (hanging neck protection) - grey steel rings
+  else if (avgColor > 0.40 && avgColor <= 0.60) {
+    // Interlocked ring texture
+    float rings = chainmailRings(v_worldPos.xz);
+
+    // Chainmail has less shine than plate
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float chainSheen = pow(viewAngle, 6.0) * 0.18;
+
+    // Individual ring highlights
+    float ringHighlights = noise(uv * 30.0) * 0.12;
+
+    color += vec3(rings + chainSheen + ringHighlights);
+    color *= 1.0 - noise(uv * 12.0) * 0.08; // Slight darkening between rings
+  }
+  // HERALDIC SURCOAT (team-colored tabard over armor) - bright cloth
+  else if (avgColor > 0.25) {
+    // Rich fabric weave texture
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+
+    // Embroidered cross emblem texture
+    float embroidery = noise(uv * 12.0) * 0.06;
+
+    // Fabric has soft sheen
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float fabricSheen = pow(1.0 - viewAngle, 6.0) * 0.08;
+
+    // Heraldic colors are vibrant
+    color *= 1.0 + noise(uv * 5.0) * 0.10 - 0.05;
+    color += vec3(weave + embroidery + fabricSheen);
+  }
+  // LEATHER/DARK ELEMENTS (straps, gloves, scabbard) - dark brown
+  else {
+    float leatherGrain = noise(uv * 10.0) * 0.15;
+    float wearMarks = noise(uv * 3.0) * 0.10;
+
+    color *= 1.0 + leatherGrain - 0.08 + wearMarks - 0.05;
+  }
+
+  float seaBlend = saturate((color.g + color.b) * 0.5 - color.r * 0.3) * 0.10;
+  color = mix(color, vec3(0.24, 0.58, 0.68), seaBlend);
+  color = clamp(color, 0.0, 1.0);
+
+  // Lighting model - hard shadows for metal, soft for fabric
+  vec3 lightDir = normalize(vec3(1.0, 1.2, 1.0));
+  float nDotL = dot(normal, lightDir);
+
+  // Metal = hard shadows, Fabric = soft wrap
+  float wrapAmount = (avgColor > 0.50) ? 0.08 : 0.30;
+  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.18);
+
+  // Extra contrast for polished steel
+  if (avgColor > 0.60 && !isBrass) {
+    diff = pow(diff, 0.85); // Sharper lighting falloff
+  }
+
+  color *= diff;
+  FragColor = vec4(color, u_alpha);
+}

+ 179 - 0
assets/shaders/knight_kingdom_of_iron.frag

@@ -0,0 +1,179 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+// Medieval plate armor articulation lines
+float armorPlates(vec2 p, float y) {
+  // Horizontal articulation lines (overlapping plates)
+  float plateY = fract(y * 6.5);
+  float plateLine = smoothstep(0.90, 0.98, plateY) * 0.12;
+
+  // Brass rivet decorations
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  float rivetPattern = rivet * step(0.92, plateY) * 0.25; // Brass is brighter
+
+  return plateLine + rivetPattern;
+}
+
+// Chainmail texture pattern
+float chainmailRings(vec2 p) {
+  vec2 grid = fract(p * 35.0) - 0.5;
+  float ring = length(grid);
+  float ringPattern =
+      smoothstep(0.35, 0.30, ring) - smoothstep(0.25, 0.20, ring);
+
+  // Offset every other row for interlinked appearance
+  vec2 offsetGrid = fract(p * 35.0 + vec2(0.5, 0.0)) - 0.5;
+  float offsetRing = length(offsetGrid);
+  float offsetPattern =
+      smoothstep(0.35, 0.30, offsetRing) - smoothstep(0.25, 0.20, offsetRing);
+
+  return (ringPattern + offsetPattern) * 0.15;
+}
+
+void main() {
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 normal = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+
+  // Detect material type by color tone
+  float colorHue =
+      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
+  bool isBrass =
+      (color.r > color.g * 1.08 && color.r > color.b * 1.15 && avgColor > 0.50);
+
+  // === MEDIEVAL KNIGHT MATERIALS ===
+
+  // POLISHED STEEL PLATE (Great Helm, cuirass, pauldrons, rerebraces) - bright
+  // silvery
+  if (avgColor > 0.60 && !isBrass) {
+    // Mirror-polished steel finish
+    float brushedMetal = abs(sin(v_worldPos.y * 90.0)) * 0.022;
+
+    // Battle wear: scratches and dents
+    float scratches = noise(uv * 32.0) * 0.020;
+    float dents = noise(uv * 7.0) * 0.024;
+
+    // Plate articulation lines and rivets
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // Strong specular reflections (polished metal)
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.55))));
+    float fresnel = pow(1.0 - viewAngle, 1.6) * 0.33;
+    float specular = pow(viewAngle, 11.0) * 0.58;
+
+    // Environmental reflections (sky dome)
+    float skyReflection = (normal.y * 0.5 + 0.5) * 0.15;
+
+    color += vec3(fresnel + skyReflection + specular * 1.8);
+    color += vec3(plates);
+    color += vec3(brushedMetal);
+    color -= vec3(scratches + dents * 0.4);
+  }
+  // BRASS ACCENTS (rivets, buckles, crosses, decorations) - golden
+  else if (isBrass) {
+    // Warm metallic brass
+    float brassNoise = noise(uv * 22.0) * 0.025;
+    float patina = noise(uv * 6.0) * 0.08; // Age darkening
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float brassSheen = pow(viewAngle, 7.5) * 0.32;
+    float brassFresnel = pow(1.0 - viewAngle, 2.2) * 0.22;
+
+    color += vec3(brassSheen + brassFresnel);
+    color += vec3(brassNoise);
+    color -= vec3(patina * 0.5); // Darker in recesses
+  }
+  // CHAINMAIL AVENTAIL (hanging neck protection) - grey steel rings
+  else if (avgColor > 0.40 && avgColor <= 0.60) {
+    // Interlocked ring texture
+    float rings = chainmailRings(v_worldPos.xz);
+
+    // Chainmail has less shine than plate
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float chainSheen = pow(viewAngle, 6.0) * 0.18;
+
+    // Individual ring highlights
+    float ringHighlights = noise(uv * 30.0) * 0.12;
+
+    color += vec3(rings + chainSheen + ringHighlights);
+    color *= 1.0 - noise(uv * 12.0) * 0.08; // Slight darkening between rings
+  }
+  // HERALDIC SURCOAT (team-colored tabard over armor) - bright cloth
+  else if (avgColor > 0.25) {
+    // Rich fabric weave texture
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+
+    // Embroidered cross emblem texture
+    float embroidery = noise(uv * 12.0) * 0.06;
+
+    // Fabric has soft sheen
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float fabricSheen = pow(1.0 - viewAngle, 6.0) * 0.08;
+
+    // Heraldic colors are vibrant
+    color *= 1.0 + noise(uv * 5.0) * 0.10 - 0.05;
+    color += vec3(weave + embroidery + fabricSheen);
+  }
+  // LEATHER/DARK ELEMENTS (straps, gloves, scabbard) - dark brown
+  else {
+    float leatherGrain = noise(uv * 10.0) * 0.15;
+    float wearMarks = noise(uv * 3.0) * 0.10;
+
+    color *= 1.0 + leatherGrain - 0.08 + wearMarks - 0.05;
+  }
+
+  float crestBlend = pow(1.0 - abs(normal.y), 2.0) * 0.12;
+  color = mix(color, vec3(0.58, 0.62, 0.80), crestBlend);
+  color = clamp(color, 0.0, 1.0);
+
+  // Lighting model - hard shadows for metal, soft for fabric
+  vec3 lightDir = normalize(vec3(1.0, 1.2, 1.0));
+  float nDotL = dot(normal, lightDir);
+
+  // Metal = hard shadows, Fabric = soft wrap
+  float wrapAmount = (avgColor > 0.50) ? 0.08 : 0.30;
+  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.18);
+
+  // Extra contrast for polished steel
+  if (avgColor > 0.60 && !isBrass) {
+    diff = pow(diff, 0.85); // Sharper lighting falloff
+  }
+
+  color *= diff;
+  FragColor = vec4(color, u_alpha);
+}

+ 177 - 0
assets/shaders/knight_roman_republic.frag

@@ -0,0 +1,177 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+// Medieval plate armor articulation lines
+float armorPlates(vec2 p, float y) {
+  // Horizontal articulation lines (overlapping plates)
+  float plateY = fract(y * 6.5);
+  float plateLine = smoothstep(0.90, 0.98, plateY) * 0.12;
+
+  // Brass rivet decorations
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  float rivetPattern = rivet * step(0.92, plateY) * 0.25; // Brass is brighter
+
+  return plateLine + rivetPattern;
+}
+
+// Chainmail texture pattern
+float chainmailRings(vec2 p) {
+  vec2 grid = fract(p * 35.0) - 0.5;
+  float ring = length(grid);
+  float ringPattern =
+      smoothstep(0.35, 0.30, ring) - smoothstep(0.25, 0.20, ring);
+
+  // Offset every other row for interlinked appearance
+  vec2 offsetGrid = fract(p * 35.0 + vec2(0.5, 0.0)) - 0.5;
+  float offsetRing = length(offsetGrid);
+  float offsetPattern =
+      smoothstep(0.35, 0.30, offsetRing) - smoothstep(0.25, 0.20, offsetRing);
+
+  return (ringPattern + offsetPattern) * 0.15;
+}
+
+void main() {
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 normal = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+
+  // Detect material type by color tone
+  float colorHue =
+      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
+  bool isBrass =
+      (color.r > color.g * 1.15 && color.r > color.b * 1.2 && avgColor > 0.55);
+
+  // === MEDIEVAL KNIGHT MATERIALS ===
+
+  // POLISHED STEEL PLATE (Great Helm, cuirass, pauldrons, rerebraces) - bright
+  // silvery
+  if (avgColor > 0.60 && !isBrass) {
+    // Mirror-polished steel finish
+    float brushedMetal = abs(sin(v_worldPos.y * 95.0)) * 0.02;
+
+    // Battle wear: scratches and dents
+    float scratches = noise(uv * 35.0) * 0.018;
+    float dents = noise(uv * 8.0) * 0.025;
+
+    // Plate articulation lines and rivets
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // Strong specular reflections (polished metal)
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float fresnel = pow(1.0 - viewAngle, 1.8) * 0.35; // Bright rim lighting
+    float specular = pow(viewAngle, 12.0) * 0.55;     // Sharp mirror highlights
+
+    // Environmental reflections (sky dome)
+    float skyReflection = (normal.y * 0.5 + 0.5) * 0.12;
+
+    color += vec3(fresnel + skyReflection + specular * 1.8);
+    color += vec3(plates);
+    color += vec3(brushedMetal);
+    color -= vec3(scratches + dents * 0.4);
+  }
+  // BRASS ACCENTS (rivets, buckles, crosses, decorations) - golden
+  else if (isBrass) {
+    // Warm metallic brass
+    float brassNoise = noise(uv * 22.0) * 0.025;
+    float patina = noise(uv * 6.0) * 0.08; // Age darkening
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float brassSheen = pow(viewAngle, 8.0) * 0.35;
+    float brassFresnel = pow(1.0 - viewAngle, 2.5) * 0.20;
+
+    color += vec3(brassSheen + brassFresnel);
+    color += vec3(brassNoise);
+    color -= vec3(patina * 0.5); // Darker in recesses
+  }
+  // CHAINMAIL AVENTAIL (hanging neck protection) - grey steel rings
+  else if (avgColor > 0.40 && avgColor <= 0.60) {
+    // Interlocked ring texture
+    float rings = chainmailRings(v_worldPos.xz);
+
+    // Chainmail has less shine than plate
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float chainSheen = pow(viewAngle, 6.0) * 0.18;
+
+    // Individual ring highlights
+    float ringHighlights = noise(uv * 30.0) * 0.12;
+
+    color += vec3(rings + chainSheen + ringHighlights);
+    color *= 1.0 - noise(uv * 12.0) * 0.08; // Slight darkening between rings
+  }
+  // HERALDIC SURCOAT (team-colored tabard over armor) - bright cloth
+  else if (avgColor > 0.25) {
+    // Rich fabric weave texture
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+
+    // Embroidered cross emblem texture
+    float embroidery = noise(uv * 12.0) * 0.06;
+
+    // Fabric has soft sheen
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float fabricSheen = pow(1.0 - viewAngle, 6.0) * 0.08;
+
+    // Heraldic colors are vibrant
+    color *= 1.0 + noise(uv * 5.0) * 0.10 - 0.05;
+    color += vec3(weave + embroidery + fabricSheen);
+  }
+  // LEATHER/DARK ELEMENTS (straps, gloves, scabbard) - dark brown
+  else {
+    float leatherGrain = noise(uv * 10.0) * 0.15;
+    float wearMarks = noise(uv * 3.0) * 0.10;
+
+    color *= 1.0 + leatherGrain - 0.08 + wearMarks - 0.05;
+  }
+
+  color = clamp(color, 0.0, 1.0);
+
+  // Lighting model - hard shadows for metal, soft for fabric
+  vec3 lightDir = normalize(vec3(1.0, 1.2, 1.0));
+  float nDotL = dot(normal, lightDir);
+
+  // Metal = hard shadows, Fabric = soft wrap
+  float wrapAmount = (avgColor > 0.50) ? 0.08 : 0.30;
+  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.18);
+
+  // Extra contrast for polished steel
+  if (avgColor > 0.60 && !isBrass) {
+    diff = pow(diff, 0.85); // Sharper lighting falloff
+  }
+
+  color *= diff;
+  FragColor = vec4(color, u_alpha);
+}

+ 353 - 0
assets/shaders/mounted_knight_carthage.frag

@@ -0,0 +1,353 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// ---------------------
+// utilities & noise
+// ---------------------
+const float PI = 3.14159265359;
+
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 x) { return clamp(x, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+  float a = 0.5;
+  float f = 0.0;
+  for (int i = 0; i < 5; ++i) {
+    f += a * noise(p);
+    p *= 2.03;
+    a *= 0.5;
+  }
+  return f;
+}
+
+// anti-aliased step
+float aaStep(float edge, float x) {
+  float w = fwidth(x);
+  return smoothstep(edge - w, edge + w, x);
+}
+
+// ---------------------
+// patterns
+// ---------------------
+
+// plate seams + rivets (AA)
+float armorPlates(vec2 p, float y) {
+  float plateY = fract(y * 6.5);
+  float line = smoothstep(0.92, 0.98, plateY) - smoothstep(0.98, 1.0, plateY);
+  // anti-aliased line thickness
+  line = smoothstep(0.0, fwidth(plateY) * 2.0, line) * 0.12;
+
+  // rivets on top seams
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  rivet *= step(0.92, plateY);
+  return line + rivet * 0.25;
+}
+
+// linked ring suggestion (AA)
+float chainmailRings(vec2 p) {
+  vec2 uv = p * 35.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
+float horseHidePattern(vec2 p) {
+  float grain = fbm(p * 80.0) * 0.10;
+  float ripple = sin(p.x * 22.0) * cos(p.y * 28.0) * 0.035;
+  float mottling = smoothstep(0.55, 0.65, fbm(p * 6.0)) * 0.07;
+  return grain + ripple + mottling;
+}
+
+// ---------------------
+// microfacet shading
+// ---------------------
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+float D_GGX(float NdotH, float rough) {
+  float a = max(0.001, rough);
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(1e-6, (PI * d * d));
+}
+
+float G_Smith(float NdotV, float NdotL, float rough) {
+  float r = rough + 1.0;
+  float k = (r * r) / 8.0;
+  float gV = NdotV / (NdotV * (1.0 - k) + k);
+  float gL = NdotL / (NdotL * (1.0 - k) + k);
+  return gV * gL;
+}
+
+// screen-space bump from a height field h(uv) in world XZ
+vec3 perturbNormalWS(vec3 N, vec3 worldPos, float h, float scale) {
+  vec3 dpdx = dFdx(worldPos);
+  vec3 dpdy = dFdy(worldPos);
+  vec3 T = normalize(dpdx);
+  vec3 B = normalize(cross(N, T));
+  float hx = dFdx(h);
+  float hy = dFdy(h);
+  vec3 Np = normalize(N - scale * (hx * B + hy * T));
+  return Np;
+}
+
+// hemisphere ambient (sky/ground)
+vec3 hemilight(vec3 N) {
+  vec3 sky = vec3(0.46, 0.70, 0.82);
+  vec3 ground = vec3(0.22, 0.18, 0.14);
+  float t = saturate(N.y * 0.5 + 0.5);
+  return mix(ground, sky, t) * 0.29;
+}
+
+// ---------------------
+// main
+// ---------------------
+void main() {
+  vec3 baseColor = u_color;
+  if (u_useTexture)
+    baseColor *= texture(u_texture, v_texCoord).rgb;
+
+  vec3 N = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+
+  float avg = (baseColor.r + baseColor.g + baseColor.b) * (1.0 / 3.0);
+  float hueSpan = max(max(baseColor.r, baseColor.g), baseColor.b) -
+                  min(min(baseColor.r, baseColor.g), baseColor.b);
+
+  bool isBrass = (baseColor.r > baseColor.g * 1.15 &&
+                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isSteel = (avg > 0.60 && !isBrass);
+  bool isChain = (!isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
+  bool isFabric = (!isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = (!isSteel && !isBrass && !isChain && !isFabric);
+  bool isHorseHide = (avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+
+  // lighting frame
+  vec3 L = normalize(vec3(1.0, 1.2, 1.0));
+  vec3 V = normalize(
+      vec3(0.0, 1.0, 0.5)); // stable view proxy (keeps interface unchanged)
+  vec3 H = normalize(L + V);
+
+  float NdotL = saturate(dot(N, L));
+  float NdotV = saturate(dot(N, V));
+  float NdotH = saturate(dot(N, H));
+  float VdotH = saturate(dot(V, H));
+
+  // wrap diffuse like original (softens lambert)
+  float wrapAmount = (avg > 0.50) ? 0.08 : 0.30;
+  float NdotL_wrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.12);
+
+  // base material params
+  float roughness = 0.5;
+  vec3 F0 = vec3(0.04); // dielectric default
+  float metalness = 0.0;
+  vec3 albedo = baseColor;
+
+  // micro details / masks (re-used)
+  float nSmall = fbm(uv * 6.0);
+  float nLarge = fbm(uv * 2.0);
+  float cavity = 1.0 - (nLarge * 0.25 + nSmall * 0.15);
+
+  // ---------------------
+  // MATERIAL BRANCHES
+  // ---------------------
+  vec3 col = vec3(0.0);
+  vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
+
+  if (isHorseHide) {
+    // subtle anisotropic sheen along body flow
+    vec3 up = vec3(0.0, 1.0, 0.0);
+    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
+    float flowNoise = fbm(uv * 10.0);
+    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
+                  0.08 * (0.6 + 0.4 * flowNoise);
+
+    float hideTex = horseHidePattern(v_worldPos.xz);
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+
+    roughness = 0.58 - hideTex * 0.08;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    // slight bump from hair grain
+    float h = fbm(v_worldPos.xz * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // composition
+    albedo = albedo * (1.0 + hideTex * 0.20) * (0.98 + 0.02 * nSmall);
+    col += ambient * albedo;
+    // microfacet spec
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
+    col += aniso + sheen;
+
+  } else if (isSteel) {
+    float brushed =
+        abs(sin(v_worldPos.y * 95.0)) * 0.02 + noise(uv * 35.0) * 0.015;
+    float dents = noise(uv * 8.0) * 0.03;
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // bump from brushing
+    float h = fbm(vec2(v_worldPos.y * 25.0, v_worldPos.z * 6.0));
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // steel-like params
+    metalness = 1.0;
+    F0 = vec3(0.92);
+    roughness = 0.28 + brushed * 2.0 + dents * 0.6;
+    roughness = clamp(roughness, 0.15, 0.55);
+
+    // base tint & sky reflection lift
+    albedo = mix(vec3(0.60), baseColor, 0.25);
+    float skyRefl = (N.y * 0.5 + 0.5) * 0.10;
+
+    // microfacet spec only for metals
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0 * albedo); // slight tint
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.3; // metals rely more on spec
+    col += NdotL_wrap * spec * 1.5;
+    col += vec3(plates) + vec3(skyRefl) - vec3(dents * 0.25) + vec3(brushed);
+
+  } else if (isBrass) {
+    float brassNoise = noise(uv * 22.0) * 0.02;
+    float patina = fbm(uv * 4.0) * 0.12; // larger-scale patina
+
+    // bump from subtle hammering
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.30);
+
+    metalness = 1.0;
+    vec3 brassTint = vec3(0.94, 0.78, 0.45);
+    F0 = mix(brassTint, baseColor, 0.5);
+    roughness = clamp(0.32 + patina * 0.45, 0.18, 0.75);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * spec * 1.35;
+    col += vec3(brassNoise) - vec3(patina * 0.35);
+
+  } else if (isChain) {
+    float rings = chainmailRings(v_worldPos.xz);
+    float ringHi = noise(uv * 30.0) * 0.10;
+
+    // small pitted bump
+    float h = fbm(uv * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.25);
+
+    metalness = 1.0;
+    F0 = vec3(0.86);
+    roughness = 0.35;
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * (spec * (1.2 + rings)) + vec3(ringHi);
+    // slight diffuse damping to keep chainmail darker in cavities
+    col *= (0.95 - 0.10 * (1.0 - cavity));
+
+  } else if (isFabric) {
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+    float embroidery = fbm(uv * 6.0) * 0.08;
+
+    float h = fbm(uv * 22.0) * 0.7 + weave * 0.6;
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    roughness = 0.78;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 6.0) * 0.10;
+    albedo *= 1.0 + fbm(uv * 5.0) * 0.10 - 0.05;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.3) +
+           vec3(weave + embroidery + sheen);
+
+  } else { // leather
+    float grain = fbm(uv * 10.0) * 0.15;
+    float wear = fbm(uv * 3.0) * 0.12;
+
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.28);
+
+    roughness = 0.58 - wear * 0.15;
+    F0 = vec3(0.038);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.06;
+
+    albedo *= (1.0 + grain - 0.06 + wear * 0.05);
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F)) + spec * 0.4 + vec3(sheen);
+  }
+
+  col = mix(col, vec3(0.32, 0.60, 0.66),
+            saturate((baseColor.g + baseColor.b) * 0.4) * 0.12);
+  col = saturate(col);
+  FragColor = vec4(col, u_alpha);
+}

+ 353 - 0
assets/shaders/mounted_knight_kingdom_of_iron.frag

@@ -0,0 +1,353 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// ---------------------
+// utilities & noise
+// ---------------------
+const float PI = 3.14159265359;
+
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 x) { return clamp(x, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+  float a = 0.5;
+  float f = 0.0;
+  for (int i = 0; i < 5; ++i) {
+    f += a * noise(p);
+    p *= 2.03;
+    a *= 0.5;
+  }
+  return f;
+}
+
+// anti-aliased step
+float aaStep(float edge, float x) {
+  float w = fwidth(x);
+  return smoothstep(edge - w, edge + w, x);
+}
+
+// ---------------------
+// patterns
+// ---------------------
+
+// plate seams + rivets (AA)
+float armorPlates(vec2 p, float y) {
+  float plateY = fract(y * 6.5);
+  float line = smoothstep(0.92, 0.98, plateY) - smoothstep(0.98, 1.0, plateY);
+  // anti-aliased line thickness
+  line = smoothstep(0.0, fwidth(plateY) * 2.0, line) * 0.12;
+
+  // rivets on top seams
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  rivet *= step(0.92, plateY);
+  return line + rivet * 0.25;
+}
+
+// linked ring suggestion (AA)
+float chainmailRings(vec2 p) {
+  vec2 uv = p * 35.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
+float horseHidePattern(vec2 p) {
+  float grain = fbm(p * 80.0) * 0.10;
+  float ripple = sin(p.x * 22.0) * cos(p.y * 28.0) * 0.035;
+  float mottling = smoothstep(0.55, 0.65, fbm(p * 6.0)) * 0.07;
+  return grain + ripple + mottling;
+}
+
+// ---------------------
+// microfacet shading
+// ---------------------
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+float D_GGX(float NdotH, float rough) {
+  float a = max(0.001, rough);
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(1e-6, (PI * d * d));
+}
+
+float G_Smith(float NdotV, float NdotL, float rough) {
+  float r = rough + 1.0;
+  float k = (r * r) / 8.0;
+  float gV = NdotV / (NdotV * (1.0 - k) + k);
+  float gL = NdotL / (NdotL * (1.0 - k) + k);
+  return gV * gL;
+}
+
+// screen-space bump from a height field h(uv) in world XZ
+vec3 perturbNormalWS(vec3 N, vec3 worldPos, float h, float scale) {
+  vec3 dpdx = dFdx(worldPos);
+  vec3 dpdy = dFdy(worldPos);
+  vec3 T = normalize(dpdx);
+  vec3 B = normalize(cross(N, T));
+  float hx = dFdx(h);
+  float hy = dFdy(h);
+  vec3 Np = normalize(N - scale * (hx * B + hy * T));
+  return Np;
+}
+
+// hemisphere ambient (sky/ground)
+vec3 hemilight(vec3 N) {
+  vec3 sky = vec3(0.54, 0.66, 0.88);
+  vec3 ground = vec3(0.19, 0.18, 0.20);
+  float t = saturate(N.y * 0.5 + 0.5);
+  return mix(ground, sky, t) * 0.30;
+}
+
+// ---------------------
+// main
+// ---------------------
+void main() {
+  vec3 baseColor = u_color;
+  if (u_useTexture)
+    baseColor *= texture(u_texture, v_texCoord).rgb;
+
+  vec3 N = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+
+  float avg = (baseColor.r + baseColor.g + baseColor.b) * (1.0 / 3.0);
+  float hueSpan = max(max(baseColor.r, baseColor.g), baseColor.b) -
+                  min(min(baseColor.r, baseColor.g), baseColor.b);
+
+  bool isBrass = (baseColor.r > baseColor.g * 1.15 &&
+                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isSteel = (avg > 0.60 && !isBrass);
+  bool isChain = (!isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
+  bool isFabric = (!isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = (!isSteel && !isBrass && !isChain && !isFabric);
+  bool isHorseHide = (avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+
+  // lighting frame
+  vec3 L = normalize(vec3(1.0, 1.2, 1.0));
+  vec3 V = normalize(
+      vec3(0.0, 1.0, 0.5)); // stable view proxy (keeps interface unchanged)
+  vec3 H = normalize(L + V);
+
+  float NdotL = saturate(dot(N, L));
+  float NdotV = saturate(dot(N, V));
+  float NdotH = saturate(dot(N, H));
+  float VdotH = saturate(dot(V, H));
+
+  // wrap diffuse like original (softens lambert)
+  float wrapAmount = (avg > 0.50) ? 0.08 : 0.30;
+  float NdotL_wrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.12);
+
+  // base material params
+  float roughness = 0.5;
+  vec3 F0 = vec3(0.04); // dielectric default
+  float metalness = 0.0;
+  vec3 albedo = baseColor;
+
+  // micro details / masks (re-used)
+  float nSmall = fbm(uv * 6.0);
+  float nLarge = fbm(uv * 2.0);
+  float cavity = 1.0 - (nLarge * 0.25 + nSmall * 0.15);
+
+  // ---------------------
+  // MATERIAL BRANCHES
+  // ---------------------
+  vec3 col = vec3(0.0);
+  vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
+
+  if (isHorseHide) {
+    // subtle anisotropic sheen along body flow
+    vec3 up = vec3(0.0, 1.0, 0.0);
+    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
+    float flowNoise = fbm(uv * 10.0);
+    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
+                  0.08 * (0.6 + 0.4 * flowNoise);
+
+    float hideTex = horseHidePattern(v_worldPos.xz);
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+
+    roughness = 0.58 - hideTex * 0.08;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    // slight bump from hair grain
+    float h = fbm(v_worldPos.xz * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // composition
+    albedo = albedo * (1.0 + hideTex * 0.20) * (0.98 + 0.02 * nSmall);
+    col += ambient * albedo;
+    // microfacet spec
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
+    col += aniso + sheen;
+
+  } else if (isSteel) {
+    float brushed =
+        abs(sin(v_worldPos.y * 95.0)) * 0.02 + noise(uv * 35.0) * 0.015;
+    float dents = noise(uv * 8.0) * 0.03;
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // bump from brushing
+    float h = fbm(vec2(v_worldPos.y * 25.0, v_worldPos.z * 6.0));
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // steel-like params
+    metalness = 1.0;
+    F0 = vec3(0.92);
+    roughness = 0.28 + brushed * 2.0 + dents * 0.6;
+    roughness = clamp(roughness, 0.15, 0.55);
+
+    // base tint & sky reflection lift
+    albedo = mix(vec3(0.60), baseColor, 0.25);
+    float skyRefl = (N.y * 0.5 + 0.5) * 0.10;
+
+    // microfacet spec only for metals
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0 * albedo); // slight tint
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.3; // metals rely more on spec
+    col += NdotL_wrap * spec * 1.5;
+    col += vec3(plates) + vec3(skyRefl) - vec3(dents * 0.25) + vec3(brushed);
+
+  } else if (isBrass) {
+    float brassNoise = noise(uv * 22.0) * 0.02;
+    float patina = fbm(uv * 4.0) * 0.12; // larger-scale patina
+
+    // bump from subtle hammering
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.30);
+
+    metalness = 1.0;
+    vec3 brassTint = vec3(0.94, 0.78, 0.45);
+    F0 = mix(brassTint, baseColor, 0.5);
+    roughness = clamp(0.32 + patina * 0.45, 0.18, 0.75);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * spec * 1.35;
+    col += vec3(brassNoise) - vec3(patina * 0.35);
+
+  } else if (isChain) {
+    float rings = chainmailRings(v_worldPos.xz);
+    float ringHi = noise(uv * 30.0) * 0.10;
+
+    // small pitted bump
+    float h = fbm(uv * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.25);
+
+    metalness = 1.0;
+    F0 = vec3(0.86);
+    roughness = 0.35;
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * (spec * (1.2 + rings)) + vec3(ringHi);
+    // slight diffuse damping to keep chainmail darker in cavities
+    col *= (0.95 - 0.10 * (1.0 - cavity));
+
+  } else if (isFabric) {
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+    float embroidery = fbm(uv * 6.0) * 0.08;
+
+    float h = fbm(uv * 22.0) * 0.7 + weave * 0.6;
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    roughness = 0.78;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 6.0) * 0.10;
+    albedo *= 1.0 + fbm(uv * 5.0) * 0.10 - 0.05;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.3) +
+           vec3(weave + embroidery + sheen);
+
+  } else { // leather
+    float grain = fbm(uv * 10.0) * 0.15;
+    float wear = fbm(uv * 3.0) * 0.12;
+
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.28);
+
+    roughness = 0.58 - wear * 0.15;
+    F0 = vec3(0.038);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.06;
+
+    albedo *= (1.0 + grain - 0.06 + wear * 0.05);
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F)) + spec * 0.4 + vec3(sheen);
+  }
+
+  // subtle kingdom highlight along crest
+  col = mix(col, vec3(0.58, 0.64, 0.82), pow(1.0 - abs(N.y), 2.0) * 0.10);
+  col = saturate(col);
+  FragColor = vec4(col, u_alpha);
+}

+ 352 - 0
assets/shaders/mounted_knight_roman_republic.frag

@@ -0,0 +1,352 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// ---------------------
+// utilities & noise
+// ---------------------
+const float PI = 3.14159265359;
+
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 x) { return clamp(x, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+  float a = 0.5;
+  float f = 0.0;
+  for (int i = 0; i < 5; ++i) {
+    f += a * noise(p);
+    p *= 2.03;
+    a *= 0.5;
+  }
+  return f;
+}
+
+// anti-aliased step
+float aaStep(float edge, float x) {
+  float w = fwidth(x);
+  return smoothstep(edge - w, edge + w, x);
+}
+
+// ---------------------
+// patterns
+// ---------------------
+
+// plate seams + rivets (AA)
+float armorPlates(vec2 p, float y) {
+  float plateY = fract(y * 6.5);
+  float line = smoothstep(0.92, 0.98, plateY) - smoothstep(0.98, 1.0, plateY);
+  // anti-aliased line thickness
+  line = smoothstep(0.0, fwidth(plateY) * 2.0, line) * 0.12;
+
+  // rivets on top seams
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  rivet *= step(0.92, plateY);
+  return line + rivet * 0.25;
+}
+
+// linked ring suggestion (AA)
+float chainmailRings(vec2 p) {
+  vec2 uv = p * 35.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
+float horseHidePattern(vec2 p) {
+  float grain = fbm(p * 80.0) * 0.10;
+  float ripple = sin(p.x * 22.0) * cos(p.y * 28.0) * 0.035;
+  float mottling = smoothstep(0.55, 0.65, fbm(p * 6.0)) * 0.07;
+  return grain + ripple + mottling;
+}
+
+// ---------------------
+// microfacet shading
+// ---------------------
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+float D_GGX(float NdotH, float rough) {
+  float a = max(0.001, rough);
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(1e-6, (PI * d * d));
+}
+
+float G_Smith(float NdotV, float NdotL, float rough) {
+  float r = rough + 1.0;
+  float k = (r * r) / 8.0;
+  float gV = NdotV / (NdotV * (1.0 - k) + k);
+  float gL = NdotL / (NdotL * (1.0 - k) + k);
+  return gV * gL;
+}
+
+// screen-space bump from a height field h(uv) in world XZ
+vec3 perturbNormalWS(vec3 N, vec3 worldPos, float h, float scale) {
+  vec3 dpdx = dFdx(worldPos);
+  vec3 dpdy = dFdy(worldPos);
+  vec3 T = normalize(dpdx);
+  vec3 B = normalize(cross(N, T));
+  float hx = dFdx(h);
+  float hy = dFdy(h);
+  vec3 Np = normalize(N - scale * (hx * B + hy * T));
+  return Np;
+}
+
+// hemisphere ambient (sky/ground)
+vec3 hemilight(vec3 N) {
+  vec3 sky = vec3(0.55, 0.64, 0.80);
+  vec3 ground = vec3(0.23, 0.20, 0.17);
+  float t = saturate(N.y * 0.5 + 0.5);
+  return mix(ground, sky, t) * 0.28;
+}
+
+// ---------------------
+// main
+// ---------------------
+void main() {
+  vec3 baseColor = u_color;
+  if (u_useTexture)
+    baseColor *= texture(u_texture, v_texCoord).rgb;
+
+  vec3 N = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+
+  float avg = (baseColor.r + baseColor.g + baseColor.b) * (1.0 / 3.0);
+  float hueSpan = max(max(baseColor.r, baseColor.g), baseColor.b) -
+                  min(min(baseColor.r, baseColor.g), baseColor.b);
+
+  bool isBrass = (baseColor.r > baseColor.g * 1.15 &&
+                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isSteel = (avg > 0.60 && !isBrass);
+  bool isChain = (!isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
+  bool isFabric = (!isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = (!isSteel && !isBrass && !isChain && !isFabric);
+  bool isHorseHide = (avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+
+  // lighting frame
+  vec3 L = normalize(vec3(1.0, 1.2, 1.0));
+  vec3 V = normalize(
+      vec3(0.0, 1.0, 0.5)); // stable view proxy (keeps interface unchanged)
+  vec3 H = normalize(L + V);
+
+  float NdotL = saturate(dot(N, L));
+  float NdotV = saturate(dot(N, V));
+  float NdotH = saturate(dot(N, H));
+  float VdotH = saturate(dot(V, H));
+
+  // wrap diffuse like original (softens lambert)
+  float wrapAmount = (avg > 0.50) ? 0.08 : 0.30;
+  float NdotL_wrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.12);
+
+  // base material params
+  float roughness = 0.5;
+  vec3 F0 = vec3(0.04); // dielectric default
+  float metalness = 0.0;
+  vec3 albedo = baseColor;
+
+  // micro details / masks (re-used)
+  float nSmall = fbm(uv * 6.0);
+  float nLarge = fbm(uv * 2.0);
+  float cavity = 1.0 - (nLarge * 0.25 + nSmall * 0.15);
+
+  // ---------------------
+  // MATERIAL BRANCHES
+  // ---------------------
+  vec3 col = vec3(0.0);
+  vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
+
+  if (isHorseHide) {
+    // subtle anisotropic sheen along body flow
+    vec3 up = vec3(0.0, 1.0, 0.0);
+    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
+    float flowNoise = fbm(uv * 10.0);
+    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
+                  0.08 * (0.6 + 0.4 * flowNoise);
+
+    float hideTex = horseHidePattern(v_worldPos.xz);
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+
+    roughness = 0.58 - hideTex * 0.08;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    // slight bump from hair grain
+    float h = fbm(v_worldPos.xz * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // composition
+    albedo = albedo * (1.0 + hideTex * 0.20) * (0.98 + 0.02 * nSmall);
+    col += ambient * albedo;
+    // microfacet spec
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
+    col += aniso + sheen;
+
+  } else if (isSteel) {
+    float brushed =
+        abs(sin(v_worldPos.y * 95.0)) * 0.02 + noise(uv * 35.0) * 0.015;
+    float dents = noise(uv * 8.0) * 0.03;
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // bump from brushing
+    float h = fbm(vec2(v_worldPos.y * 25.0, v_worldPos.z * 6.0));
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // steel-like params
+    metalness = 1.0;
+    F0 = vec3(0.92);
+    roughness = 0.28 + brushed * 2.0 + dents * 0.6;
+    roughness = clamp(roughness, 0.15, 0.55);
+
+    // base tint & sky reflection lift
+    albedo = mix(vec3(0.60), baseColor, 0.25);
+    float skyRefl = (N.y * 0.5 + 0.5) * 0.10;
+
+    // microfacet spec only for metals
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0 * albedo); // slight tint
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.3; // metals rely more on spec
+    col += NdotL_wrap * spec * 1.5;
+    col += vec3(plates) + vec3(skyRefl) - vec3(dents * 0.25) + vec3(brushed);
+
+  } else if (isBrass) {
+    float brassNoise = noise(uv * 22.0) * 0.02;
+    float patina = fbm(uv * 4.0) * 0.12; // larger-scale patina
+
+    // bump from subtle hammering
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.30);
+
+    metalness = 1.0;
+    vec3 brassTint = vec3(0.94, 0.78, 0.45);
+    F0 = mix(brassTint, baseColor, 0.5);
+    roughness = clamp(0.32 + patina * 0.45, 0.18, 0.75);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * spec * 1.35;
+    col += vec3(brassNoise) - vec3(patina * 0.35);
+
+  } else if (isChain) {
+    float rings = chainmailRings(v_worldPos.xz);
+    float ringHi = noise(uv * 30.0) * 0.10;
+
+    // small pitted bump
+    float h = fbm(uv * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.25);
+
+    metalness = 1.0;
+    F0 = vec3(0.86);
+    roughness = 0.35;
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * (spec * (1.2 + rings)) + vec3(ringHi);
+    // slight diffuse damping to keep chainmail darker in cavities
+    col *= (0.95 - 0.10 * (1.0 - cavity));
+
+  } else if (isFabric) {
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+    float embroidery = fbm(uv * 6.0) * 0.08;
+
+    float h = fbm(uv * 22.0) * 0.7 + weave * 0.6;
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    roughness = 0.78;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 6.0) * 0.10;
+    albedo *= 1.0 + fbm(uv * 5.0) * 0.10 - 0.05;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.3) +
+           vec3(weave + embroidery + sheen);
+
+  } else { // leather
+    float grain = fbm(uv * 10.0) * 0.15;
+    float wear = fbm(uv * 3.0) * 0.12;
+
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.28);
+
+    roughness = 0.58 - wear * 0.15;
+    F0 = vec3(0.038);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.06;
+
+    albedo *= (1.0 + grain - 0.06 + wear * 0.05);
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F)) + spec * 0.4 + vec3(sheen);
+  }
+
+  // final clamp and alpha
+  col = saturate(col);
+  FragColor = vec4(col, u_alpha);
+}

+ 326 - 0
assets/shaders/spearman_carthage.frag

@@ -0,0 +1,326 @@
+#version 330 core
+
+// === Inputs preserved (do not change) ===
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// === Utility ===
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 v) { return clamp(v, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float leatherGrain(vec2 p) {
+  float grain = noise(p * 10.0) * 0.16;
+  float pores = noise(p * 22.0) * 0.08;
+  return grain + pores;
+}
+
+// Fixed bug: use 2D input (was referencing p.z).
+float fabricWeave(vec2 p) {
+  float weaveU = sin(p.x * 60.0);
+  float weaveV = sin(p.y * 60.0);
+  return weaveU * weaveV * 0.05;
+}
+
+// Hemispheric ambient (simple IBL feel without extra uniforms)
+vec3 hemiAmbient(vec3 n) {
+  float up = saturate(n.y * 0.5 + 0.5);
+  vec3 sky = vec3(0.46, 0.66, 0.78) * 0.36;
+  vec3 ground = vec3(0.18, 0.16, 0.14) * 0.28;
+  return mix(ground, sky, up);
+}
+
+// Schlick Fresnel
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - saturate(cosTheta), 5.0);
+}
+
+// GGX / Trowbridge-Reitz
+float distributionGGX(float NdotH, float a) {
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(3.14159265 * d * d, 1e-6);
+}
+
+// Smith's Schlick-G for GGX
+float geometrySchlickGGX(float NdotX, float k) {
+  return NdotX / max(NdotX * (1.0 - k) + k, 1e-6);
+}
+float geometrySmith(float NdotV, float NdotL, float roughness) {
+  float r = roughness + 1.0;
+  float k = (r * r) / 8.0; // Schlick approximation
+  float ggx1 = geometrySchlickGGX(NdotV, k);
+  float ggx2 = geometrySchlickGGX(NdotL, k);
+  return ggx1 * ggx2;
+}
+
+// Screen-space curvature (edge detector) from normal derivatives
+float edgeWearMask(vec3 n) {
+  vec3 nx = dFdx(n);
+  vec3 ny = dFdy(n);
+  float curvature = length(nx) + length(ny);
+  return saturate(smoothstep(0.10, 0.70, curvature));
+}
+
+// Build an approximate TBN from derivatives (no new inputs needed)
+void buildTBN(out vec3 T, out vec3 B, out vec3 N, vec3 n, vec3 pos, vec2 uv) {
+  vec3 dp1 = dFdx(pos);
+  vec3 dp2 = dFdy(pos);
+  vec2 duv1 = dFdx(uv);
+  vec2 duv2 = dFdy(uv);
+
+  float det = duv1.x * duv2.y - duv1.y * duv2.x;
+  vec3 t = (dp1 * duv2.y - dp2 * duv1.y) * (det == 0.0 ? 1.0 : sign(det));
+  T = normalize(t - n * dot(n, t));
+  B = normalize(cross(n, T));
+  N = normalize(n);
+}
+
+// Cheap bump from a procedural height map in UV space
+vec3 perturbNormalFromHeight(vec3 n, vec3 pos, vec2 uv, float height,
+                             float scale, float strength) {
+  vec3 T, B, N;
+  buildTBN(T, B, N, n, pos, uv);
+
+  // Finite-difference heights in UV for gradient
+  float h0 = height;
+  float hx = noise((uv + vec2(0.002, 0.0)) * scale) - h0;
+  float hy = noise((uv + vec2(0.0, 0.002)) * scale) - h0;
+
+  vec3 bump = normalize(N + (T * hx + B * hy) * strength);
+  return bump;
+}
+
+void main() {
+  // Base color
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  // Inputs & coordinate prep
+  vec3 N = normalize(v_normal);
+  vec2 uvW = v_worldPos.xz * 4.5;
+  vec2 uv = v_texCoord * 4.5;
+
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+  float colorHue =
+      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
+
+  // Material classification preserved
+  bool isMetal = (avgColor > 0.45 && avgColor <= 0.65 && colorHue < 0.15);
+  bool isLeather = (avgColor > 0.30 && avgColor <= 0.50 && colorHue < 0.20);
+  bool isFabric = (avgColor > 0.25 && !isMetal && !isLeather);
+
+  // Lighting basis (kept compatible with prior shader)
+  vec3 L = normalize(vec3(1.0, 1.15, 1.0));
+  // Approximate view vector from world origin; nudged to avoid degenerate
+  // normalization
+  vec3 V = normalize(-v_worldPos + N * 0.001);
+  vec3 H = normalize(L + V);
+
+  float NdotL = saturate(dot(N, L));
+  float NdotV = saturate(dot(N, V));
+  float NdotH = saturate(dot(N, H));
+  float VdotH = saturate(dot(V, H));
+
+  // Ambient
+  vec3 ambient = hemiAmbient(N);
+
+  // Shared wrap diffuse (preserved behavior, slight tweak via saturate)
+  float wrapAmount = isMetal ? 0.14 : (isLeather ? 0.28 : 0.38);
+  float diffWrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.20);
+  if (isMetal)
+    diffWrap = pow(diffWrap, 0.88);
+
+  // Edge & cavity masks (for wear/rust/shine)
+  float edgeMask = edgeWearMask(N);  // bright edges
+  float cavityMask = 1.0 - edgeMask; // crevices
+  // Gravity bias: downward-facing areas collect more dirt/rust
+  float downBias = saturate((-N.y) * 0.6 + 0.4);
+  cavityMask *= downBias;
+
+  // === Material models ===
+  vec3 F0 = vec3(0.045, 0.05, 0.055);
+  float roughness = 0.6; // default roughness
+  float cavityAO = 1.0;  // occlusion multiplier
+  vec3 albedo = color;   // base diffuse/albedo
+  vec3 specular = vec3(0.0);
+
+  if (isMetal) {
+    // Use texture UVs for stability (as in original)
+    vec2 metalUV = v_texCoord * 4.5;
+
+    // Brushed/anisotropic micro-lines & microdents
+    float brushed = abs(sin(v_texCoord.y * 85.0)) * 0.022;
+    float dents = noise(metalUV * 6.0) * 0.035;
+    float rustTex = noise(metalUV * 8.0) * 0.10;
+
+    // Small directional scratches
+    float scratchLines = smoothstep(
+        0.97, 1.0, abs(sin(metalUV.x * 160.0 + noise(metalUV * 3.0) * 2.0)));
+    scratchLines *= 0.08;
+
+    // Procedural height for bumping (kept subtle to avoid shimmer)
+    float height = noise(metalUV * 12.0) * 0.5 + brushed * 2.0 + scratchLines;
+    vec3 Np =
+        perturbNormalFromHeight(N, v_worldPos, v_texCoord, height, 12.0, 0.55);
+    N = mix(N, Np, 0.65); // blend to keep stable
+
+    // Physically-based specular with GGX
+    roughness = clamp(0.18 + brushed * 0.35 + dents * 0.25 + rustTex * 0.30 -
+                          edgeMask * 0.12,
+                      0.05, 0.9);
+    float a = max(0.001, roughness * roughness);
+
+    // Metals take F0 from their base color
+    F0 = saturate(color);
+
+    // Rust/dirt reduce albedo and boost roughness in cavities
+    float rustMask = smoothstep(0.55, 0.85, noise(metalUV * 5.5)) * cavityMask;
+    vec3 rustTint = vec3(0.35, 0.18, 0.08); // warm iron-oxide tint
+    albedo = mix(albedo, albedo * 0.55 + rustTint * 0.35, rustMask);
+    roughness = clamp(mix(roughness, 0.85, rustMask), 0.05, 0.95);
+
+    // Edge wear: brighten edges with lower roughness (polished)
+    albedo = mix(albedo, albedo * 1.12 + vec3(0.05), edgeMask * 0.7);
+    roughness = clamp(mix(roughness, 0.10, edgeMask * 0.5), 0.05, 0.95);
+
+    // Recompute lighting terms with updated normal
+    H = normalize(L + V);
+    NdotL = saturate(dot(N, L));
+    NdotV = saturate(dot(N, V));
+    NdotH = saturate(dot(N, H));
+    VdotH = saturate(dot(V, H));
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    // Clearcoat sparkle (very subtle tight lobe)
+    float aCoat = 0.04; // ~roughness 0.2
+    float Dcoat = distributionGGX(NdotH, aCoat);
+    float Gcoat = geometrySmith(NdotV, NdotL, sqrt(aCoat));
+    vec3 Fcoat = fresnelSchlick(VdotH, vec3(0.04));
+    specular += 0.06 * (Dcoat * Gcoat * Fcoat) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    // Metals have almost no diffuse term
+    float kD = 0.0;
+    vec3 diffuse = vec3(kD);
+
+    // AO from cavities
+    cavityAO = 1.0 - rustMask * 0.6;
+
+    // Final combine (ambient + wrapped diffuse + specular)
+    vec3 lit = ambient * albedo * cavityAO + diffWrap * albedo * diffuse +
+               specular * NdotL;
+
+    // Small addition of brushed sheen from the original
+    lit += vec3(brushed) * 0.8;
+
+    color = lit;
+
+  } else if (isLeather) {
+    // Leather microstructure & wear
+    float leather = leatherGrain(uvW);
+    float wear = noise(uvW * 4.0) * 0.12 - 0.06;
+
+    float viewAngle = abs(dot(N, normalize(vec3(0.0, 1.0, 0.5))));
+    float leatherSheen = pow(1.0 - viewAngle, 5.0) * 0.12;
+
+    albedo *= 1.0 + leather - 0.08 + wear;
+    albedo += vec3(leatherSheen);
+
+    // Leather: dielectric
+    roughness = clamp(0.55 + leather * 0.25, 0.2, 0.95);
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    cavityAO = 1.0 - (noise(uvW * 3.0) * 0.15) * cavityMask;
+
+    color = ambient * albedo * cavityAO + diffWrap * diffuse + specular * NdotL;
+
+  } else if (isFabric) {
+    float weave = fabricWeave(v_worldPos.xz);
+    float fabricFuzz = noise(uvW * 18.0) * 0.08;
+    float folds = noise(uvW * 5.0) * 0.10 - 0.05;
+
+    float viewAngle = abs(dot(N, normalize(vec3(0.0, 1.0, 0.5))));
+    float fabricSheen = pow(1.0 - viewAngle, 7.0) * 0.10;
+
+    albedo *= 1.0 + fabricFuzz - 0.04 + folds;
+    albedo += vec3(weave + fabricSheen);
+
+    roughness = clamp(0.65 + fabricFuzz * 0.25, 0.3, 0.98);
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    cavityAO = 1.0 - (noise(uvW * 2.5) * 0.10) * cavityMask;
+
+    color = ambient * albedo * cavityAO + diffWrap * diffuse + specular * NdotL;
+
+  } else {
+    // Generic matte
+    float detail = noise(uvW * 8.0) * 0.14;
+    albedo *= 1.0 + detail - 0.07;
+
+    roughness = 0.7;
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    color = ambient * albedo + diffWrap * diffuse + specular * NdotL;
+  }
+
+  color = mix(color, vec3(0.30, 0.55, 0.65), edgeMask * 0.18);
+
+  // Final color clamp and alpha preserved
+  color = saturate(color);
+  FragColor = vec4(color, u_alpha);
+}

+ 327 - 0
assets/shaders/spearman_kingdom_of_iron.frag

@@ -0,0 +1,327 @@
+#version 330 core
+
+// === Inputs preserved (do not change) ===
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// === Utility ===
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 v) { return clamp(v, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float leatherGrain(vec2 p) {
+  float grain = noise(p * 10.0) * 0.16;
+  float pores = noise(p * 22.0) * 0.08;
+  return grain + pores;
+}
+
+// Fixed bug: use 2D input (was referencing p.z).
+float fabricWeave(vec2 p) {
+  float weaveU = sin(p.x * 60.0);
+  float weaveV = sin(p.y * 60.0);
+  return weaveU * weaveV * 0.05;
+}
+
+// Hemispheric ambient (simple IBL feel without extra uniforms)
+vec3 hemiAmbient(vec3 n) {
+  float up = saturate(n.y * 0.5 + 0.5);
+  vec3 sky = vec3(0.55, 0.68, 0.92) * 0.36;
+  vec3 ground = vec3(0.16, 0.16, 0.18) * 0.22;
+  return mix(ground, sky, up);
+}
+
+// Schlick Fresnel
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - saturate(cosTheta), 5.0);
+}
+
+// GGX / Trowbridge-Reitz
+float distributionGGX(float NdotH, float a) {
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(3.14159265 * d * d, 1e-6);
+}
+
+// Smith's Schlick-G for GGX
+float geometrySchlickGGX(float NdotX, float k) {
+  return NdotX / max(NdotX * (1.0 - k) + k, 1e-6);
+}
+float geometrySmith(float NdotV, float NdotL, float roughness) {
+  float r = roughness + 1.0;
+  float k = (r * r) / 8.0; // Schlick approximation
+  float ggx1 = geometrySchlickGGX(NdotV, k);
+  float ggx2 = geometrySchlickGGX(NdotL, k);
+  return ggx1 * ggx2;
+}
+
+// Screen-space curvature (edge detector) from normal derivatives
+float edgeWearMask(vec3 n) {
+  vec3 nx = dFdx(n);
+  vec3 ny = dFdy(n);
+  float curvature = length(nx) + length(ny);
+  return saturate(smoothstep(0.10, 0.70, curvature));
+}
+
+// Build an approximate TBN from derivatives (no new inputs needed)
+void buildTBN(out vec3 T, out vec3 B, out vec3 N, vec3 n, vec3 pos, vec2 uv) {
+  vec3 dp1 = dFdx(pos);
+  vec3 dp2 = dFdy(pos);
+  vec2 duv1 = dFdx(uv);
+  vec2 duv2 = dFdy(uv);
+
+  float det = duv1.x * duv2.y - duv1.y * duv2.x;
+  vec3 t = (dp1 * duv2.y - dp2 * duv1.y) * (det == 0.0 ? 1.0 : sign(det));
+  T = normalize(t - n * dot(n, t));
+  B = normalize(cross(n, T));
+  N = normalize(n);
+}
+
+// Cheap bump from a procedural height map in UV space
+vec3 perturbNormalFromHeight(vec3 n, vec3 pos, vec2 uv, float height,
+                             float scale, float strength) {
+  vec3 T, B, N;
+  buildTBN(T, B, N, n, pos, uv);
+
+  // Finite-difference heights in UV for gradient
+  float h0 = height;
+  float hx = noise((uv + vec2(0.002, 0.0)) * scale) - h0;
+  float hy = noise((uv + vec2(0.0, 0.002)) * scale) - h0;
+
+  vec3 bump = normalize(N + (T * hx + B * hy) * strength);
+  return bump;
+}
+
+void main() {
+  // Base color
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  // Inputs & coordinate prep
+  vec3 N = normalize(v_normal);
+  vec2 uvW = v_worldPos.xz * 4.5;
+  vec2 uv = v_texCoord * 4.5;
+
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+  float colorHue =
+      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
+
+  // Material classification preserved
+  bool isMetal = (avgColor > 0.45 && avgColor <= 0.65 && colorHue < 0.15);
+  bool isLeather = (avgColor > 0.30 && avgColor <= 0.50 && colorHue < 0.20);
+  bool isFabric = (avgColor > 0.25 && !isMetal && !isLeather);
+
+  // Lighting basis (kept compatible with prior shader)
+  vec3 L = normalize(vec3(1.0, 1.15, 1.0));
+  // Approximate view vector from world origin; nudged to avoid degenerate
+  // normalization
+  vec3 V = normalize(-v_worldPos + N * 0.001);
+  vec3 H = normalize(L + V);
+
+  float NdotL = saturate(dot(N, L));
+  float NdotV = saturate(dot(N, V));
+  float NdotH = saturate(dot(N, H));
+  float VdotH = saturate(dot(V, H));
+
+  // Ambient
+  vec3 ambient = hemiAmbient(N);
+
+  // Shared wrap diffuse (preserved behavior, slight tweak via saturate)
+  float wrapAmount = isMetal ? 0.10 : (isLeather ? 0.22 : 0.33);
+  float diffWrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.20);
+  if (isMetal)
+    diffWrap = pow(diffWrap, 0.88);
+
+  // Edge & cavity masks (for wear/rust/shine)
+  float edgeMask = edgeWearMask(N);  // bright edges
+  float cavityMask = 1.0 - edgeMask; // crevices
+  // Gravity bias: downward-facing areas collect more dirt/rust
+  float downBias = saturate((-N.y) * 0.6 + 0.4);
+  cavityMask *= downBias;
+
+  // === Material models ===
+  vec3 F0 = vec3(0.05, 0.05, 0.06);
+  float roughness = 0.6; // default roughness
+  float cavityAO = 1.0;  // occlusion multiplier
+  vec3 albedo = color;   // base diffuse/albedo
+  vec3 specular = vec3(0.0);
+
+  if (isMetal) {
+    // Use texture UVs for stability (as in original)
+    vec2 metalUV = v_texCoord * 4.5;
+
+    // Brushed/anisotropic micro-lines & microdents
+    float brushed = abs(sin(v_texCoord.y * 85.0)) * 0.022;
+    float dents = noise(metalUV * 6.0) * 0.035;
+    float rustTex = noise(metalUV * 8.0) * 0.10;
+
+    // Small directional scratches
+    float scratchLines = smoothstep(
+        0.97, 1.0, abs(sin(metalUV.x * 160.0 + noise(metalUV * 3.0) * 2.0)));
+    scratchLines *= 0.08;
+
+    // Procedural height for bumping (kept subtle to avoid shimmer)
+    float height = noise(metalUV * 12.0) * 0.5 + brushed * 2.0 + scratchLines;
+    vec3 Np =
+        perturbNormalFromHeight(N, v_worldPos, v_texCoord, height, 12.0, 0.55);
+    N = mix(N, Np, 0.65); // blend to keep stable
+
+    // Physically-based specular with GGX
+    roughness = clamp(0.18 + brushed * 0.35 + dents * 0.25 + rustTex * 0.30 -
+                          edgeMask * 0.12,
+                      0.05, 0.9);
+    float a = max(0.001, roughness * roughness);
+
+    // Metals take F0 from their base color
+    F0 = saturate(color);
+
+    // Rust/dirt reduce albedo and boost roughness in cavities
+    float rustMask = smoothstep(0.55, 0.85, noise(metalUV * 5.5)) * cavityMask;
+    vec3 rustTint = vec3(0.35, 0.18, 0.08); // warm iron-oxide tint
+    albedo = mix(albedo, albedo * 0.55 + rustTint * 0.35, rustMask);
+    roughness = clamp(mix(roughness, 0.85, rustMask), 0.05, 0.95);
+
+    // Edge wear: brighten edges with lower roughness (polished)
+    albedo = mix(albedo, albedo * 1.12 + vec3(0.05), edgeMask * 0.7);
+    roughness = clamp(mix(roughness, 0.10, edgeMask * 0.5), 0.05, 0.95);
+
+    // Recompute lighting terms with updated normal
+    H = normalize(L + V);
+    NdotL = saturate(dot(N, L));
+    NdotV = saturate(dot(N, V));
+    NdotH = saturate(dot(N, H));
+    VdotH = saturate(dot(V, H));
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    // Clearcoat sparkle (very subtle tight lobe)
+    float aCoat = 0.04; // ~roughness 0.2
+    float Dcoat = distributionGGX(NdotH, aCoat);
+    float Gcoat = geometrySmith(NdotV, NdotL, sqrt(aCoat));
+    vec3 Fcoat = fresnelSchlick(VdotH, vec3(0.04));
+    specular += 0.06 * (Dcoat * Gcoat * Fcoat) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    // Metals have almost no diffuse term
+    float kD = 0.0;
+    vec3 diffuse = vec3(kD);
+
+    // AO from cavities
+    cavityAO = 1.0 - rustMask * 0.6;
+
+    // Final combine (ambient + wrapped diffuse + specular)
+    vec3 lit = ambient * albedo * cavityAO + diffWrap * albedo * diffuse +
+               specular * NdotL;
+
+    // Small addition of brushed sheen from the original
+    lit += vec3(brushed) * 0.8;
+
+    color = lit;
+
+  } else if (isLeather) {
+    // Leather microstructure & wear
+    float leather = leatherGrain(uvW);
+    float wear = noise(uvW * 4.0) * 0.12 - 0.06;
+
+    float viewAngle = abs(dot(N, normalize(vec3(0.0, 1.0, 0.5))));
+    float leatherSheen = pow(1.0 - viewAngle, 5.0) * 0.12;
+
+    albedo *= 1.0 + leather - 0.08 + wear;
+    albedo += vec3(leatherSheen);
+
+    // Leather: dielectric
+    roughness = clamp(0.55 + leather * 0.25, 0.2, 0.95);
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    cavityAO = 1.0 - (noise(uvW * 3.0) * 0.15) * cavityMask;
+
+    color = ambient * albedo * cavityAO + diffWrap * diffuse + specular * NdotL;
+
+  } else if (isFabric) {
+    float weave = fabricWeave(v_worldPos.xz);
+    float fabricFuzz = noise(uvW * 18.0) * 0.08;
+    float folds = noise(uvW * 5.0) * 0.10 - 0.05;
+
+    float viewAngle = abs(dot(N, normalize(vec3(0.0, 1.0, 0.5))));
+    float fabricSheen = pow(1.0 - viewAngle, 7.0) * 0.10;
+
+    albedo *= 1.0 + fabricFuzz - 0.04 + folds;
+    albedo += vec3(weave + fabricSheen);
+
+    roughness = clamp(0.65 + fabricFuzz * 0.25, 0.3, 0.98);
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    cavityAO = 1.0 - (noise(uvW * 2.5) * 0.10) * cavityMask;
+
+    color = ambient * albedo * cavityAO + diffWrap * diffuse + specular * NdotL;
+
+  } else {
+    // Generic matte
+    float detail = noise(uvW * 8.0) * 0.14;
+    albedo *= 1.0 + detail - 0.07;
+
+    roughness = 0.7;
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    color = ambient * albedo + diffWrap * diffuse + specular * NdotL;
+  }
+
+  // Subtle kingdom accent along edges
+  color = mix(color, vec3(0.58, 0.62, 0.78), edgeMask * 0.12);
+
+  // Final color clamp and alpha preserved
+  color = saturate(color);
+  FragColor = vec4(color, u_alpha);
+}

+ 324 - 0
assets/shaders/spearman_roman_republic.frag

@@ -0,0 +1,324 @@
+#version 330 core
+
+// === Inputs preserved (do not change) ===
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// === Utility ===
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 v) { return clamp(v, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float leatherGrain(vec2 p) {
+  float grain = noise(p * 10.0) * 0.16;
+  float pores = noise(p * 22.0) * 0.08;
+  return grain + pores;
+}
+
+// Fixed bug: use 2D input (was referencing p.z).
+float fabricWeave(vec2 p) {
+  float weaveU = sin(p.x * 60.0);
+  float weaveV = sin(p.y * 60.0);
+  return weaveU * weaveV * 0.05;
+}
+
+// Hemispheric ambient (simple IBL feel without extra uniforms)
+vec3 hemiAmbient(vec3 n) {
+  float up = saturate(n.y * 0.5 + 0.5);
+  vec3 sky = vec3(0.60, 0.70, 0.80) * 0.35;
+  vec3 ground = vec3(0.20, 0.18, 0.16) * 0.25;
+  return mix(ground, sky, up);
+}
+
+// Schlick Fresnel
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - saturate(cosTheta), 5.0);
+}
+
+// GGX / Trowbridge-Reitz
+float distributionGGX(float NdotH, float a) {
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(3.14159265 * d * d, 1e-6);
+}
+
+// Smith's Schlick-G for GGX
+float geometrySchlickGGX(float NdotX, float k) {
+  return NdotX / max(NdotX * (1.0 - k) + k, 1e-6);
+}
+float geometrySmith(float NdotV, float NdotL, float roughness) {
+  float r = roughness + 1.0;
+  float k = (r * r) / 8.0; // Schlick approximation
+  float ggx1 = geometrySchlickGGX(NdotV, k);
+  float ggx2 = geometrySchlickGGX(NdotL, k);
+  return ggx1 * ggx2;
+}
+
+// Screen-space curvature (edge detector) from normal derivatives
+float edgeWearMask(vec3 n) {
+  vec3 nx = dFdx(n);
+  vec3 ny = dFdy(n);
+  float curvature = length(nx) + length(ny);
+  return saturate(smoothstep(0.10, 0.70, curvature));
+}
+
+// Build an approximate TBN from derivatives (no new inputs needed)
+void buildTBN(out vec3 T, out vec3 B, out vec3 N, vec3 n, vec3 pos, vec2 uv) {
+  vec3 dp1 = dFdx(pos);
+  vec3 dp2 = dFdy(pos);
+  vec2 duv1 = dFdx(uv);
+  vec2 duv2 = dFdy(uv);
+
+  float det = duv1.x * duv2.y - duv1.y * duv2.x;
+  vec3 t = (dp1 * duv2.y - dp2 * duv1.y) * (det == 0.0 ? 1.0 : sign(det));
+  T = normalize(t - n * dot(n, t));
+  B = normalize(cross(n, T));
+  N = normalize(n);
+}
+
+// Cheap bump from a procedural height map in UV space
+vec3 perturbNormalFromHeight(vec3 n, vec3 pos, vec2 uv, float height,
+                             float scale, float strength) {
+  vec3 T, B, N;
+  buildTBN(T, B, N, n, pos, uv);
+
+  // Finite-difference heights in UV for gradient
+  float h0 = height;
+  float hx = noise((uv + vec2(0.002, 0.0)) * scale) - h0;
+  float hy = noise((uv + vec2(0.0, 0.002)) * scale) - h0;
+
+  vec3 bump = normalize(N + (T * hx + B * hy) * strength);
+  return bump;
+}
+
+void main() {
+  // Base color
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  // Inputs & coordinate prep
+  vec3 N = normalize(v_normal);
+  vec2 uvW = v_worldPos.xz * 4.5;
+  vec2 uv = v_texCoord * 4.5;
+
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+  float colorHue =
+      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
+
+  // Material classification preserved
+  bool isMetal = (avgColor > 0.45 && avgColor <= 0.65 && colorHue < 0.15);
+  bool isLeather = (avgColor > 0.30 && avgColor <= 0.50 && colorHue < 0.20);
+  bool isFabric = (avgColor > 0.25 && !isMetal && !isLeather);
+
+  // Lighting basis (kept compatible with prior shader)
+  vec3 L = normalize(vec3(1.0, 1.15, 1.0));
+  // Approximate view vector from world origin; nudged to avoid degenerate
+  // normalization
+  vec3 V = normalize(-v_worldPos + N * 0.001);
+  vec3 H = normalize(L + V);
+
+  float NdotL = saturate(dot(N, L));
+  float NdotV = saturate(dot(N, V));
+  float NdotH = saturate(dot(N, H));
+  float VdotH = saturate(dot(V, H));
+
+  // Ambient
+  vec3 ambient = hemiAmbient(N);
+
+  // Shared wrap diffuse (preserved behavior, slight tweak via saturate)
+  float wrapAmount = isMetal ? 0.12 : (isLeather ? 0.25 : 0.35);
+  float diffWrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.20);
+  if (isMetal)
+    diffWrap = pow(diffWrap, 0.88);
+
+  // Edge & cavity masks (for wear/rust/shine)
+  float edgeMask = edgeWearMask(N);  // bright edges
+  float cavityMask = 1.0 - edgeMask; // crevices
+  // Gravity bias: downward-facing areas collect more dirt/rust
+  float downBias = saturate((-N.y) * 0.6 + 0.4);
+  cavityMask *= downBias;
+
+  // === Material models ===
+  vec3 F0 = vec3(0.04);  // default dielectric reflectance
+  float roughness = 0.6; // default roughness
+  float cavityAO = 1.0;  // occlusion multiplier
+  vec3 albedo = color;   // base diffuse/albedo
+  vec3 specular = vec3(0.0);
+
+  if (isMetal) {
+    // Use texture UVs for stability (as in original)
+    vec2 metalUV = v_texCoord * 4.5;
+
+    // Brushed/anisotropic micro-lines & microdents
+    float brushed = abs(sin(v_texCoord.y * 85.0)) * 0.022;
+    float dents = noise(metalUV * 6.0) * 0.035;
+    float rustTex = noise(metalUV * 8.0) * 0.10;
+
+    // Small directional scratches
+    float scratchLines = smoothstep(
+        0.97, 1.0, abs(sin(metalUV.x * 160.0 + noise(metalUV * 3.0) * 2.0)));
+    scratchLines *= 0.08;
+
+    // Procedural height for bumping (kept subtle to avoid shimmer)
+    float height = noise(metalUV * 12.0) * 0.5 + brushed * 2.0 + scratchLines;
+    vec3 Np =
+        perturbNormalFromHeight(N, v_worldPos, v_texCoord, height, 12.0, 0.55);
+    N = mix(N, Np, 0.65); // blend to keep stable
+
+    // Physically-based specular with GGX
+    roughness = clamp(0.18 + brushed * 0.35 + dents * 0.25 + rustTex * 0.30 -
+                          edgeMask * 0.12,
+                      0.05, 0.9);
+    float a = max(0.001, roughness * roughness);
+
+    // Metals take F0 from their base color
+    F0 = saturate(color);
+
+    // Rust/dirt reduce albedo and boost roughness in cavities
+    float rustMask = smoothstep(0.55, 0.85, noise(metalUV * 5.5)) * cavityMask;
+    vec3 rustTint = vec3(0.35, 0.18, 0.08); // warm iron-oxide tint
+    albedo = mix(albedo, albedo * 0.55 + rustTint * 0.35, rustMask);
+    roughness = clamp(mix(roughness, 0.85, rustMask), 0.05, 0.95);
+
+    // Edge wear: brighten edges with lower roughness (polished)
+    albedo = mix(albedo, albedo * 1.12 + vec3(0.05), edgeMask * 0.7);
+    roughness = clamp(mix(roughness, 0.10, edgeMask * 0.5), 0.05, 0.95);
+
+    // Recompute lighting terms with updated normal
+    H = normalize(L + V);
+    NdotL = saturate(dot(N, L));
+    NdotV = saturate(dot(N, V));
+    NdotH = saturate(dot(N, H));
+    VdotH = saturate(dot(V, H));
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    // Clearcoat sparkle (very subtle tight lobe)
+    float aCoat = 0.04; // ~roughness 0.2
+    float Dcoat = distributionGGX(NdotH, aCoat);
+    float Gcoat = geometrySmith(NdotV, NdotL, sqrt(aCoat));
+    vec3 Fcoat = fresnelSchlick(VdotH, vec3(0.04));
+    specular += 0.06 * (Dcoat * Gcoat * Fcoat) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    // Metals have almost no diffuse term
+    float kD = 0.0;
+    vec3 diffuse = vec3(kD);
+
+    // AO from cavities
+    cavityAO = 1.0 - rustMask * 0.6;
+
+    // Final combine (ambient + wrapped diffuse + specular)
+    vec3 lit = ambient * albedo * cavityAO + diffWrap * albedo * diffuse +
+               specular * NdotL;
+
+    // Small addition of brushed sheen from the original
+    lit += vec3(brushed) * 0.8;
+
+    color = lit;
+
+  } else if (isLeather) {
+    // Leather microstructure & wear
+    float leather = leatherGrain(uvW);
+    float wear = noise(uvW * 4.0) * 0.12 - 0.06;
+
+    float viewAngle = abs(dot(N, normalize(vec3(0.0, 1.0, 0.5))));
+    float leatherSheen = pow(1.0 - viewAngle, 5.0) * 0.12;
+
+    albedo *= 1.0 + leather - 0.08 + wear;
+    albedo += vec3(leatherSheen);
+
+    // Leather: dielectric
+    roughness = clamp(0.55 + leather * 0.25, 0.2, 0.95);
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    cavityAO = 1.0 - (noise(uvW * 3.0) * 0.15) * cavityMask;
+
+    color = ambient * albedo * cavityAO + diffWrap * diffuse + specular * NdotL;
+
+  } else if (isFabric) {
+    float weave = fabricWeave(v_worldPos.xz);
+    float fabricFuzz = noise(uvW * 18.0) * 0.08;
+    float folds = noise(uvW * 5.0) * 0.10 - 0.05;
+
+    float viewAngle = abs(dot(N, normalize(vec3(0.0, 1.0, 0.5))));
+    float fabricSheen = pow(1.0 - viewAngle, 7.0) * 0.10;
+
+    albedo *= 1.0 + fabricFuzz - 0.04 + folds;
+    albedo += vec3(weave + fabricSheen);
+
+    roughness = clamp(0.65 + fabricFuzz * 0.25, 0.3, 0.98);
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    cavityAO = 1.0 - (noise(uvW * 2.5) * 0.10) * cavityMask;
+
+    color = ambient * albedo * cavityAO + diffWrap * diffuse + specular * NdotL;
+
+  } else {
+    // Generic matte
+    float detail = noise(uvW * 8.0) * 0.14;
+    albedo *= 1.0 + detail - 0.07;
+
+    roughness = 0.7;
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    color = ambient * albedo + diffWrap * diffuse + specular * NdotL;
+  }
+
+  // Final color clamp and alpha preserved
+  color = saturate(color);
+  FragColor = vec4(color, u_alpha);
+}

二進制
assets/visuals/emblems/cartaghe.png


二進制
assets/visuals/emblems/rome.png


+ 13 - 1
assets/visuals/unit_visuals.json

@@ -5,11 +5,23 @@
       "color": [0.8, 0.9, 1.0],
       "texture": ""
     },
-    "knight": {
+    "swordsman": {
       "mesh": "Capsule",
       "color": [0.7, 0.7, 0.8],
       "texture": "",
       "equipment": "sword_and_shield"
+    },
+    "spearman": {
+      "mesh": "Capsule",
+      "color": [0.7, 0.85, 0.75],
+      "texture": "",
+      "equipment": "spear_and_shield"
+    },
+    "mounted_knight": {
+      "mesh": "Capsule",
+      "color": [0.75, 0.75, 0.85],
+      "texture": "",
+      "equipment": "cavalry_lance"
     }
   }
 }

+ 43 - 0
docs/renderer_modernization_plan.md

@@ -0,0 +1,43 @@
+# Renderer Modernization Roadmap
+
+## 1. Core Humanoid Platform
+- **Refactor Interface**: extract a single public `humanoid::Rig` facade (header + implementation) that owns animation state sampling, limb chain solvers, and attachment hooks. Expose high-level APIs (`configure_pose`, `attach_item`, `apply_nation_overrides`) so troop renderers stop inheriting and poking internals.
+- **Reusable Motion Modules**: split current ad-hoc logic into composable blocks (stances, locomotion profiles, melee/ranged actions, kneel/brace, sit/mount). Each module should be data-driven (JSON or C++ tables) for easy tuning.
+- **State Machine Support**: provide a light animation-state descriptor (idle, walk, attack, defend, mount, death, celebratory). Ensure transitions interpolate positions/rotations for smoothness and allow parameterized variations (e.g., weapon length).
+- **Attachment Slots**: define explicit mount points (hands, back, hip, shoulders, head) that expose transforms per frame. Allow multiple attachments per slot with layering order and blending (for mixing robes + armor + insignia).
+- **Procedural IK Utilities**: move elbow/knee solving, aim constraints, and recoil damping to self-contained helpers reusable by troops and potential future animations (jumping, climbing).
+
+## 2. Horse / Mount Base
+- **Mount Rig API**: mirror the humanoid facade with a `mount::Rig` that handles gait blending (walk/trot/gallop), turning lean, rearing, and idle behaviors.
+- **Rider Coupling**: provide helper hooks so humanoid rigs can query saddle pose, stirrup offsets, and adapt posture dynamically (e.g., lance couching vs. bow drawing).
+- **Attachment Points**: expose tack points (saddle, reins, banner poles) and allow nation/unit styles to plug in custom ornaments.
+
+## 3. Troop Renderer Architecture
+- **Factory Pipeline**: standardize the build sequence: (1) instantiate base rig (humanoid or mount), (2) apply troop-class template (weapon set, armor layout, emblem spec), (3) apply nation overrides (palette, insignia, attachments), (4) apply runtime context (player tint, formation stance).
+- **Data-Driven Styles**: move style definitions (colors, attachments, proportions) into dedicated descriptive files (YAML/JSON). Provide validation + fallback logic to keep runtime robust.
+- **Shader Coordination**: ensure renderers tag draw calls with material profiles so the shader pipeline can swap variants (metallic, cloth, emissive). Reserve IDs for future nation-specific shaders.
+- **LOD & Instancing**: add LOD metadata to troop classes (mesh complexity, animation detail) and make renderers respect instancing hints for large formations.
+
+## 4. Nation Customization Layer
+- **Style Registry**: keep the per-nation style registries but back them with data assets so designers can add nations without recompiling. Include hierarchical overrides (base → troop → nation → scenario).
+- **Palette Blending Controls**: expose blend weights and masks (e.g., keep helmets nation-colored but tunics team-colored). Document best practices to keep units readable.
+- **Accessory Library**: support shared accessory descriptors (e.g., Roman crest types, Carthaginian cloaks) that can be reused across troop classes.
+
+## 5. Animation & Interaction Enhancements
+- **Motion Variance**: integrate procedural noise (micro head turns, breathing, shield settling) to reduce uniformity. Allow specifying variance ranges per troop/nation.
+- **Contextual Poses**: provide dedicated pose presets for formation states (testudo, shield wall, skirmish) and environmental interactions (boarding ships, using siege gear).
+- **Death / Impact Hooks**: expose hooks for physics impulses so unit renderers can react to ragdoll or impact systems without bespoke code.
+
+## 6. Tooling & Workflow
+- **Visualizer Tool**: create a debug UI (Qt/ImGui) to preview rig states, swap styles, and validate nation overrides live. Essential for rapid iteration.
+- **Unit Test Harness**: add render snapshot tests (headless GL) to ensure future refactors do not break silhouettes or tint blending.
+- **Documentation**: author developer docs describing the layering (base rig → troop template → nation override → runtime tint) with examples for adding new troops or nations.
+
+## 7. Incremental Adoption Plan
+- **Phase 1**: extract current humanoid renderer logic into the `humanoid::Rig` without changing behavior; migrate archer/swordsman/spearman to the facade.
+- **Phase 2**: move horse logic into `mount::Rig`, refactor mounted knight to use the new API, and unify rider coupling.
+- **Phase 3**: shift style data into external assets, update registries to hot-load from disk.
+- **Phase 4**: implement advanced animation modules (stance library, IK helpers, contextual poses) and roll out across troop classes.
+- **Phase 5**: integrate visualization tooling and automated tests to lock in quality.
+
+This roadmap keeps the renderer stack modular, testable, and ready for additional troop types or playable factions while maintaining strong separation between base rigs, troop templates, and nation flavor.

+ 6 - 2
game/CMakeLists.txt

@@ -50,7 +50,9 @@ add_library(game_systems STATIC
     systems/save_storage.cpp
     systems/game_state_serializer.cpp
     systems/nation_registry.cpp
+    systems/nation_loader.cpp
     systems/formation_system.cpp
+    systems/troop_profile_service.cpp
     map/map_loader.cpp
     map/level_loader.cpp
     map/map_transformer.cpp
@@ -64,9 +66,11 @@ add_library(game_systems STATIC
     visuals/visual_catalog.cpp
     units/unit.cpp
     units/archer.cpp
-    units/knight.cpp
+    units/swordsman.cpp
     units/mounted_knight.cpp
     units/spearman.cpp
+    units/troop_catalog.cpp
+    units/troop_catalog_loader.cpp
     units/factory.cpp
     units/barracks.cpp
 )
@@ -74,4 +78,4 @@ add_library(game_systems STATIC
 target_include_directories(game_systems PUBLIC .)
 target_link_libraries(game_systems PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui Qt${QT_VERSION_MAJOR}::Sql engine_core render_gl)
 
-add_subdirectory(audio)
+add_subdirectory(audio)

+ 17 - 21
game/audio/AudioConstants.h

@@ -2,25 +2,21 @@
 
 #include <cstddef>
 
-// Audio system constants
 namespace AudioConstants {
-  // Volume constants
-  constexpr float DEFAULT_VOLUME = 1.0F;
-  constexpr float MIN_VOLUME = 0.0F;
-  constexpr float MAX_VOLUME = 1.0F;
-  
-  // Priority constants
-  constexpr int DEFAULT_PRIORITY = 0;
-  
-  // Channel constants
-  constexpr size_t DEFAULT_MAX_CHANNELS = 32;
-  constexpr size_t MIN_CHANNELS = 1;
-  
-  // Music player constants
-  constexpr int DEFAULT_MUSIC_CHANNELS = 4;
-  constexpr int DEFAULT_SAMPLE_RATE = 48000;
-  constexpr int DEFAULT_OUTPUT_CHANNELS = 2;
-  constexpr int DEFAULT_FADE_IN_MS = 250;
-  constexpr int DEFAULT_FADE_OUT_MS = 150;
-  constexpr int NO_FADE_MS = 0;
-}
+
+constexpr float DEFAULT_VOLUME = 1.0F;
+constexpr float MIN_VOLUME = 0.0F;
+constexpr float MAX_VOLUME = 1.0F;
+
+constexpr int DEFAULT_PRIORITY = 0;
+
+constexpr size_t DEFAULT_MAX_CHANNELS = 32;
+constexpr size_t MIN_CHANNELS = 1;
+
+constexpr int DEFAULT_MUSIC_CHANNELS = 4;
+constexpr int DEFAULT_SAMPLE_RATE = 48000;
+constexpr int DEFAULT_OUTPUT_CHANNELS = 2;
+constexpr int DEFAULT_FADE_IN_MS = 250;
+constexpr int DEFAULT_FADE_OUT_MS = 150;
+constexpr int NO_FADE_MS = 0;
+} // namespace AudioConstants

+ 2 - 1
game/audio/AudioEventHandler.cpp

@@ -111,7 +111,8 @@ void AudioEventHandler::onUnitSelected(
     if (should_play) {
       AudioCategory const category =
           m_useVoiceCategory ? AudioCategory::VOICE : AudioCategory::SFX;
-      AudioSystem::getInstance().playSound(it->second, UNIT_SELECTION_VOLUME, false, UNIT_SELECTION_PRIORITY,
+      AudioSystem::getInstance().playSound(it->second, UNIT_SELECTION_VOLUME,
+                                           false, UNIT_SELECTION_PRIORITY,
                                            category);
       m_lastSelectionSoundTime = now;
       m_lastSelectionUnitType = unit_type_str;

+ 1 - 1
game/audio/AudioEventHandler.h

@@ -18,7 +18,7 @@ class AudioEventHandler {
 public:
   static constexpr float UNIT_SELECTION_VOLUME = 1.0F;
   static constexpr int UNIT_SELECTION_PRIORITY = 5;
-  
+
   AudioEventHandler(Engine::Core::World *world);
   ~AudioEventHandler();
 

+ 18 - 9
game/audio/AudioSystem.cpp

@@ -16,8 +16,10 @@
 #include <vector>
 
 AudioSystem::AudioSystem()
-    : isRunning(false), masterVolume(AudioConstants::DEFAULT_VOLUME), soundVolume(AudioConstants::DEFAULT_VOLUME),
-      musicVolume(AudioConstants::DEFAULT_VOLUME), voiceVolume(AudioConstants::DEFAULT_VOLUME) {}
+    : isRunning(false), masterVolume(AudioConstants::DEFAULT_VOLUME),
+      soundVolume(AudioConstants::DEFAULT_VOLUME),
+      musicVolume(AudioConstants::DEFAULT_VOLUME),
+      voiceVolume(AudioConstants::DEFAULT_VOLUME) {}
 
 AudioSystem::~AudioSystem() { shutdown(); }
 
@@ -100,14 +102,16 @@ void AudioSystem::stopMusic() {
 }
 
 void AudioSystem::setMasterVolume(float volume) {
-  masterVolume = std::clamp(volume, AudioConstants::MIN_VOLUME, AudioConstants::MAX_VOLUME);
+  masterVolume = std::clamp(volume, AudioConstants::MIN_VOLUME,
+                            AudioConstants::MAX_VOLUME);
 
   std::lock_guard<std::mutex> const lock(resourceMutex);
   for (auto &sound : sounds) {
     auto it = soundCategories.find(sound.first);
     AudioCategory const category =
         (it != soundCategories.end()) ? it->second : AudioCategory::SFX;
-    sound.second->set_volume(getEffectiveVolume(category, AudioConstants::DEFAULT_VOLUME));
+    sound.second->set_volume(
+        getEffectiveVolume(category, AudioConstants::DEFAULT_VOLUME));
   }
 
   if (m_musicPlayer != nullptr) {
@@ -116,19 +120,22 @@ void AudioSystem::setMasterVolume(float volume) {
 }
 
 void AudioSystem::setSoundVolume(float volume) {
-  soundVolume = std::clamp(volume, AudioConstants::MIN_VOLUME, AudioConstants::MAX_VOLUME);
+  soundVolume = std::clamp(volume, AudioConstants::MIN_VOLUME,
+                           AudioConstants::MAX_VOLUME);
 
   std::lock_guard<std::mutex> const lock(resourceMutex);
   for (auto &sound : sounds) {
     auto it = soundCategories.find(sound.first);
     if (it != soundCategories.end() && it->second == AudioCategory::SFX) {
-      sound.second->set_volume(getEffectiveVolume(AudioCategory::SFX, AudioConstants::DEFAULT_VOLUME));
+      sound.second->set_volume(getEffectiveVolume(
+          AudioCategory::SFX, AudioConstants::DEFAULT_VOLUME));
     }
   }
 }
 
 void AudioSystem::setMusicVolume(float volume) {
-  musicVolume = std::clamp(volume, AudioConstants::MIN_VOLUME, AudioConstants::MAX_VOLUME);
+  musicVolume = std::clamp(volume, AudioConstants::MIN_VOLUME,
+                           AudioConstants::MAX_VOLUME);
 
   std::lock_guard<std::mutex> const lock(resourceMutex);
   if (m_musicPlayer != nullptr) {
@@ -137,13 +144,15 @@ void AudioSystem::setMusicVolume(float volume) {
 }
 
 void AudioSystem::setVoiceVolume(float volume) {
-  voiceVolume = std::clamp(volume, AudioConstants::MIN_VOLUME, AudioConstants::MAX_VOLUME);
+  voiceVolume = std::clamp(volume, AudioConstants::MIN_VOLUME,
+                           AudioConstants::MAX_VOLUME);
 
   std::lock_guard<std::mutex> const lock(resourceMutex);
   for (auto &sound : sounds) {
     auto it = soundCategories.find(sound.first);
     if (it != soundCategories.end() && it->second == AudioCategory::VOICE) {
-      sound.second->set_volume(getEffectiveVolume(AudioCategory::VOICE, AudioConstants::DEFAULT_VOLUME));
+      sound.second->set_volume(getEffectiveVolume(
+          AudioCategory::VOICE, AudioConstants::DEFAULT_VOLUME));
     }
   }
 }

+ 10 - 5
game/audio/AudioSystem.h

@@ -43,8 +43,10 @@ struct AudioEvent {
   int priority = AudioConstants::DEFAULT_PRIORITY;
   AudioCategory category = AudioCategory::SFX;
 
-  AudioEvent(AudioEventType t, std::string id = "", float vol = AudioConstants::DEFAULT_VOLUME,
-             bool l = false, int p = AudioConstants::DEFAULT_PRIORITY, AudioCategory cat = AudioCategory::SFX)
+  AudioEvent(AudioEventType t, std::string id = "",
+             float vol = AudioConstants::DEFAULT_VOLUME, bool l = false,
+             int p = AudioConstants::DEFAULT_PRIORITY,
+             AudioCategory cat = AudioCategory::SFX)
       : type(t), resourceId(std::move(id)), volume(vol), loop(l), priority(p),
         category(cat) {}
 };
@@ -56,10 +58,13 @@ public:
   auto initialize() -> bool;
   void shutdown();
 
-  void playSound(const std::string &soundId, float volume = AudioConstants::DEFAULT_VOLUME,
-                 bool loop = false, int priority = AudioConstants::DEFAULT_PRIORITY,
+  void playSound(const std::string &soundId,
+                 float volume = AudioConstants::DEFAULT_VOLUME,
+                 bool loop = false,
+                 int priority = AudioConstants::DEFAULT_PRIORITY,
                  AudioCategory category = AudioCategory::SFX);
-  void playMusic(const std::string &musicId, float volume = AudioConstants::DEFAULT_VOLUME,
+  void playMusic(const std::string &musicId,
+                 float volume = AudioConstants::DEFAULT_VOLUME,
                  bool crossfade = true);
   void stopSound(const std::string &soundId);
   void stopMusic();

+ 25 - 17
game/audio/MiniaudioBackend.cpp

@@ -33,10 +33,13 @@ static void audioCallback(ma_device *device, void *output_buffer, const void *,
   auto *wrapper = reinterpret_cast<DeviceWrapper *>(device->pUserData);
   if ((wrapper == nullptr) || (wrapper->self == nullptr)) {
     std::memset(output_buffer, 0,
-                static_cast<unsigned long>(frame_count * MiniaudioBackend::DEFAULT_OUTPUT_CHANNELS) * sizeof(float));
+                static_cast<unsigned long>(
+                    frame_count * MiniaudioBackend::DEFAULT_OUTPUT_CHANNELS) *
+                    sizeof(float));
     return;
   }
-  wrapper->self->on_audio(reinterpret_cast<float *>(output_buffer), frame_count);
+  wrapper->self->on_audio(reinterpret_cast<float *>(output_buffer),
+                          frame_count);
 }
 
 MiniaudioBackend::MiniaudioBackend(QObject *parent) : QObject(parent) {}
@@ -119,8 +122,8 @@ auto MiniaudioBackend::predecode(const QString &id,
   ma_decoder_config const decoder_config =
       ma_decoder_config_init(ma_format_f32, m_output_channels, m_sample_rate);
   ma_decoder decoder;
-  if (ma_decoder_init_file(path.toUtf8().constData(), &decoder_config, &decoder) !=
-      MA_SUCCESS) {
+  if (ma_decoder_init_file(path.toUtf8().constData(), &decoder_config,
+                           &decoder) != MA_SUCCESS) {
     qWarning() << "miniaudio: cannot open" << path;
     return false;
   }
@@ -129,8 +132,8 @@ auto MiniaudioBackend::predecode(const QString &id,
   float buffer[DECODE_BUFFER_FRAMES * DEFAULT_OUTPUT_CHANNELS];
   for (;;) {
     ma_uint64 frames_read = 0;
-    ma_result const result =
-        ma_decoder_read_pcm_frames(&decoder, buffer, DECODE_BUFFER_FRAMES, &frames_read);
+    ma_result const result = ma_decoder_read_pcm_frames(
+        &decoder, buffer, DECODE_BUFFER_FRAMES, &frames_read);
     if (frames_read > 0) {
       const size_t samples = size_t(frames_read) * DEFAULT_OUTPUT_CHANNELS;
       const size_t old_size = pcm.size();
@@ -159,7 +162,7 @@ void MiniaudioBackend::play(int channel, const QString &id, float volume,
                             bool loop, int fade_ms) {
   static constexpr int MIN_FADE_MS = 1;
   static constexpr int MS_PER_SECOND = 1000;
-  
+
   QMutexLocker locker(&m_mutex);
   if (channel < 0 || channel >= m_channels.size()) {
     return;
@@ -187,7 +190,8 @@ void MiniaudioBackend::play(int channel, const QString &id, float volume,
   ch.current_volume = MIN_VOLUME;
 
   const unsigned fade_samples =
-      std::max(unsigned(MIN_FADE_MS), unsigned((fade_ms * m_sample_rate) / MS_PER_SECOND));
+      std::max(unsigned(MIN_FADE_MS),
+               unsigned((fade_ms * m_sample_rate) / MS_PER_SECOND));
   ch.fade_samples = fade_samples;
   ch.volume_step = (ch.target_volume - ch.current_volume) / float(fade_samples);
 }
@@ -195,7 +199,7 @@ void MiniaudioBackend::play(int channel, const QString &id, float volume,
 void MiniaudioBackend::stop(int channel, int fade_ms) {
   static constexpr int MIN_FADE_MS = 1;
   static constexpr int MS_PER_SECOND = 1000;
-  
+
   QMutexLocker const locker(&m_mutex);
   if (channel < 0 || channel >= m_channels.size()) {
     return;
@@ -205,7 +209,8 @@ void MiniaudioBackend::stop(int channel, int fade_ms) {
     return;
   }
   const unsigned fade_samples =
-      std::max(unsigned(MIN_FADE_MS), unsigned((fade_ms * m_sample_rate) / MS_PER_SECOND));
+      std::max(unsigned(MIN_FADE_MS),
+               unsigned((fade_ms * m_sample_rate) / MS_PER_SECOND));
   ch.target_volume = MIN_VOLUME;
   ch.fade_samples = fade_samples;
   ch.volume_step = (ch.target_volume - ch.current_volume) / float(fade_samples);
@@ -228,7 +233,7 @@ void MiniaudioBackend::resume(int channel) {
 void MiniaudioBackend::set_volume(int channel, float volume, int fade_ms) {
   static constexpr int MIN_FADE_MS = 1;
   static constexpr int MS_PER_SECOND = 1000;
-  
+
   QMutexLocker const locker(&m_mutex);
   if (channel < 0 || channel >= m_channels.size()) {
     return;
@@ -239,7 +244,8 @@ void MiniaudioBackend::set_volume(int channel, float volume, int fade_ms) {
   }
   ch.target_volume = std::clamp(volume, MIN_VOLUME, MAX_VOLUME);
   const unsigned fade_samples =
-      std::max(unsigned(MIN_FADE_MS), unsigned((fade_ms * m_sample_rate) / MS_PER_SECOND));
+      std::max(unsigned(MIN_FADE_MS),
+               unsigned((fade_ms * m_sample_rate) / MS_PER_SECOND));
   ch.fade_samples = fade_samples;
   ch.volume_step = (ch.target_volume - ch.current_volume) / float(fade_samples);
 }
@@ -247,17 +253,19 @@ void MiniaudioBackend::set_volume(int channel, float volume, int fade_ms) {
 void MiniaudioBackend::stop_all(int fade_ms) {
   static constexpr int MIN_FADE_MS = 1;
   static constexpr int MS_PER_SECOND = 1000;
-  
+
   QMutexLocker const locker(&m_mutex);
   const unsigned fade_samples =
-      std::max(unsigned(MIN_FADE_MS), unsigned((fade_ms * m_sample_rate) / MS_PER_SECOND));
+      std::max(unsigned(MIN_FADE_MS),
+               unsigned((fade_ms * m_sample_rate) / MS_PER_SECOND));
   for (auto &ch : m_channels) {
     if (!ch.active) {
       continue;
     }
     ch.target_volume = MIN_VOLUME;
     ch.fade_samples = fade_samples;
-    ch.volume_step = (ch.target_volume - ch.current_volume) / float(fade_samples);
+    ch.volume_step =
+        (ch.target_volume - ch.current_volume) / float(fade_samples);
     ch.looping = false;
   }
 }
@@ -373,8 +381,8 @@ void MiniaudioBackend::on_audio(float *output, unsigned frames) {
       ch.current_volume = ch.target_volume = MIN_VOLUME;
       ch.fade_samples = 0;
     }
-    if (ch.fade_samples == 0 && ch.current_volume == MIN_VOLUME && ch.target_volume == MIN_VOLUME &&
-        !ch.looping) {
+    if (ch.fade_samples == 0 && ch.current_volume == MIN_VOLUME &&
+        ch.target_volume == MIN_VOLUME && !ch.looping) {
       ch.active = false;
     }
   }

+ 2 - 1
game/audio/MiniaudioBackend.h

@@ -24,7 +24,8 @@ public:
   explicit MiniaudioBackend(QObject *parent = nullptr);
   ~MiniaudioBackend() override;
 
-  auto initialize(int device_rate, int output_channels, int music_channels) -> bool;
+  auto initialize(int device_rate, int output_channels,
+                  int music_channels) -> bool;
   void shutdown();
 
   auto predecode(const QString &id, const QString &path) -> bool;

+ 4 - 3
game/audio/Music.cpp

@@ -24,7 +24,7 @@ Music::Music(const std::string &file_path)
 
   QObject::connect(player, &QMediaPlayer::errorOccurred,
                    [file_path = this->file_path](QMediaPlayer::Error error,
-                                               const QString &desc) {
+                                                 const QString &desc) {
                      qWarning() << "QMediaPlayer error for"
                                 << QString::fromStdString(file_path)
                                 << "- Error code:" << static_cast<int>(error)
@@ -223,7 +223,7 @@ void Music::set_volume(float volume) {
 
 void Music::fade_out() {
   static constexpr int FADE_OUT_DELAY_MS = 50;
-  
+
   if (!player || marked_for_deletion) {
     return;
   }
@@ -245,7 +245,8 @@ void Music::fade_out() {
         }
 
         QTimer::singleShot(FADE_OUT_DELAY_MS, [player_ptr, this]() {
-          if (player_ptr && player_ptr->playbackState() == QMediaPlayer::PlayingState) {
+          if (player_ptr &&
+              player_ptr->playbackState() == QMediaPlayer::PlayingState) {
             qDebug() << "Fading out and pausing"
                      << QString::fromStdString(file_path);
             player_ptr->pause();

+ 10 - 4
game/audio/MusicPlayer.cpp

@@ -36,7 +36,7 @@ MusicPlayer::~MusicPlayer() { shutdown(); }
 
 auto MusicPlayer::initialize(int musicChannels) -> bool {
   static constexpr int MIN_CHANNELS = 1;
-  
+
   if (m_initialized) {
     return true;
   }
@@ -48,7 +48,9 @@ auto MusicPlayer::initialize(int musicChannels) -> bool {
 
   m_channelCount = std::max(MIN_CHANNELS, musicChannels);
   m_backend = new MiniaudioBackend(this);
-  if (!m_backend->initialize(AudioConstants::DEFAULT_SAMPLE_RATE, AudioConstants::DEFAULT_OUTPUT_CHANNELS, m_channelCount)) {
+  if (!m_backend->initialize(AudioConstants::DEFAULT_SAMPLE_RATE,
+                             AudioConstants::DEFAULT_OUTPUT_CHANNELS,
+                             m_channelCount)) {
     qWarning() << "MusicPlayer: backend init failed";
     m_backend->deleteLater();
     m_backend = nullptr;
@@ -119,10 +121,14 @@ void MusicPlayer::registerTrack(const std::string &trackId,
 void MusicPlayer::play(const std::string &id, float v, bool loop) {
   play(id, v, loop, m_defaultChannel, AudioConstants::DEFAULT_FADE_IN_MS);
 }
-void MusicPlayer::stop() { stop(m_defaultChannel, AudioConstants::DEFAULT_FADE_OUT_MS); }
+void MusicPlayer::stop() {
+  stop(m_defaultChannel, AudioConstants::DEFAULT_FADE_OUT_MS);
+}
 void MusicPlayer::pause() { pause(m_defaultChannel); }
 void MusicPlayer::resume() { resume(m_defaultChannel); }
-void MusicPlayer::setVolume(float v) { setVolume(m_defaultChannel, v, AudioConstants::NO_FADE_MS); }
+void MusicPlayer::setVolume(float v) {
+  setVolume(m_defaultChannel, v, AudioConstants::NO_FADE_MS);
+}
 
 auto MusicPlayer::play(const std::string &id, float vol, bool loop, int channel,
                        int fadeMs) -> int {

+ 6 - 3
game/audio/MusicPlayer.h

@@ -16,12 +16,14 @@ class MusicPlayer final : public QObject {
 public:
   static auto getInstance() -> MusicPlayer &;
 
-  auto initialize(int musicChannels = AudioConstants::DEFAULT_MUSIC_CHANNELS) -> bool;
+  auto initialize(int musicChannels = AudioConstants::DEFAULT_MUSIC_CHANNELS)
+      -> bool;
   void shutdown();
 
   void registerTrack(const std::string &trackId, const std::string &filePath);
 
-  void play(const std::string &trackId, float volume = AudioConstants::DEFAULT_VOLUME, bool loop = true);
+  void play(const std::string &trackId,
+            float volume = AudioConstants::DEFAULT_VOLUME, bool loop = true);
   void stop();
   void pause();
   void resume();
@@ -32,7 +34,8 @@ public:
   void stop(int channel, int fadeMs = AudioConstants::DEFAULT_FADE_OUT_MS);
   void pause(int channel);
   void resume(int channel);
-  void setVolume(int channel, float volume, int fadeMs = AudioConstants::NO_FADE_MS);
+  void setVolume(int channel, float volume,
+                 int fadeMs = AudioConstants::NO_FADE_MS);
 
   void stopAll(int fadeMs = AudioConstants::DEFAULT_FADE_OUT_MS);
   void setMasterVolume(float volume, int fadeMs = AudioConstants::NO_FADE_MS);

+ 2 - 0
game/core/component.h

@@ -60,6 +60,7 @@ public:
 
   std::string meshPath;
   std::string texturePath;
+  std::string rendererId;
   bool visible{true};
   MeshKind mesh{MeshKind::Cube};
   std::array<float, 3> color{};
@@ -80,6 +81,7 @@ public:
   Game::Units::SpawnType spawn_type{Game::Units::SpawnType::Archer};
   int owner_id{0};
   float vision_range;
+  std::string nation_id;
 };
 
 class MovementComponent : public Component {

+ 10 - 0
game/core/serialization.cpp

@@ -99,6 +99,10 @@ auto Serialization::serializeEntity(const Entity *entity) -> QJsonObject {
     renderable_obj["meshPath"] = QString::fromStdString(renderable->meshPath);
     renderable_obj["texturePath"] =
         QString::fromStdString(renderable->texturePath);
+    if (!renderable->rendererId.empty()) {
+      renderable_obj["rendererId"] =
+          QString::fromStdString(renderable->rendererId);
+    }
     renderable_obj["visible"] = renderable->visible;
     renderable_obj["mesh"] = static_cast<int>(renderable->mesh);
     renderable_obj["color"] = serializeColor(renderable->color);
@@ -114,6 +118,7 @@ auto Serialization::serializeEntity(const Entity *entity) -> QJsonObject {
     unit_obj["unit_type"] = QString::fromStdString(
         Game::Units::spawn_typeToString(unit->spawn_type));
     unit_obj["owner_id"] = unit->owner_id;
+    unit_obj["nation_id"] = QString::fromStdString(unit->nation_id);
     entity_obj["unit"] = unit_obj;
   }
 
@@ -267,6 +272,8 @@ void Serialization::deserializeEntity(Entity *entity, const QJsonObject &json) {
     renderable->meshPath = renderable_obj["meshPath"].toString().toStdString();
     renderable->texturePath =
         renderable_obj["texturePath"].toString().toStdString();
+    renderable->rendererId =
+        renderable_obj["rendererId"].toString().toStdString();
     renderable->visible = renderable_obj["visible"].toBool(true);
     renderable->mesh =
         static_cast<RenderableComponent::MeshKind>(renderable_obj["mesh"].toInt(
@@ -297,6 +304,9 @@ void Serialization::deserializeEntity(Entity *entity, const QJsonObject &json) {
     }
 
     unit->owner_id = unit_obj["owner_id"].toInt(0);
+    if (unit_obj.contains("nation_id")) {
+      unit->nation_id = unit_obj["nation_id"].toString().toStdString();
+    }
   }
 
   if (json.contains("movement")) {

+ 37 - 9
game/map/level_loader.cpp

@@ -3,6 +3,7 @@
 #include "../../render/scene_renderer.h"
 #include "../core/component.h"
 #include "../core/world.h"
+#include "../systems/nation_registry.h"
 #include "../systems/owner_registry.h"
 #include "../units/factory.h"
 #include "../visuals/visual_catalog.h"
@@ -16,6 +17,7 @@
 #include "units/unit.h"
 #include "utils/resource_utils.h"
 #include <QDebug>
+#include <QFile>
 #include <memory>
 #include <qglobal.h>
 #include <qstringliteral.h>
@@ -31,15 +33,19 @@ auto LevelLoader::loadFromAssets(
   auto &owners = Game::Systems::OwnerRegistry::instance();
 
   Game::Visuals::VisualCatalog visual_catalog;
-  QString visuals_err;
   const QString visuals_path = Utils::Resources::resolveResourcePath(
       QStringLiteral(":/assets/visuals/unit_visuals.json"));
-  if (!visual_catalog.loadFromJsonFile(visuals_path, &visuals_err)) {
-    res.ok = false;
-    res.errorMessage =
-        QString("Failed to load visual catalog: %1").arg(visuals_err);
-    qWarning() << res.errorMessage;
-    return res;
+  bool visuals_loaded = false;
+  if (QFile::exists(visuals_path)) {
+    QString visuals_err;
+    visuals_loaded =
+        visual_catalog.loadFromJsonFile(visuals_path, &visuals_err);
+    if (!visuals_loaded && !visuals_err.isEmpty()) {
+      qWarning() << "LevelLoader: Visual catalog parse failed:" << visuals_err;
+    }
+  } else {
+    qInfo() << "LevelLoader: unit visuals catalog not found at" << visuals_path
+            << "- continuing without overrides.";
   }
 
   auto unit_reg = std::make_shared<Game::Units::UnitFactoryRegistry>();
@@ -67,12 +73,14 @@ auto LevelLoader::loadFromAssets(
     res.max_troops_per_player = def.max_troops_per_player;
     res.victoryConfig = def.victory;
 
-    auto rt =
-        Game::Map::MapTransformer::applyToWorld(def, world, &visual_catalog);
+    const Game::Visuals::VisualCatalog *catalog_ptr =
+        visuals_loaded ? &visual_catalog : nullptr;
+    auto rt = Game::Map::MapTransformer::applyToWorld(def, world, catalog_ptr);
     if (!rt.unit_ids.empty()) {
       res.playerUnitId = rt.unit_ids.front();
     } else {
 
+      auto &nationRegistry = Game::Systems::NationRegistry::instance();
       auto reg = Game::Map::MapTransformer::getFactoryRegistry();
       if (reg) {
         Game::Units::SpawnParams sp;
@@ -80,6 +88,12 @@ auto LevelLoader::loadFromAssets(
         sp.player_id = 0;
         sp.spawn_type = Game::Units::SpawnType::Archer;
         sp.aiControlled = !owners.isPlayer(sp.player_id);
+        if (const auto *nation =
+                nationRegistry.getNationForPlayer(sp.player_id)) {
+          sp.nation_id = nation->id;
+        } else {
+          sp.nation_id = nationRegistry.default_nation_id();
+        }
         if (auto unit =
                 reg->create(Game::Units::SpawnType::Archer, world, sp)) {
           res.playerUnitId = unit->id();
@@ -100,6 +114,7 @@ auto LevelLoader::loadFromAssets(
       }
     }
     if (!has_barracks) {
+      auto &nationRegistry = Game::Systems::NationRegistry::instance();
       auto reg2 = Game::Map::MapTransformer::getFactoryRegistry();
       if (reg2) {
         Game::Units::SpawnParams sp;
@@ -107,6 +122,12 @@ auto LevelLoader::loadFromAssets(
         sp.player_id = owners.getLocalPlayerId();
         sp.spawn_type = Game::Units::SpawnType::Barracks;
         sp.aiControlled = !owners.isPlayer(sp.player_id);
+        if (const auto *nation =
+                nationRegistry.getNationForPlayer(sp.player_id)) {
+          sp.nation_id = nation->id;
+        } else {
+          sp.nation_id = nationRegistry.default_nation_id();
+        }
         reg2->create(Game::Units::SpawnType::Barracks, world, sp);
       }
     }
@@ -125,6 +146,7 @@ auto LevelLoader::loadFromAssets(
     res.grid_height = 50;
     res.tile_size = 1.0F;
 
+    auto &nationRegistry = Game::Systems::NationRegistry::instance();
     auto reg = Game::Map::MapTransformer::getFactoryRegistry();
     if (reg) {
       Game::Units::SpawnParams sp;
@@ -132,6 +154,12 @@ auto LevelLoader::loadFromAssets(
       sp.player_id = 0;
       sp.spawn_type = Game::Units::SpawnType::Archer;
       sp.aiControlled = !owners.isPlayer(sp.player_id);
+      if (const auto *nation =
+              nationRegistry.getNationForPlayer(sp.player_id)) {
+        sp.nation_id = nation->id;
+      } else {
+        sp.nation_id = nationRegistry.default_nation_id();
+      }
       if (auto unit = reg->create(Game::Units::SpawnType::Archer, world, sp)) {
         res.playerUnitId = unit->id();
       }

+ 9 - 0
game/map/map_transformer.cpp

@@ -3,6 +3,7 @@
 #include "../core/component.h"
 #include "../core/ownership_constants.h"
 #include "../core/world.h"
+#include "../systems/nation_registry.h"
 #include "../systems/owner_registry.h"
 #include "../units/factory.h"
 #include "../units/spawn_type.h"
@@ -176,6 +177,14 @@ auto MapTransformer::applyToWorld(
       sp.spawn_type = s.type;
       sp.aiControlled = !owner_registry.isPlayer(effective_player_id);
       sp.maxPopulation = s.maxPopulation;
+      if (const auto *nation =
+              Game::Systems::NationRegistry::instance().getNationForPlayer(
+                  effective_player_id)) {
+        sp.nation_id = nation->id;
+      } else {
+        sp.nation_id =
+            Game::Systems::NationRegistry::instance().default_nation_id();
+      }
       auto obj = s_registry->create(s.type, world, sp);
       if (obj) {
         e = world.getEntity(obj->id());

+ 37 - 1
game/map/skirmish_loader.cpp

@@ -9,6 +9,7 @@
 #include "game/systems/building_collision_registry.h"
 #include "game/systems/command_service.h"
 #include "game/systems/global_stats_registry.h"
+#include "game/systems/nation_registry.h"
 #include "game/systems/owner_registry.h"
 #include "game/systems/selection_system.h"
 #include "game/systems/troop_count_registry.h"
@@ -91,6 +92,8 @@ void SkirmishLoader::resetGameState() {
   auto &troop_registry = Game::Systems::TroopCountRegistry::instance();
   troop_registry.clear();
 
+  Game::Systems::NationRegistry::instance().clearPlayerAssignments();
+
   if (m_fog != nullptr) {
     m_fog->updateMask(0, 0, 1.0F, {});
   }
@@ -155,6 +158,7 @@ auto SkirmishLoader::start(const QString &map_path,
   owner_registry.setLocalPlayerId(player_owner_id);
 
   std::unordered_map<int, int> team_overrides;
+  std::unordered_map<int, std::string> nation_overrides;
   QVariantList saved_player_configs;
   std::set<int> processed_player_ids;
 
@@ -166,6 +170,7 @@ auto SkirmishLoader::start(const QString &map_path,
       const int team_id = config.value("team_id", 0).toInt();
       const QString color_hex = config.value("colorHex", "#FFFFFF").toString();
       const bool is_human = config.value("isHuman", false).toBool();
+      const QString nation_id_str = config.value("nationId").toString();
 
       if (is_human && player_id != player_owner_id) {
         player_id = player_owner_id;
@@ -179,6 +184,15 @@ auto SkirmishLoader::start(const QString &map_path,
         processed_player_ids.insert(player_id);
         team_overrides[player_id] = team_id;
 
+        std::string chosen_nation;
+        if (!nation_id_str.isEmpty()) {
+          chosen_nation = nation_id_str.toStdString();
+        } else {
+          chosen_nation =
+              Game::Systems::NationRegistry::instance().default_nation_id();
+        }
+        nation_overrides[player_id] = std::move(chosen_nation);
+
         QVariantMap updated_config = config;
         updated_config["player_id"] = player_id;
         saved_player_configs.append(updated_config);
@@ -203,6 +217,29 @@ auto SkirmishLoader::start(const QString &map_path,
   Game::Map::MapTransformer::setLocalOwnerId(player_owner_id);
   Game::Map::MapTransformer::setPlayerTeamOverrides(team_overrides);
 
+  auto &nation_registry = Game::Systems::NationRegistry::instance();
+
+  for (auto it = map_player_ids.begin(); it != map_player_ids.end(); ++it) {
+    int player_id = *it;
+    auto nat_it = nation_overrides.find(player_id);
+    if (nat_it != nation_overrides.end()) {
+      nation_registry.setPlayerNation(player_id, nat_it->second);
+    } else {
+      nation_registry.setPlayerNation(player_id,
+                                      nation_registry.default_nation_id());
+    }
+  }
+
+  if (map_player_ids.isEmpty()) {
+    auto nat_it = nation_overrides.find(player_owner_id);
+    if (nat_it != nation_overrides.end()) {
+      nation_registry.setPlayerNation(player_owner_id, nat_it->second);
+    } else {
+      nation_registry.setPlayerNation(player_owner_id,
+                                      nation_registry.default_nation_id());
+    }
+  }
+
   auto level_result = Game::Map::LevelLoader::loadFromAssets(
       map_path, m_world, m_renderer, m_camera);
 
@@ -442,5 +479,4 @@ auto SkirmishLoader::start(const QString &map_path,
 
   return result;
 }
-
 } // namespace Game::Map

+ 22 - 4
game/systems/capture_system.cpp

@@ -3,6 +3,8 @@
 #include "../core/event_manager.h"
 #include "../core/ownership_constants.h"
 #include "../core/world.h"
+#include "../systems/nation_registry.h"
+#include "../systems/troop_profile_service.h"
 #include "../units/troop_config.h"
 #include "../visuals/team_colors.h"
 #include "building_collision_registry.h"
@@ -71,6 +73,17 @@ void CaptureSystem::transferBarrackOwnership(Engine::Core::World *,
   int const previous_owner_id = unit->owner_id;
   unit->owner_id = newOwnerId;
 
+  auto &nation_registry = NationRegistry::instance();
+  if (!Game::Core::isNeutralOwner(newOwnerId)) {
+    if (const auto *nation = nation_registry.getNationForPlayer(newOwnerId)) {
+      unit->nation_id = nation->id;
+    } else {
+      unit->nation_id = nation_registry.default_nation_id();
+    }
+  } else {
+    unit->nation_id.clear();
+  }
+
   QVector3D const tc = Game::Visuals::team_colorForOwner(newOwnerId);
   renderable->color[0] = tc.x();
   renderable->color[1] = tc.y();
@@ -83,7 +96,6 @@ void CaptureSystem::transferBarrackOwnership(Engine::Core::World *,
     prod = barrack->addComponent<Engine::Core::ProductionComponent>();
     if (prod != nullptr) {
       prod->product_type = Game::Units::TroopType::Archer;
-      prod->buildTime = 10.0F;
       prod->maxUnits = 150;
       prod->inProgress = false;
       prod->timeRemaining = 0.0F;
@@ -91,12 +103,18 @@ void CaptureSystem::transferBarrackOwnership(Engine::Core::World *,
       prod->rallyX = transform->position.x + 4.0F;
       prod->rallyZ = transform->position.z + 2.0F;
       prod->rallySet = true;
-      prod->villagerCost =
-          Game::Units::TroopConfig::instance().getIndividualsPerUnit(
-              prod->product_type);
+      const auto profile = TroopProfileService::instance().get_profile(
+          unit->nation_id, prod->product_type);
+      prod->buildTime = profile.production.build_time;
+      prod->villagerCost = profile.individuals_per_unit;
     }
   } else if (Game::Core::isNeutralOwner(newOwnerId) && (prod != nullptr)) {
     barrack->removeComponent<Engine::Core::ProductionComponent>();
+  } else if (prod != nullptr) {
+    const auto profile = TroopProfileService::instance().get_profile(
+        unit->nation_id, prod->product_type);
+    prod->buildTime = profile.production.build_time;
+    prod->villagerCost = profile.individuals_per_unit;
   }
 
   Engine::Core::EventManager::instance().publish(

+ 359 - 0
game/systems/nation_loader.cpp

@@ -0,0 +1,359 @@
+#include "nation_loader.h"
+
+#include "../units/troop_catalog.h"
+#include "../units/troop_type.h"
+#include "nation_registry.h"
+#include <QCoreApplication>
+#include <QDir>
+#include <QDirIterator>
+#include <QFile>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QLoggingCategory>
+
+namespace {
+
+using Game::Systems::FormationType;
+using Game::Systems::Nation;
+using Game::Systems::NationLoader;
+using Game::Systems::NationTroopVariant;
+using Game::Systems::TroopType;
+using Game::Units::TroopCatalog;
+using Game::Units::TroopClass;
+
+[[nodiscard]] auto ensure_object(const QJsonValue &value) -> QJsonObject {
+  if (value.isObject()) {
+    return value.toObject();
+  }
+  return {};
+}
+
+[[nodiscard]] auto ensure_array(const QJsonValue &value) -> QJsonArray {
+  if (value.isArray()) {
+    return value.toArray();
+  }
+  return {};
+}
+
+[[nodiscard]] auto read_string(const QJsonObject &obj, const char *key,
+                               const QString &fallback) -> QString {
+  const auto value = obj.value(key);
+  if (value.isString()) {
+    return value.toString();
+  }
+  return fallback;
+}
+
+[[nodiscard]] auto read_float_opt(const QJsonObject &obj,
+                                  const char *key) -> std::optional<float> {
+  if (!obj.contains(key)) {
+    return std::nullopt;
+  }
+  const auto value = obj.value(key);
+  return static_cast<float>(value.toDouble());
+}
+
+[[nodiscard]] auto read_int_opt(const QJsonObject &obj,
+                                const char *key) -> std::optional<int> {
+  if (!obj.contains(key)) {
+    return std::nullopt;
+  }
+  const auto value = obj.value(key);
+  if (value.isDouble()) {
+    return value.toInt();
+  }
+  if (value.isString()) {
+    bool ok = false;
+    const auto str = value.toString();
+    const int parsed = str.toInt(&ok);
+    if (ok) {
+      return parsed;
+    }
+  }
+  return std::nullopt;
+}
+
+[[nodiscard]] auto read_bool(const QJsonObject &obj, const char *key,
+                             bool fallback) -> bool {
+  if (!obj.contains(key)) {
+    return fallback;
+  }
+  return obj.value(key).toBool(fallback);
+}
+
+[[nodiscard]] auto read_bool_opt(const QJsonObject &obj,
+                                 const char *key) -> std::optional<bool> {
+  if (!obj.contains(key)) {
+    return std::nullopt;
+  }
+  return obj.value(key).toBool();
+}
+
+[[nodiscard]] auto
+parse_formation_type(const QString &value) -> std::optional<FormationType> {
+  const QString lowered = value.trimmed().toLower();
+  if (lowered == QStringLiteral("roman")) {
+    return FormationType::Roman;
+  }
+  if (lowered == QStringLiteral("barbarian")) {
+    return FormationType::Barbarian;
+  }
+  return std::nullopt;
+}
+
+[[nodiscard]] auto logger() -> QLoggingCategory & {
+  static QLoggingCategory category("NationLoader");
+  return category;
+}
+
+static constexpr const char *k_nation_troops_key = "troops";
+
+static auto nation_loader_logger() -> QLoggingCategory & { return logger(); }
+
+[[nodiscard]] auto build_troop_entry(const QJsonObject &obj,
+                                     Nation &nation) -> bool {
+  const QString troop_id = obj.value("id").toString();
+  if (troop_id.isEmpty()) {
+    qCWarning(logger()) << "Encountered troop without id in nation"
+                        << QString::fromStdString(nation.id);
+    return false;
+  }
+
+  const auto type_opt = Game::Units::tryParseTroopType(troop_id.toStdString());
+  if (!type_opt.has_value()) {
+    qCWarning(logger()) << "Unknown troop type" << troop_id << "for nation"
+                        << QString::fromStdString(nation.id);
+    return false;
+  }
+
+  const Game::Units::TroopType troop_type = *type_opt;
+  const TroopClass &base_class =
+      TroopCatalog::instance().get_class_or_fallback(troop_type);
+
+  TroopType entry{};
+  entry.unit_type = troop_type;
+  entry.displayName =
+      read_string(obj, "display_name",
+                  QString::fromStdString(base_class.display_name))
+          .toStdString();
+  entry.isMelee = read_bool(ensure_object(obj.value("production")), "is_melee",
+                            base_class.production.is_melee);
+  const QJsonObject production = ensure_object(obj.value("production"));
+  entry.cost = production.value("cost").toInt(base_class.production.cost);
+  entry.buildTime =
+      static_cast<float>(production.value("build_time")
+                             .toDouble(base_class.production.build_time));
+  entry.priority =
+      production.value("priority").toInt(base_class.production.priority);
+
+  nation.availableTroops.push_back(entry);
+
+  NationTroopVariant variant{};
+  variant.unit_type = troop_type;
+  bool has_variant = false;
+
+  const QJsonObject combat = ensure_object(obj.value("combat"));
+  if (auto value = read_int_opt(combat, "health")) {
+    variant.health = value;
+    has_variant = true;
+  }
+  if (auto value = read_int_opt(combat, "max_health")) {
+    variant.max_health = value;
+    has_variant = true;
+  }
+  if (auto value = read_float_opt(combat, "speed")) {
+    variant.speed = value;
+    has_variant = true;
+  }
+  if (auto value = read_float_opt(combat, "vision_range")) {
+    variant.vision_range = value;
+    has_variant = true;
+  }
+  if (auto value = read_int_opt(combat, "ranged_damage")) {
+    variant.attack_damage = value;
+    has_variant = true;
+  }
+  if (auto value = read_float_opt(combat, "ranged_range")) {
+    variant.attack_range = value;
+    has_variant = true;
+  }
+  if (auto value = read_float_opt(combat, "ranged_cooldown")) {
+    variant.attack_cooldown = value;
+    has_variant = true;
+  }
+  if (auto value = read_int_opt(combat, "melee_damage")) {
+    variant.melee_damage = value;
+    has_variant = true;
+  }
+  if (auto value = read_float_opt(combat, "melee_range")) {
+    variant.melee_range = value;
+    has_variant = true;
+  }
+  if (auto value = read_float_opt(combat, "melee_cooldown")) {
+    variant.melee_cooldown = value;
+    has_variant = true;
+  }
+  if (auto value = read_bool_opt(combat, "can_ranged")) {
+    variant.can_ranged = value;
+    has_variant = true;
+  }
+  if (auto value = read_bool_opt(combat, "can_melee")) {
+    variant.can_melee = value;
+    has_variant = true;
+  }
+
+  const QJsonObject visuals = ensure_object(obj.value("visuals"));
+  if (auto value = read_float_opt(visuals, "selection_ring_size")) {
+    variant.selection_ring_size = value;
+    has_variant = true;
+  }
+  if (auto value = read_float_opt(visuals, "selection_ring_y_offset")) {
+    variant.selection_ring_y_offset = value;
+    has_variant = true;
+  }
+  if (auto value = read_float_opt(visuals, "selection_ring_ground_offset")) {
+    variant.selection_ring_ground_offset = value;
+    has_variant = true;
+  }
+  if (visuals.contains("renderer_id")) {
+    variant.renderer_id = visuals.value("renderer_id").toString().toStdString();
+    has_variant = true;
+  }
+  if (auto value = read_float_opt(visuals, "render_scale")) {
+    variant.render_scale = value;
+    has_variant = true;
+  }
+
+  const QJsonObject formation = ensure_object(obj.value("formation"));
+  if (auto value = read_int_opt(formation, "individuals_per_unit")) {
+    variant.individuals_per_unit = value;
+    has_variant = true;
+  }
+  if (auto value = read_int_opt(formation, "max_units_per_row")) {
+    variant.max_units_per_row = value;
+    has_variant = true;
+  }
+
+  if (auto formation_override =
+          parse_formation_type(obj.value("formation_type").toString())) {
+    variant.formation_type = formation_override;
+    has_variant = true;
+  }
+
+  if (has_variant) {
+    nation.troopVariants[troop_type] = std::move(variant);
+  }
+
+  return true;
+}
+
+} // namespace
+
+namespace Game::Systems {
+
+auto NationLoader::resolve_data_path(const QString &relative) -> QString {
+  const QString direct = QDir::current().filePath(relative);
+  if (QFile::exists(direct)) {
+    return direct;
+  }
+
+  const QString app_dir = QCoreApplication::applicationDirPath();
+  if (!app_dir.isEmpty()) {
+    const QString from_app = QDir(app_dir).filePath(relative);
+    if (QFile::exists(from_app)) {
+      return from_app;
+    }
+
+    const QString parent = QDir(app_dir).filePath("../" + relative);
+    if (QFile::exists(parent)) {
+      return QDir(parent).canonicalPath();
+    }
+  }
+
+  return {};
+}
+
+auto NationLoader::load_default_nations() -> std::vector<Nation> {
+  const QString dir = resolve_data_path("assets/data/nations");
+  if (dir.isEmpty()) {
+    qCWarning(nation_loader_logger())
+        << "Failed to locate assets/data/nations directory";
+    return {};
+  }
+  return load_from_directory(dir);
+}
+
+auto NationLoader::load_from_directory(const QString &directory)
+    -> std::vector<Nation> {
+  std::vector<Nation> nations;
+
+  QDir dir(directory);
+  if (!dir.exists()) {
+    qCWarning(nation_loader_logger())
+        << "Nation directory does not exist" << directory;
+    return nations;
+  }
+
+  QDirIterator it(directory, QStringList{QStringLiteral("*.json")},
+                  QDir::Files | QDir::Readable);
+  while (it.hasNext()) {
+    const QString file_path = it.next();
+    if (auto nation = load_from_file(file_path)) {
+      nations.push_back(std::move(*nation));
+    }
+  }
+
+  return nations;
+}
+
+auto NationLoader::load_from_file(const QString &path)
+    -> std::optional<Nation> {
+  QFile file(path);
+  if (!file.open(QIODevice::ReadOnly)) {
+    qCWarning(nation_loader_logger()) << "Unable to open nation definition"
+                                      << path << ":" << file.errorString();
+    return std::nullopt;
+  }
+
+  const QByteArray data = file.readAll();
+  QJsonParseError parse_error;
+  const QJsonDocument doc = QJsonDocument::fromJson(data, &parse_error);
+  if (parse_error.error != QJsonParseError::NoError) {
+    qCWarning(nation_loader_logger())
+        << "Failed to parse nation" << path << ":" << parse_error.errorString();
+    return std::nullopt;
+  }
+
+  const QJsonObject root = doc.object();
+  Nation nation{};
+  nation.id = root.value("id").toString().toStdString();
+  if (nation.id.empty()) {
+    qCWarning(nation_loader_logger())
+        << "Nation file" << path << "is missing 'id'";
+    return std::nullopt;
+  }
+  nation.displayName = root.value("display_name")
+                           .toString(QString::fromStdString(nation.id))
+                           .toStdString();
+  nation.primaryBuilding = root.value("primary_building")
+                               .toString(QStringLiteral("barracks"))
+                               .toStdString();
+  if (auto formation =
+          parse_formation_type(root.value("formation_type").toString())) {
+    nation.formation_type = *formation;
+  }
+
+  const QJsonArray troops = ensure_array(root.value(k_nation_troops_key));
+  for (const auto &value : troops) {
+    const QJsonObject troop_obj = ensure_object(value);
+    if (!build_troop_entry(troop_obj, nation)) {
+      qCWarning(nation_loader_logger())
+          << "Failed to load troop entry in nation" << path;
+    }
+  }
+
+  return nation;
+}
+
+} // namespace Game::Systems

+ 21 - 0
game/systems/nation_loader.h

@@ -0,0 +1,21 @@
+#pragma once
+
+#include "nation_registry.h"
+#include <QString>
+#include <optional>
+#include <vector>
+
+namespace Game::Systems {
+
+class NationLoader {
+public:
+  static auto load_from_file(const QString &path) -> std::optional<Nation>;
+  static auto
+  load_from_directory(const QString &directory) -> std::vector<Nation>;
+  static auto load_default_nations() -> std::vector<Nation>;
+
+private:
+  static auto resolve_data_path(const QString &relative) -> QString;
+};
+
+} // namespace Game::Systems

+ 56 - 45
game/systems/nation_registry.cpp

@@ -1,5 +1,9 @@
 #include "nation_registry.h"
 #include "systems/formation_system.h"
+#include "systems/nation_loader.h"
+#include "systems/troop_profile_service.h"
+#include "units/troop_catalog.h"
+#include "units/troop_catalog_loader.h"
 #include "units/troop_type.h"
 #include <QDebug>
 #include <algorithm>
@@ -126,59 +130,66 @@ void NationRegistry::setPlayerNation(int player_id,
 }
 
 void NationRegistry::initializeDefaults() {
+  if (m_initialized) {
+    return;
+  }
+
   clear();
+  Game::Units::TroopCatalogLoader::load_default_catalog();
+
+  auto nations = NationLoader::load_default_nations();
+  if (nations.empty()) {
+    Nation kingdom_of_iron;
+    kingdom_of_iron.id = "kingdom_of_iron";
+    kingdom_of_iron.displayName = "Kingdom of Iron";
+    kingdom_of_iron.primaryBuilding = "barracks";
+    kingdom_of_iron.formation_type = FormationType::Roman;
+
+    auto appendTroop = [&kingdom_of_iron](Game::Units::TroopType type) {
+      TroopType troop_entry;
+      troop_entry.unit_type = type;
+
+      const auto &troop_class =
+          Game::Units::TroopCatalog::instance().get_class_or_fallback(type);
+      troop_entry.displayName = troop_class.display_name;
+      troop_entry.isMelee = troop_class.production.is_melee;
+      troop_entry.cost = troop_class.production.cost;
+      troop_entry.buildTime = troop_class.production.build_time;
+      troop_entry.priority = troop_class.production.priority;
+
+      kingdom_of_iron.availableTroops.push_back(std::move(troop_entry));
+    };
+
+    appendTroop(Game::Units::TroopType::Archer);
+    appendTroop(Game::Units::TroopType::Swordsman);
+    appendTroop(Game::Units::TroopType::Spearman);
+    appendTroop(Game::Units::TroopType::MountedKnight);
+
+    registerNation(std::move(kingdom_of_iron));
+    m_defaultNation = "kingdom_of_iron";
+  } else {
+    const std::string desired_default = "kingdom_of_iron";
+    std::string fallback_default = nations.front().id;
+    for (auto &nation : nations) {
+      if (nation.id == desired_default) {
+        fallback_default = nation.id;
+      }
+      registerNation(std::move(nation));
+    }
+    m_defaultNation = fallback_default;
+  }
 
-  Nation kingdom_of_iron;
-  kingdom_of_iron.id = "kingdom_of_iron";
-  kingdom_of_iron.displayName = "Kingdom of Iron";
-  kingdom_of_iron.primaryBuilding = "barracks";
-  kingdom_of_iron.formation_type = FormationType::Roman;
-
-  TroopType archer;
-  archer.unit_type = Game::Units::TroopType::Archer;
-  archer.displayName = "Archer";
-  archer.isMelee = false;
-  archer.cost = 50;
-  archer.buildTime = 5.0F;
-  archer.priority = 10;
-  kingdom_of_iron.availableTroops.push_back(archer);
-
-  TroopType knight;
-  knight.unit_type = Game::Units::TroopType::Knight;
-  knight.displayName = "Knight";
-  knight.isMelee = true;
-  knight.cost = 100;
-  knight.buildTime = 8.0F;
-  knight.priority = 10;
-  kingdom_of_iron.availableTroops.push_back(knight);
-
-  TroopType spearman;
-  spearman.unit_type = Game::Units::TroopType::Spearman;
-  spearman.displayName = "Spearman";
-  spearman.isMelee = true;
-  spearman.cost = 75;
-  spearman.buildTime = 6.0F;
-  spearman.priority = 5;
-  kingdom_of_iron.availableTroops.push_back(spearman);
-
-  TroopType mounted_knight;
-  mounted_knight.unit_type = Game::Units::TroopType::MountedKnight;
-  mounted_knight.displayName = "Mounted Knight";
-  mounted_knight.isMelee = true;
-  mounted_knight.cost = 150;
-  mounted_knight.buildTime = 10.0F;
-  mounted_knight.priority = 15;
-  kingdom_of_iron.availableTroops.push_back(mounted_knight);
-
-  registerNation(std::move(kingdom_of_iron));
-
-  m_defaultNation = "kingdom_of_iron";
+  TroopProfileService::instance().clear();
+  m_initialized = true;
 }
 
 void NationRegistry::clear() {
   m_nations.clear();
   m_nationIndex.clear();
   m_playerNations.clear();
+  m_initialized = false;
 }
 
+void NationRegistry::clearPlayerAssignments() { m_playerNations.clear(); }
+
 } // namespace Game::Systems

+ 33 - 0
game/systems/nation_registry.h

@@ -3,12 +3,37 @@
 #include "../units/troop_type.h"
 #include "formation_system.h"
 #include <memory>
+#include <optional>
 #include <string>
 #include <unordered_map>
 #include <vector>
 
 namespace Game::Systems {
 
+struct NationTroopVariant {
+  Game::Units::TroopType unit_type;
+  std::optional<int> health;
+  std::optional<int> max_health;
+  std::optional<float> speed;
+  std::optional<float> vision_range;
+  std::optional<int> attack_damage;
+  std::optional<float> attack_range;
+  std::optional<int> melee_damage;
+  std::optional<float> melee_range;
+  std::optional<float> attack_cooldown;
+  std::optional<float> melee_cooldown;
+  std::optional<int> individuals_per_unit;
+  std::optional<int> max_units_per_row;
+  std::optional<float> selection_ring_size;
+  std::optional<float> selection_ring_y_offset;
+  std::optional<float> selection_ring_ground_offset;
+  std::optional<float> render_scale;
+  std::optional<FormationType> formation_type;
+  std::optional<std::string> renderer_id;
+  std::optional<bool> can_ranged;
+  std::optional<bool> can_melee;
+};
+
 struct TroopType {
   Game::Units::TroopType unit_type;
   std::string displayName;
@@ -24,6 +49,7 @@ struct Nation {
   std::vector<TroopType> availableTroops;
   std::string primaryBuilding = "barracks";
   FormationType formation_type = FormationType::Roman;
+  std::unordered_map<Game::Units::TroopType, NationTroopVariant> troopVariants;
 
   [[nodiscard]] auto getMeleeTroops() const -> std::vector<const TroopType *>;
 
@@ -61,6 +87,12 @@ public:
 
   void clear();
 
+  void clearPlayerAssignments();
+
+  auto default_nation_id() const -> const std::string & {
+    return m_defaultNation;
+  }
+
 private:
   NationRegistry() = default;
 
@@ -68,6 +100,7 @@ private:
   std::unordered_map<std::string, size_t> m_nationIndex;
   std::unordered_map<int, std::string> m_playerNations;
   std::string m_defaultNation = "kingdom_of_iron";
+  bool m_initialized = false;
 };
 
 } // namespace Game::Systems

+ 38 - 2
game/systems/production_service.cpp

@@ -2,6 +2,8 @@
 #include "../core/component.h"
 #include "../core/world.h"
 #include "../game_config.h"
+#include "../systems/nation_registry.h"
+#include "../systems/troop_profile_service.h"
 #include "../units/troop_config.h"
 #include "core/entity.h"
 #include "units/spawn_type.h"
@@ -28,6 +30,35 @@ findFirstSelectedBarracks(Engine::Core::World &world,
   return nullptr;
 }
 
+namespace {
+
+auto resolve_nation_id(const Engine::Core::UnitComponent *unit,
+                       int owner_id) -> std::string {
+  if ((unit != nullptr) && !unit->nation_id.empty()) {
+    return unit->nation_id;
+  }
+
+  auto &registry = NationRegistry::instance();
+  if (const auto *nation = registry.getNationForPlayer(owner_id)) {
+    return nation->id;
+  }
+  return registry.default_nation_id();
+}
+
+void apply_production_profile(Engine::Core::ProductionComponent *prod,
+                              const std::string &nation_id,
+                              Game::Units::TroopType unit_type) {
+  if (prod == nullptr) {
+    return;
+  }
+  const auto profile =
+      TroopProfileService::instance().get_profile(nation_id, unit_type);
+  prod->buildTime = profile.production.build_time;
+  prod->villagerCost = profile.individuals_per_unit;
+}
+
+} // namespace
+
 auto ProductionService::startProductionForFirstSelectedBarracks(
     Engine::Core::World &world,
     const std::vector<Engine::Core::EntityID> &selected, int owner_id,
@@ -36,6 +67,11 @@ auto ProductionService::startProductionForFirstSelectedBarracks(
   if (e == nullptr) {
     return ProductionResult::NoBarracks;
   }
+  auto *unit = e->getComponent<Engine::Core::UnitComponent>();
+  const std::string nation_id = resolve_nation_id(unit, owner_id);
+  const auto profile =
+      TroopProfileService::instance().get_profile(nation_id, unit_type);
+
   auto *p = e->getComponent<Engine::Core::ProductionComponent>();
   if (p == nullptr) {
     p = e->addComponent<Engine::Core::ProductionComponent>();
@@ -44,8 +80,7 @@ auto ProductionService::startProductionForFirstSelectedBarracks(
     return ProductionResult::NoBarracks;
   }
 
-  int const individuals_per_unit =
-      Game::Units::TroopConfig::instance().getIndividualsPerUnit(unit_type);
+  int const individuals_per_unit = profile.individuals_per_unit;
 
   if (p->producedCount + individuals_per_unit > p->maxUnits) {
     return ProductionResult::PerBarracksLimitReached;
@@ -70,6 +105,7 @@ auto ProductionService::startProductionForFirstSelectedBarracks(
     p->productionQueue.push_back(unit_type);
   } else {
     p->product_type = unit_type;
+    apply_production_profile(p, nation_id, unit_type);
     p->timeRemaining = p->buildTime;
     p->inProgress = true;
   }

+ 37 - 3
game/systems/production_system.cpp

@@ -6,6 +6,8 @@
 #include "../map/map_transformer.h"
 #include "../units/factory.h"
 #include "../units/troop_config.h"
+#include "nation_registry.h"
+#include "troop_profile_service.h"
 #include "units/spawn_type.h"
 #include "units/unit.h"
 #include <cmath>
@@ -13,6 +15,34 @@
 
 namespace Game::Systems {
 
+namespace {
+
+void apply_production_profile(Engine::Core::ProductionComponent *prod,
+                              const std::string &nation_id,
+                              Game::Units::TroopType troop_type) {
+  if (prod == nullptr) {
+    return;
+  }
+  const auto profile =
+      TroopProfileService::instance().get_profile(nation_id, troop_type);
+  prod->buildTime = profile.production.build_time;
+  prod->villagerCost = profile.individuals_per_unit;
+}
+
+auto resolve_nation_id(const Engine::Core::UnitComponent *unit,
+                       int owner_id) -> std::string {
+  if ((unit != nullptr) && !unit->nation_id.empty()) {
+    return unit->nation_id;
+  }
+  auto &registry = NationRegistry::instance();
+  if (const auto *nation = registry.getNationForPlayer(owner_id)) {
+    return nation->id;
+  }
+  return registry.default_nation_id();
+}
+
+} // namespace
+
 void ProductionSystem::update(Engine::Core::World *world, float deltaTime) {
   if (world == nullptr) {
     return;
@@ -34,9 +64,11 @@ void ProductionSystem::update(Engine::Core::World *world, float deltaTime) {
       continue;
     }
 
-    int const individuals_per_unit =
-        Game::Units::TroopConfig::instance().getIndividualsPerUnit(
-            prod->product_type);
+    const int owner_id = (unit_comp != nullptr) ? unit_comp->owner_id : -1;
+    const std::string nation_id = resolve_nation_id(unit_comp, owner_id);
+    const auto current_profile = TroopProfileService::instance().get_profile(
+        nation_id, prod->product_type);
+    int const individuals_per_unit = current_profile.individuals_per_unit;
 
     if (prod->producedCount + individuals_per_unit > prod->maxUnits) {
       prod->inProgress = false;
@@ -74,6 +106,7 @@ void ProductionSystem::update(Engine::Core::World *world, float deltaTime) {
               Game::Units::spawn_typeFromTroopType(prod->product_type);
           sp.aiControlled =
               e->hasComponent<Engine::Core::AIControlledComponent>();
+          sp.nation_id = nation_id;
           auto unit = reg->create(sp.spawn_type, *world, sp);
 
           if (unit && prod->rallySet) {
@@ -90,6 +123,7 @@ void ProductionSystem::update(Engine::Core::World *world, float deltaTime) {
       if (!prod->productionQueue.empty()) {
         prod->product_type = prod->productionQueue.front();
         prod->productionQueue.erase(prod->productionQueue.begin());
+        apply_production_profile(prod, nation_id, prod->product_type);
         prod->timeRemaining = prod->buildTime;
         prod->inProgress = true;
       }

+ 143 - 0
game/systems/troop_profile_service.cpp

@@ -0,0 +1,143 @@
+#include "troop_profile_service.h"
+
+#include "nation_registry.h"
+#include "units/troop_catalog.h"
+
+namespace Game::Systems {
+
+auto TroopProfileService::instance() -> TroopProfileService & {
+  static TroopProfileService inst;
+  return inst;
+}
+
+void TroopProfileService::clear() { m_cache.clear(); }
+
+auto TroopProfileService::get_profile(
+    const std::string &nation_id, Game::Units::TroopType type) -> TroopProfile {
+  auto &nationCache = m_cache[nation_id];
+  auto cached = nationCache.find(type);
+  if (cached != nationCache.end()) {
+    return cached->second;
+  }
+
+  const Nation *nation = NationRegistry::instance().getNation(nation_id);
+  if (nation == nullptr) {
+    const auto &fallback_id = NationRegistry::instance().default_nation_id();
+    nation = NationRegistry::instance().getNation(fallback_id);
+    if (nation == nullptr) {
+      const auto &all = NationRegistry::instance().getAllNations();
+      if (all.empty()) {
+        const auto &catalog_class =
+            Game::Units::TroopCatalog::instance().get_class_or_fallback(type);
+        TroopProfile fallback{};
+        fallback.display_name = catalog_class.display_name;
+        fallback.production = catalog_class.production;
+        fallback.combat = catalog_class.combat;
+        fallback.visuals = catalog_class.visuals;
+        fallback.individuals_per_unit = catalog_class.individuals_per_unit;
+        fallback.max_units_per_row = catalog_class.max_units_per_row;
+        fallback.formation_type = FormationType::Roman;
+        return fallback;
+      }
+      nation = &all.front();
+    }
+  }
+
+  TroopProfile profile = build_profile(*nation, type);
+  nationCache.emplace(type, profile);
+  return profile;
+}
+
+auto TroopProfileService::build_profile(
+    const Nation &nation, Game::Units::TroopType type) -> TroopProfile {
+  const auto &catalogClass =
+      Game::Units::TroopCatalog::instance().get_class_or_fallback(type);
+
+  TroopProfile profile{};
+  profile.display_name = catalogClass.display_name;
+  profile.production = catalogClass.production;
+  profile.combat = catalogClass.combat;
+  profile.visuals = catalogClass.visuals;
+  profile.individuals_per_unit = catalogClass.individuals_per_unit;
+  profile.max_units_per_row = catalogClass.max_units_per_row;
+  profile.formation_type = nation.formation_type;
+
+  if (const auto *nationTroop = nation.getTroop(type)) {
+    profile.display_name = nationTroop->displayName;
+    profile.production.cost = nationTroop->cost;
+    profile.production.build_time = nationTroop->buildTime;
+    profile.production.priority = nationTroop->priority;
+    profile.production.is_melee = nationTroop->isMelee;
+  }
+
+  auto variantIt = nation.troopVariants.find(type);
+  if (variantIt != nation.troopVariants.end()) {
+    const auto &variant = variantIt->second;
+    if (variant.health) {
+      profile.combat.health = *variant.health;
+    }
+    if (variant.max_health) {
+      profile.combat.max_health = *variant.max_health;
+    }
+    if (variant.speed) {
+      profile.combat.speed = *variant.speed;
+    }
+    if (variant.vision_range) {
+      profile.combat.vision_range = *variant.vision_range;
+    }
+    if (variant.attack_damage) {
+      profile.combat.ranged_damage = *variant.attack_damage;
+    }
+    if (variant.attack_range) {
+      profile.combat.ranged_range = *variant.attack_range;
+    }
+    if (variant.attack_cooldown) {
+      profile.combat.ranged_cooldown = *variant.attack_cooldown;
+    }
+    if (variant.melee_damage) {
+      profile.combat.melee_damage = *variant.melee_damage;
+    }
+    if (variant.melee_range) {
+      profile.combat.melee_range = *variant.melee_range;
+    }
+    if (variant.melee_cooldown) {
+      profile.combat.melee_cooldown = *variant.melee_cooldown;
+    }
+    if (variant.individuals_per_unit) {
+      profile.individuals_per_unit = *variant.individuals_per_unit;
+    }
+    if (variant.max_units_per_row) {
+      profile.max_units_per_row = *variant.max_units_per_row;
+    }
+    if (variant.selection_ring_size) {
+      profile.visuals.selection_ring_size = *variant.selection_ring_size;
+    }
+    if (variant.selection_ring_y_offset) {
+      profile.visuals.selection_ring_y_offset =
+          *variant.selection_ring_y_offset;
+    }
+    if (variant.selection_ring_ground_offset) {
+      profile.visuals.selection_ring_ground_offset =
+          *variant.selection_ring_ground_offset;
+    }
+    if (variant.renderer_id) {
+      profile.visuals.renderer_id = *variant.renderer_id;
+    }
+    if (variant.render_scale) {
+      profile.visuals.render_scale = *variant.render_scale;
+    }
+    if (variant.formation_type) {
+      profile.formation_type = *variant.formation_type;
+    }
+    if (variant.can_ranged) {
+      profile.combat.can_ranged = *variant.can_ranged;
+    }
+    if (variant.can_melee) {
+      profile.combat.can_melee = *variant.can_melee;
+    }
+  }
+
+  return profile;
+}
+
+} // namespace Game::Systems

+ 41 - 0
game/systems/troop_profile_service.h

@@ -0,0 +1,41 @@
+#pragma once
+
+#include "../units/troop_catalog.h"
+#include "nation_registry.h"
+#include <optional>
+#include <string>
+#include <unordered_map>
+
+namespace Game::Systems {
+
+struct TroopProfile {
+  std::string display_name;
+  Game::Units::TroopProductionStats production;
+  Game::Units::TroopCombatStats combat;
+  Game::Units::TroopVisualStats visuals;
+  int individuals_per_unit = 1;
+  int max_units_per_row = 1;
+  FormationType formation_type = FormationType::Roman;
+};
+
+class TroopProfileService {
+public:
+  static auto instance() -> TroopProfileService &;
+
+  auto get_profile(const std::string &nation_id,
+                   Game::Units::TroopType type) -> TroopProfile;
+
+  void clear();
+
+private:
+  TroopProfileService() = default;
+
+  auto build_profile(const Nation &nation,
+                     Game::Units::TroopType type) -> TroopProfile;
+
+  std::unordered_map<std::string,
+                     std::unordered_map<Game::Units::TroopType, TroopProfile>>
+      m_cache;
+};
+
+} // namespace Game::Systems

+ 29 - 17
game/units/archer.cpp

@@ -2,6 +2,7 @@
 #include "../core/component.h"
 #include "../core/event_manager.h"
 #include "../core/world.h"
+#include "../systems/troop_profile_service.h"
 #include "units/troop_type.h"
 #include "units/unit.h"
 #include <memory>
@@ -38,21 +39,28 @@ void Archer::init(const SpawnParams &params) {
   auto *e = m_world->createEntity();
   m_id = e->getId();
 
+  const std::string nation_id = resolve_nation_id(params);
+  auto profile = Game::Systems::TroopProfileService::instance().get_profile(
+      nation_id, TroopType::Archer);
+
   m_t = e->addComponent<Engine::Core::TransformComponent>();
   m_t->position = {params.position.x(), params.position.y(),
                    params.position.z()};
-  m_t->scale = {0.5F, 0.5F, 0.5F};
+  float const scale = profile.visuals.render_scale;
+  m_t->scale = {scale, scale, scale};
 
   m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
   m_r->visible = true;
+  m_r->rendererId = profile.visuals.renderer_id;
 
   m_u = e->addComponent<Engine::Core::UnitComponent>();
   m_u->spawn_type = params.spawn_type;
-  m_u->health = 80;
-  m_u->max_health = 80;
-  m_u->speed = 3.0F;
+  m_u->health = profile.combat.health;
+  m_u->max_health = profile.combat.max_health;
+  m_u->speed = profile.combat.speed;
   m_u->owner_id = params.player_id;
-  m_u->vision_range = 16.0F;
+  m_u->vision_range = profile.combat.vision_range;
+  m_u->nation_id = nation_id;
 
   if (params.aiControlled) {
     e->addComponent<Engine::Core::AIControlledComponent>();
@@ -74,18 +82,22 @@ void Archer::init(const SpawnParams &params) {
 
   m_atk = e->addComponent<Engine::Core::AttackComponent>();
 
-  m_atk->range = 6.0F;
-  m_atk->damage = 12;
-  m_atk->cooldown = 1.2F;
-
-  m_atk->meleeRange = 1.5F;
-  m_atk->meleeDamage = 5;
-  m_atk->meleeCooldown = 0.8F;
-
-  m_atk->preferredMode = Engine::Core::AttackComponent::CombatMode::Auto;
-  m_atk->currentMode = Engine::Core::AttackComponent::CombatMode::Ranged;
-  m_atk->canRanged = true;
-  m_atk->canMelee = true;
+  m_atk->range = profile.combat.ranged_range;
+  m_atk->damage = profile.combat.ranged_damage;
+  m_atk->cooldown = profile.combat.ranged_cooldown;
+
+  m_atk->meleeRange = profile.combat.melee_range;
+  m_atk->meleeDamage = profile.combat.melee_damage;
+  m_atk->meleeCooldown = profile.combat.melee_cooldown;
+
+  m_atk->preferredMode = profile.combat.can_ranged
+                             ? Engine::Core::AttackComponent::CombatMode::Auto
+                             : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->currentMode = profile.combat.can_ranged
+                           ? Engine::Core::AttackComponent::CombatMode::Ranged
+                           : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->canRanged = profile.combat.can_ranged;
+  m_atk->canMelee = profile.combat.can_melee;
   m_atk->max_heightDifference = 2.0F;
 
   Engine::Core::EventManager::instance().publish(

+ 9 - 3
game/units/barracks.cpp

@@ -4,6 +4,7 @@
 #include "../core/ownership_constants.h"
 #include "../core/world.h"
 #include "../systems/building_collision_registry.h"
+#include "../systems/troop_profile_service.h"
 #include "../visuals/team_colors.h"
 #include "troop_config.h"
 #include "units/troop_type.h"
@@ -25,6 +26,8 @@ void Barracks::init(const SpawnParams &params) {
   auto *e = m_world->createEntity();
   m_id = e->getId();
 
+  const std::string nation_id = resolve_nation_id(params);
+
   m_t = e->addComponent<Engine::Core::TransformComponent>();
   m_t->position = {params.position.x(), params.position.y(),
                    params.position.z()};
@@ -41,6 +44,7 @@ void Barracks::init(const SpawnParams &params) {
   m_u->speed = 0.0F;
   m_u->owner_id = params.player_id;
   m_u->vision_range = 22.0F;
+  m_u->nation_id = nation_id;
 
   if (params.aiControlled) {
     e->addComponent<Engine::Core::AIControlledComponent>();
@@ -69,9 +73,11 @@ void Barracks::init(const SpawnParams &params) {
       prod->rallyZ = m_t->position.z + 2.0F;
       prod->rallySet = true;
 
-      prod->villagerCost =
-          Game::Units::TroopConfig::instance().getIndividualsPerUnit(
-              prod->product_type);
+      const auto profile =
+          Game::Systems::TroopProfileService::instance().get_profile(
+              nation_id, prod->product_type);
+      prod->buildTime = profile.production.build_time;
+      prod->villagerCost = profile.individuals_per_unit;
     }
   }
 

+ 2 - 2
game/units/factory.cpp

@@ -1,9 +1,9 @@
 #include "factory.h"
 #include "archer.h"
 #include "barracks.h"
-#include "knight.h"
 #include "mounted_knight.h"
 #include "spearman.h"
+#include "swordsman.h"
 #include "units/spawn_type.h"
 #include "units/unit.h"
 
@@ -17,7 +17,7 @@ void registerBuiltInUnits(UnitFactoryRegistry &reg) {
 
   reg.registerFactory(SpawnType::Knight, [](Engine::Core::World &world,
                                             const SpawnParams &params) {
-    return Knight::Create(world, params);
+    return Swordsman::Create(world, params);
   });
 
   reg.registerFactory(SpawnType::MountedKnight, [](Engine::Core::World &world,

+ 29 - 17
game/units/mounted_knight.cpp

@@ -2,6 +2,7 @@
 #include "../core/component.h"
 #include "../core/event_manager.h"
 #include "../core/world.h"
+#include "../systems/troop_profile_service.h"
 #include "units/troop_type.h"
 #include "units/unit.h"
 #include <memory>
@@ -40,21 +41,28 @@ void MountedKnight::init(const SpawnParams &params) {
   auto *e = m_world->createEntity();
   m_id = e->getId();
 
+  const std::string nation_id = resolve_nation_id(params);
+  auto profile = Game::Systems::TroopProfileService::instance().get_profile(
+      nation_id, TroopType::MountedKnight);
+
   m_t = e->addComponent<Engine::Core::TransformComponent>();
   m_t->position = {params.position.x(), params.position.y(),
                    params.position.z()};
-  m_t->scale = {0.8F, 0.8F, 0.8F};
+  float const scale = profile.visuals.render_scale;
+  m_t->scale = {scale, scale, scale};
 
   m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
   m_r->visible = true;
+  m_r->rendererId = profile.visuals.renderer_id;
 
   m_u = e->addComponent<Engine::Core::UnitComponent>();
   m_u->spawn_type = params.spawn_type;
-  m_u->health = 200;
-  m_u->max_health = 200;
-  m_u->speed = 8.0F;
+  m_u->health = profile.combat.health;
+  m_u->max_health = profile.combat.max_health;
+  m_u->speed = profile.combat.speed;
   m_u->owner_id = params.player_id;
-  m_u->vision_range = 16.0F;
+  m_u->vision_range = profile.combat.vision_range;
+  m_u->nation_id = nation_id;
 
   if (params.aiControlled) {
     e->addComponent<Engine::Core::AIControlledComponent>();
@@ -76,18 +84,22 @@ void MountedKnight::init(const SpawnParams &params) {
 
   m_atk = e->addComponent<Engine::Core::AttackComponent>();
 
-  m_atk->range = 1.5F;
-  m_atk->damage = 5;
-  m_atk->cooldown = 2.0F;
-
-  m_atk->meleeRange = 2.0F;
-  m_atk->meleeDamage = 25;
-  m_atk->meleeCooldown = 0.8F;
-
-  m_atk->preferredMode = Engine::Core::AttackComponent::CombatMode::Melee;
-  m_atk->currentMode = Engine::Core::AttackComponent::CombatMode::Melee;
-  m_atk->canRanged = false;
-  m_atk->canMelee = true;
+  m_atk->range = profile.combat.ranged_range;
+  m_atk->damage = profile.combat.ranged_damage;
+  m_atk->cooldown = profile.combat.ranged_cooldown;
+
+  m_atk->meleeRange = profile.combat.melee_range;
+  m_atk->meleeDamage = profile.combat.melee_damage;
+  m_atk->meleeCooldown = profile.combat.melee_cooldown;
+
+  m_atk->preferredMode = profile.combat.can_ranged
+                             ? Engine::Core::AttackComponent::CombatMode::Auto
+                             : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->currentMode = profile.combat.can_ranged
+                           ? Engine::Core::AttackComponent::CombatMode::Ranged
+                           : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->canRanged = profile.combat.can_ranged;
+  m_atk->canMelee = profile.combat.can_melee;
   m_atk->max_heightDifference = 2.0F;
 
   Engine::Core::EventManager::instance().publish(

+ 6 - 5
game/units/spawn_type.h

@@ -22,7 +22,7 @@ inline auto spawn_typeToQString(SpawnType type) -> QString {
   case SpawnType::Archer:
     return QStringLiteral("archer");
   case SpawnType::Knight:
-    return QStringLiteral("knight");
+    return QStringLiteral("swordsman");
   case SpawnType::Spearman:
     return QStringLiteral("spearman");
   case SpawnType::MountedKnight:
@@ -43,7 +43,8 @@ inline auto tryParseSpawnType(const QString &value, SpawnType &out) -> bool {
     out = SpawnType::Archer;
     return true;
   }
-  if (lowered == QStringLiteral("knight")) {
+  if (lowered == QStringLiteral("swordsman") ||
+      lowered == QStringLiteral("knight")) {
     out = SpawnType::Knight;
     return true;
   }
@@ -67,7 +68,7 @@ spawn_typeFromString(const std::string &str) -> std::optional<SpawnType> {
   if (str == "archer") {
     return SpawnType::Archer;
   }
-  if (str == "knight") {
+  if (str == "swordsman" || str == "knight") {
     return SpawnType::Knight;
   }
   if (str == "spearman") {
@@ -95,7 +96,7 @@ inline auto spawn_typeToTroopType(SpawnType type) -> std::optional<TroopType> {
   case SpawnType::Archer:
     return TroopType::Archer;
   case SpawnType::Knight:
-    return TroopType::Knight;
+    return TroopType::Swordsman;
   case SpawnType::Spearman:
     return TroopType::Spearman;
   case SpawnType::MountedKnight:
@@ -110,7 +111,7 @@ inline auto spawn_typeFromTroopType(TroopType type) -> SpawnType {
   switch (type) {
   case TroopType::Archer:
     return SpawnType::Archer;
-  case TroopType::Knight:
+  case TroopType::Swordsman:
     return SpawnType::Knight;
   case TroopType::Spearman:
     return SpawnType::Spearman;

+ 29 - 17
game/units/spearman.cpp

@@ -2,6 +2,7 @@
 #include "../core/component.h"
 #include "../core/event_manager.h"
 #include "../core/world.h"
+#include "../systems/troop_profile_service.h"
 #include "units/troop_type.h"
 #include "units/unit.h"
 #include <memory>
@@ -39,21 +40,28 @@ void Spearman::init(const SpawnParams &params) {
   auto *e = m_world->createEntity();
   m_id = e->getId();
 
+  const std::string nation_id = resolve_nation_id(params);
+  auto profile = Game::Systems::TroopProfileService::instance().get_profile(
+      nation_id, TroopType::Spearman);
+
   m_t = e->addComponent<Engine::Core::TransformComponent>();
   m_t->position = {params.position.x(), params.position.y(),
                    params.position.z()};
-  m_t->scale = {0.55F, 0.55F, 0.55F};
+  float const scale = profile.visuals.render_scale;
+  m_t->scale = {scale, scale, scale};
 
   m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
   m_r->visible = true;
+  m_r->rendererId = profile.visuals.renderer_id;
 
   m_u = e->addComponent<Engine::Core::UnitComponent>();
   m_u->spawn_type = params.spawn_type;
-  m_u->health = 120;
-  m_u->max_health = 120;
-  m_u->speed = 2.5F;
+  m_u->health = profile.combat.health;
+  m_u->max_health = profile.combat.max_health;
+  m_u->speed = profile.combat.speed;
   m_u->owner_id = params.player_id;
-  m_u->vision_range = 15.0F;
+  m_u->vision_range = profile.combat.vision_range;
+  m_u->nation_id = nation_id;
 
   if (params.aiControlled) {
     e->addComponent<Engine::Core::AIControlledComponent>();
@@ -75,18 +83,22 @@ void Spearman::init(const SpawnParams &params) {
 
   m_atk = e->addComponent<Engine::Core::AttackComponent>();
 
-  m_atk->range = 2.5F;
-  m_atk->damage = 8;
-  m_atk->cooldown = 1.5F;
-
-  m_atk->meleeRange = 2.5F;
-  m_atk->meleeDamage = 18;
-  m_atk->meleeCooldown = 0.8F;
-
-  m_atk->preferredMode = Engine::Core::AttackComponent::CombatMode::Melee;
-  m_atk->currentMode = Engine::Core::AttackComponent::CombatMode::Melee;
-  m_atk->canRanged = false;
-  m_atk->canMelee = true;
+  m_atk->range = profile.combat.ranged_range;
+  m_atk->damage = profile.combat.ranged_damage;
+  m_atk->cooldown = profile.combat.ranged_cooldown;
+
+  m_atk->meleeRange = profile.combat.melee_range;
+  m_atk->meleeDamage = profile.combat.melee_damage;
+  m_atk->meleeCooldown = profile.combat.melee_cooldown;
+
+  m_atk->preferredMode = profile.combat.can_ranged
+                             ? Engine::Core::AttackComponent::CombatMode::Auto
+                             : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->currentMode = profile.combat.can_ranged
+                           ? Engine::Core::AttackComponent::CombatMode::Ranged
+                           : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->canRanged = profile.combat.can_ranged;
+  m_atk->canMelee = profile.combat.can_melee;
   m_atk->max_heightDifference = 2.0F;
 
   Engine::Core::EventManager::instance().publish(

+ 36 - 23
game/units/knight.cpp → game/units/swordsman.cpp

@@ -1,7 +1,8 @@
-#include "knight.h"
+#include "swordsman.h"
 #include "../core/component.h"
 #include "../core/event_manager.h"
 #include "../core/world.h"
+#include "../systems/troop_profile_service.h"
 #include "units/troop_type.h"
 #include "units/unit.h"
 #include <memory>
@@ -24,35 +25,43 @@ static inline auto team_color(int owner_id) -> QVector3D {
 
 namespace Game::Units {
 
-Knight::Knight(Engine::Core::World &world) : Unit(world, TroopType::Knight) {}
+Swordsman::Swordsman(Engine::Core::World &world)
+    : Unit(world, TroopType::Swordsman) {}
 
-auto Knight::Create(Engine::Core::World &world,
-                    const SpawnParams &params) -> std::unique_ptr<Knight> {
-  auto unit = std::unique_ptr<Knight>(new Knight(world));
+auto Swordsman::Create(Engine::Core::World &world, const SpawnParams &params)
+    -> std::unique_ptr<Swordsman> {
+  auto unit = std::unique_ptr<Swordsman>(new Swordsman(world));
   unit->init(params);
   return unit;
 }
 
-void Knight::init(const SpawnParams &params) {
+void Swordsman::init(const SpawnParams &params) {
 
   auto *e = m_world->createEntity();
   m_id = e->getId();
 
+  const std::string nation_id = resolve_nation_id(params);
+  auto profile = Game::Systems::TroopProfileService::instance().get_profile(
+      nation_id, TroopType::Swordsman);
+
   m_t = e->addComponent<Engine::Core::TransformComponent>();
   m_t->position = {params.position.x(), params.position.y(),
                    params.position.z()};
-  m_t->scale = {0.6F, 0.6F, 0.6F};
+  float const scale = profile.visuals.render_scale;
+  m_t->scale = {scale, scale, scale};
 
   m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
   m_r->visible = true;
+  m_r->rendererId = profile.visuals.renderer_id;
 
   m_u = e->addComponent<Engine::Core::UnitComponent>();
   m_u->spawn_type = params.spawn_type;
-  m_u->health = 150;
-  m_u->max_health = 150;
-  m_u->speed = 2.0F;
+  m_u->health = profile.combat.health;
+  m_u->max_health = profile.combat.max_health;
+  m_u->speed = profile.combat.speed;
   m_u->owner_id = params.player_id;
-  m_u->vision_range = 14.0F;
+  m_u->vision_range = profile.combat.vision_range;
+  m_u->nation_id = nation_id;
 
   if (params.aiControlled) {
     e->addComponent<Engine::Core::AIControlledComponent>();
@@ -74,18 +83,22 @@ void Knight::init(const SpawnParams &params) {
 
   m_atk = e->addComponent<Engine::Core::AttackComponent>();
 
-  m_atk->range = 1.5F;
-  m_atk->damage = 5;
-  m_atk->cooldown = 2.0F;
-
-  m_atk->meleeRange = 1.5F;
-  m_atk->meleeDamage = 20;
-  m_atk->meleeCooldown = 0.6F;
-
-  m_atk->preferredMode = Engine::Core::AttackComponent::CombatMode::Melee;
-  m_atk->currentMode = Engine::Core::AttackComponent::CombatMode::Melee;
-  m_atk->canRanged = false;
-  m_atk->canMelee = true;
+  m_atk->range = profile.combat.ranged_range;
+  m_atk->damage = profile.combat.ranged_damage;
+  m_atk->cooldown = profile.combat.ranged_cooldown;
+
+  m_atk->meleeRange = profile.combat.melee_range;
+  m_atk->meleeDamage = profile.combat.melee_damage;
+  m_atk->meleeCooldown = profile.combat.melee_cooldown;
+
+  m_atk->preferredMode = profile.combat.can_ranged
+                             ? Engine::Core::AttackComponent::CombatMode::Auto
+                             : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->currentMode = profile.combat.can_ranged
+                           ? Engine::Core::AttackComponent::CombatMode::Ranged
+                           : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->canRanged = profile.combat.can_ranged;
+  m_atk->canMelee = profile.combat.can_melee;
   m_atk->max_heightDifference = 2.0F;
 
   Engine::Core::EventManager::instance().publish(

+ 3 - 3
game/units/knight.h → game/units/swordsman.h

@@ -4,13 +4,13 @@
 
 namespace Game::Units {
 
-class Knight : public Unit {
+class Swordsman : public Unit {
 public:
   static auto Create(Engine::Core::World &world,
-                     const SpawnParams &params) -> std::unique_ptr<Knight>;
+                     const SpawnParams &params) -> std::unique_ptr<Swordsman>;
 
 private:
-  Knight(Engine::Core::World &world);
+  Swordsman(Engine::Core::World &world);
   void init(const SpawnParams &params);
 };
 

+ 171 - 0
game/units/troop_catalog.cpp

@@ -0,0 +1,171 @@
+#include "troop_catalog.h"
+
+#include <utility>
+
+namespace Game::Units {
+
+auto TroopCatalog::instance() -> TroopCatalog & {
+  static TroopCatalog inst;
+  return inst;
+}
+
+TroopCatalog::TroopCatalog() { register_defaults(); }
+
+void TroopCatalog::register_class(TroopClass troop_class) {
+  m_classes[troop_class.unit_type] = std::move(troop_class);
+}
+
+auto TroopCatalog::get_class(Game::Units::TroopType type) const
+    -> const TroopClass * {
+  auto it = m_classes.find(type);
+  if (it != m_classes.end()) {
+    return &it->second;
+  }
+  return nullptr;
+}
+
+auto TroopCatalog::get_class_or_fallback(Game::Units::TroopType type) const
+    -> const TroopClass & {
+  auto it = m_classes.find(type);
+  if (it != m_classes.end()) {
+    return it->second;
+  }
+  return m_fallback;
+}
+
+void TroopCatalog::clear() { m_classes.clear(); }
+
+void TroopCatalog::register_defaults() {
+  m_fallback.display_name = "Unknown Troop";
+  m_fallback.visuals.renderer_id = "troops/unknown";
+
+  TroopClass archer{};
+  archer.unit_type = Game::Units::TroopType::Archer;
+  archer.display_name = "Archer";
+  archer.production.cost = 50;
+  archer.production.build_time = 5.0F;
+  archer.production.priority = 10;
+  archer.production.is_melee = false;
+
+  archer.combat.health = 80;
+  archer.combat.max_health = 80;
+  archer.combat.speed = 3.0F;
+  archer.combat.vision_range = 16.0F;
+  archer.combat.ranged_range = 6.0F;
+  archer.combat.ranged_damage = 12;
+  archer.combat.ranged_cooldown = 1.2F;
+  archer.combat.melee_range = 1.5F;
+  archer.combat.melee_damage = 5;
+  archer.combat.melee_cooldown = 0.8F;
+  archer.combat.can_ranged = true;
+  archer.combat.can_melee = true;
+
+  archer.visuals.render_scale = 0.5F;
+  archer.visuals.selection_ring_size = 1.2F;
+  archer.visuals.selection_ring_ground_offset = 0.0F;
+  archer.visuals.selection_ring_y_offset = 0.0F;
+  archer.visuals.renderer_id = "troops/kingdom/archer";
+
+  archer.individuals_per_unit = 20;
+  archer.max_units_per_row = 5;
+
+  register_class(std::move(archer));
+
+  TroopClass swordsman{};
+  swordsman.unit_type = Game::Units::TroopType::Swordsman;
+  swordsman.display_name = "Swordsman";
+  swordsman.production.cost = 90;
+  swordsman.production.build_time = 7.0F;
+  swordsman.production.priority = 10;
+  swordsman.production.is_melee = true;
+
+  swordsman.combat.health = 140;
+  swordsman.combat.max_health = 140;
+  swordsman.combat.speed = 2.2F;
+  swordsman.combat.vision_range = 14.0F;
+  swordsman.combat.ranged_range = 1.5F;
+  swordsman.combat.ranged_damage = 6;
+  swordsman.combat.ranged_cooldown = 1.8F;
+  swordsman.combat.melee_range = 1.6F;
+  swordsman.combat.melee_damage = 18;
+  swordsman.combat.melee_cooldown = 0.6F;
+  swordsman.combat.can_ranged = false;
+  swordsman.combat.can_melee = true;
+
+  swordsman.visuals.render_scale = 0.6F;
+  swordsman.visuals.selection_ring_size = 1.1F;
+  swordsman.visuals.selection_ring_ground_offset = 0.0F;
+  swordsman.visuals.selection_ring_y_offset = 0.0F;
+  swordsman.visuals.renderer_id = "troops/kingdom/swordsman";
+
+  swordsman.individuals_per_unit = 15;
+  swordsman.max_units_per_row = 5;
+
+  register_class(std::move(swordsman));
+
+  TroopClass spearman{};
+  spearman.unit_type = Game::Units::TroopType::Spearman;
+  spearman.display_name = "Spearman";
+  spearman.production.cost = 75;
+  spearman.production.build_time = 6.0F;
+  spearman.production.priority = 5;
+  spearman.production.is_melee = true;
+
+  spearman.combat.health = 120;
+  spearman.combat.max_health = 120;
+  spearman.combat.speed = 2.5F;
+  spearman.combat.vision_range = 15.0F;
+  spearman.combat.ranged_range = 2.5F;
+  spearman.combat.ranged_damage = 8;
+  spearman.combat.ranged_cooldown = 1.5F;
+  spearman.combat.melee_range = 2.5F;
+  spearman.combat.melee_damage = 18;
+  spearman.combat.melee_cooldown = 0.8F;
+  spearman.combat.can_ranged = false;
+  spearman.combat.can_melee = true;
+
+  spearman.visuals.render_scale = 0.55F;
+  spearman.visuals.selection_ring_size = 1.4F;
+  spearman.visuals.selection_ring_ground_offset = 0.0F;
+  spearman.visuals.selection_ring_y_offset = 0.0F;
+  spearman.visuals.renderer_id = "troops/kingdom/spearman";
+
+  spearman.individuals_per_unit = 24;
+  spearman.max_units_per_row = 6;
+
+  register_class(std::move(spearman));
+
+  TroopClass mounted_knight{};
+  mounted_knight.unit_type = Game::Units::TroopType::MountedKnight;
+  mounted_knight.display_name = "Mounted Knight";
+  mounted_knight.production.cost = 150;
+  mounted_knight.production.build_time = 10.0F;
+  mounted_knight.production.priority = 15;
+  mounted_knight.production.is_melee = true;
+
+  mounted_knight.combat.health = 200;
+  mounted_knight.combat.max_health = 200;
+  mounted_knight.combat.speed = 8.0F;
+  mounted_knight.combat.vision_range = 16.0F;
+  mounted_knight.combat.ranged_range = 1.5F;
+  mounted_knight.combat.ranged_damage = 5;
+  mounted_knight.combat.ranged_cooldown = 2.0F;
+  mounted_knight.combat.melee_range = 2.0F;
+  mounted_knight.combat.melee_damage = 25;
+  mounted_knight.combat.melee_cooldown = 0.8F;
+  mounted_knight.combat.can_ranged = false;
+  mounted_knight.combat.can_melee = true;
+
+  mounted_knight.visuals.render_scale = 0.8F;
+  mounted_knight.visuals.selection_ring_size = 2.0F;
+  mounted_knight.visuals.selection_ring_ground_offset = 1.35F;
+  mounted_knight.visuals.selection_ring_y_offset = 0.0F;
+  mounted_knight.visuals.renderer_id = "troops/kingdom/mounted_knight";
+
+  mounted_knight.individuals_per_unit = 9;
+  mounted_knight.max_units_per_row = 3;
+
+  register_class(std::move(mounted_knight));
+}
+
+} // namespace Game::Units

+ 82 - 0
game/units/troop_catalog.h

@@ -0,0 +1,82 @@
+#pragma once
+
+#include "troop_type.h"
+#include <string>
+#include <unordered_map>
+
+namespace Game::Units {
+
+struct TroopCombatStats {
+  int health = 100;
+  int max_health = 100;
+  float speed = 1.0F;
+  float vision_range = 12.0F;
+
+  float ranged_range = 2.0F;
+  int ranged_damage = 10;
+  float ranged_cooldown = 1.0F;
+
+  float melee_range = 1.5F;
+  int melee_damage = 10;
+  float melee_cooldown = 1.0F;
+
+  bool can_ranged = false;
+  bool can_melee = true;
+};
+
+struct TroopProductionStats {
+  int cost = 100;
+  float build_time = 4.0F;
+  int priority = 0;
+  bool is_melee = true;
+};
+
+struct TroopVisualStats {
+  float render_scale = 1.0F;
+  float selection_ring_size = 0.5F;
+  float selection_ring_y_offset = 0.0F;
+  float selection_ring_ground_offset = 0.0F;
+  std::string renderer_id;
+};
+
+struct TroopClass {
+  Game::Units::TroopType unit_type;
+  std::string display_name;
+
+  TroopProductionStats production;
+  TroopCombatStats combat;
+  TroopVisualStats visuals;
+
+  int individuals_per_unit = 1;
+  int max_units_per_row = 1;
+};
+
+class TroopCatalog {
+public:
+  static auto instance() -> TroopCatalog &;
+
+  void register_class(TroopClass troop_class);
+
+  [[nodiscard]] auto
+  get_class(Game::Units::TroopType type) const -> const TroopClass *;
+
+  [[nodiscard]] auto get_class_or_fallback(Game::Units::TroopType type) const
+      -> const TroopClass &;
+
+  [[nodiscard]] auto get_all_classes() const
+      -> const std::unordered_map<Game::Units::TroopType, TroopClass> & {
+    return m_classes;
+  }
+
+  void clear();
+
+private:
+  TroopCatalog();
+
+  void register_defaults();
+
+  std::unordered_map<Game::Units::TroopType, TroopClass> m_classes;
+  TroopClass m_fallback{};
+};
+
+} // namespace Game::Units

+ 229 - 0
game/units/troop_catalog_loader.cpp

@@ -0,0 +1,229 @@
+#include "troop_catalog_loader.h"
+
+#include "troop_catalog.h"
+#include "troop_config.h"
+#include <QCoreApplication>
+#include <QDir>
+#include <QFile>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonValue>
+#include <QLoggingCategory>
+#include <QVariant>
+
+namespace {
+[[nodiscard]] auto ensure_array(const QJsonValue &value) -> QJsonArray {
+  if (value.isArray()) {
+    return value.toArray();
+  }
+  return {};
+}
+
+[[nodiscard]] auto ensure_object(const QJsonValue &value) -> QJsonObject {
+  if (value.isObject()) {
+    return value.toObject();
+  }
+  return {};
+}
+
+[[nodiscard]] auto read_float(const QJsonObject &obj, const char *key,
+                              float fallback) -> float {
+  if (!obj.contains(key)) {
+    return fallback;
+  }
+  const auto value = obj.value(key);
+  return static_cast<float>(value.toDouble(fallback));
+}
+
+[[nodiscard]] auto read_int(const QJsonObject &obj, const char *key,
+                            int fallback) -> int {
+  if (!obj.contains(key)) {
+    return fallback;
+  }
+  const auto value = obj.value(key);
+  if (value.isDouble()) {
+    return value.toInt(fallback);
+  }
+  if (value.isString()) {
+    bool ok = false;
+    const auto str = value.toString();
+    const int result = str.toInt(&ok);
+    return ok ? result : fallback;
+  }
+  return fallback;
+}
+
+[[nodiscard]] auto read_bool(const QJsonObject &obj, const char *key,
+                             bool fallback) -> bool {
+  if (!obj.contains(key)) {
+    return fallback;
+  }
+  return obj.value(key).toBool(fallback);
+}
+
+} // namespace
+
+namespace Game::Units {
+
+static constexpr const char *k_troop_list_key = "troops";
+static bool g_catalog_loaded = false;
+
+static auto logger() -> QLoggingCategory & {
+  static QLoggingCategory category("TroopCatalogLoader");
+  return category;
+}
+
+auto TroopCatalogLoader::resolve_data_path(const QString &relative) -> QString {
+  const QString direct = QDir::current().filePath(relative);
+  if (QFile::exists(direct)) {
+    return direct;
+  }
+
+  const QString appDir = QCoreApplication::applicationDirPath();
+  if (!appDir.isEmpty()) {
+    const QString fromApp = QDir(appDir).filePath(relative);
+    if (QFile::exists(fromApp)) {
+      return fromApp;
+    }
+
+    const QString parent = QDir(appDir).filePath("../" + relative);
+    if (QFile::exists(parent)) {
+      return QDir(parent).canonicalPath();
+    }
+  }
+
+  return {};
+}
+
+auto TroopCatalogLoader::load_default_catalog() -> bool {
+  if (g_catalog_loaded) {
+    return true;
+  }
+
+  const QString path = resolve_data_path("assets/data/troops/base.json");
+  if (path.isEmpty()) {
+    qCWarning(logger()) << "Failed to locate base troop catalog at"
+                        << "assets/data/troops/base.json";
+    return false;
+  }
+  if (!load_from_file(path)) {
+    return false;
+  }
+  g_catalog_loaded = true;
+  return true;
+}
+
+auto TroopCatalogLoader::load_from_file(const QString &path) -> bool {
+  QFile file(path);
+  if (!file.open(QIODevice::ReadOnly)) {
+    qCWarning(logger()) << "Unable to open troop catalog" << path << ":"
+                        << file.errorString();
+    return false;
+  }
+
+  const QByteArray data = file.readAll();
+  QJsonParseError parseError;
+  const QJsonDocument doc = QJsonDocument::fromJson(data, &parseError);
+  if (parseError.error != QJsonParseError::NoError) {
+    qCWarning(logger()) << "Failed to parse troop catalog" << path << ":"
+                        << parseError.errorString();
+    return false;
+  }
+
+  const QJsonObject root = doc.object();
+  const QJsonArray troops = ensure_array(root.value(k_troop_list_key));
+  if (troops.isEmpty()) {
+    qCWarning(logger()) << "Troop catalog" << path
+                        << "does not contain a 'troops' array";
+    return false;
+  }
+
+  auto &catalog = TroopCatalog::instance();
+  catalog.clear();
+
+  for (const QJsonValue &value : troops) {
+    const QJsonObject troop_obj = ensure_object(value);
+    const QString troop_id = troop_obj.value("id").toString();
+    if (troop_id.isEmpty()) {
+      qCWarning(logger()) << "Encountered troop without id in" << path;
+      continue;
+    }
+
+    const auto type_opt = tryParseTroopType(troop_id.toStdString());
+    if (!type_opt.has_value()) {
+      qCWarning(logger()) << "Unknown troop type" << troop_id << "in" << path;
+      continue;
+    }
+
+    TroopClass troop_class{};
+    troop_class.unit_type = *type_opt;
+    troop_class.display_name =
+        troop_obj.value("display_name").toString(troop_id).toStdString();
+
+    const QJsonObject production = ensure_object(troop_obj.value("production"));
+    troop_class.production.cost =
+        read_int(production, "cost", troop_class.production.cost);
+    troop_class.production.build_time =
+        read_float(production, "build_time", troop_class.production.build_time);
+    troop_class.production.priority =
+        read_int(production, "priority", troop_class.production.priority);
+    troop_class.production.is_melee =
+        read_bool(production, "is_melee", troop_class.production.is_melee);
+
+    const QJsonObject combat = ensure_object(troop_obj.value("combat"));
+    troop_class.combat.health =
+        read_int(combat, "health", troop_class.combat.health);
+    troop_class.combat.max_health =
+        read_int(combat, "max_health", troop_class.combat.max_health);
+    troop_class.combat.speed =
+        read_float(combat, "speed", troop_class.combat.speed);
+    troop_class.combat.vision_range =
+        read_float(combat, "vision_range", troop_class.combat.vision_range);
+    troop_class.combat.ranged_range =
+        read_float(combat, "ranged_range", troop_class.combat.ranged_range);
+    troop_class.combat.ranged_damage =
+        read_int(combat, "ranged_damage", troop_class.combat.ranged_damage);
+    troop_class.combat.ranged_cooldown = read_float(
+        combat, "ranged_cooldown", troop_class.combat.ranged_cooldown);
+    troop_class.combat.melee_range =
+        read_float(combat, "melee_range", troop_class.combat.melee_range);
+    troop_class.combat.melee_damage =
+        read_int(combat, "melee_damage", troop_class.combat.melee_damage);
+    troop_class.combat.melee_cooldown =
+        read_float(combat, "melee_cooldown", troop_class.combat.melee_cooldown);
+    troop_class.combat.can_ranged =
+        read_bool(combat, "can_ranged", troop_class.combat.can_ranged);
+    troop_class.combat.can_melee =
+        read_bool(combat, "can_melee", troop_class.combat.can_melee);
+
+    const QJsonObject visuals = ensure_object(troop_obj.value("visuals"));
+    troop_class.visuals.render_scale =
+        read_float(visuals, "render_scale", troop_class.visuals.render_scale);
+    troop_class.visuals.selection_ring_size =
+        read_float(visuals, "selection_ring_size",
+                   troop_class.visuals.selection_ring_size);
+    troop_class.visuals.selection_ring_y_offset =
+        read_float(visuals, "selection_ring_y_offset",
+                   troop_class.visuals.selection_ring_y_offset);
+    troop_class.visuals.selection_ring_ground_offset =
+        read_float(visuals, "selection_ring_ground_offset",
+                   troop_class.visuals.selection_ring_ground_offset);
+    const QString default_renderer = QStringLiteral("troops/") + troop_id;
+    troop_class.visuals.renderer_id =
+        visuals.value("renderer_id").toString(default_renderer).toStdString();
+
+    const QJsonObject formation = ensure_object(troop_obj.value("formation"));
+    troop_class.individuals_per_unit = read_int(
+        formation, "individuals_per_unit", troop_class.individuals_per_unit);
+    troop_class.max_units_per_row =
+        read_int(formation, "max_units_per_row", troop_class.max_units_per_row);
+
+    catalog.register_class(std::move(troop_class));
+  }
+
+  TroopConfig::instance().refresh_from_catalog();
+  return true;
+}
+
+} // namespace Game::Units

+ 14 - 0
game/units/troop_catalog_loader.h

@@ -0,0 +1,14 @@
+#pragma once
+
+#include <QString>
+
+namespace Game::Units {
+
+class TroopCatalogLoader {
+public:
+  static auto load_from_file(const QString &path) -> bool;
+  static auto load_default_catalog() -> bool;
+  static auto resolve_data_path(const QString &relative) -> QString;
+};
+
+} // namespace Game::Units

+ 24 - 24
game/units/troop_config.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "spawn_type.h"
+#include "troop_catalog.h"
 #include "troop_type.h"
 #include <string>
 #include <unordered_map>
@@ -135,31 +136,30 @@ public:
     m_selectionRingGroundOffset[unit_type] = offset;
   }
 
+  void refresh_from_catalog() { reload_from_catalog(); }
+
 private:
-  TroopConfig() {
-    m_individuals_per_unit[TroopType::Archer] = 20;
-    m_maxUnitsPerRow[TroopType::Archer] = 5;
-    m_selectionRingSize[TroopType::Archer] = 1.2F;
-    m_selectionRingYOffset[TroopType::Archer] = 0.0F;
-    m_selectionRingGroundOffset[TroopType::Archer] = 0.0F;
-
-    m_individuals_per_unit[TroopType::Knight] = 15;
-    m_maxUnitsPerRow[TroopType::Knight] = 5;
-    m_selectionRingSize[TroopType::Knight] = 1.1F;
-    m_selectionRingYOffset[TroopType::Knight] = 0.0F;
-    m_selectionRingGroundOffset[TroopType::Knight] = 0.0F;
-
-    m_individuals_per_unit[TroopType::Spearman] = 24;
-    m_maxUnitsPerRow[TroopType::Spearman] = 6;
-    m_selectionRingSize[TroopType::Spearman] = 1.4F;
-    m_selectionRingYOffset[TroopType::Spearman] = 0.0F;
-    m_selectionRingGroundOffset[TroopType::Spearman] = 0.0F;
-
-    m_individuals_per_unit[TroopType::MountedKnight] = 9;
-    m_maxUnitsPerRow[TroopType::MountedKnight] = 3;
-    m_selectionRingSize[TroopType::MountedKnight] = 2.0F;
-    m_selectionRingYOffset[TroopType::MountedKnight] = 0.0F;
-    m_selectionRingGroundOffset[TroopType::MountedKnight] = 1.35F;
+  TroopConfig() { reload_from_catalog(); }
+
+  void reload_from_catalog() {
+    m_individuals_per_unit.clear();
+    m_maxUnitsPerRow.clear();
+    m_selectionRingSize.clear();
+    m_selectionRingYOffset.clear();
+    m_selectionRingGroundOffset.clear();
+
+    const auto &classes = TroopCatalog::instance().get_all_classes();
+    for (const auto &entry : classes) {
+      const auto &troop_class = entry.second;
+      auto type = troop_class.unit_type;
+      m_individuals_per_unit[type] = troop_class.individuals_per_unit;
+      m_maxUnitsPerRow[type] = troop_class.max_units_per_row;
+      m_selectionRingSize[type] = troop_class.visuals.selection_ring_size;
+      m_selectionRingYOffset[type] =
+          troop_class.visuals.selection_ring_y_offset;
+      m_selectionRingGroundOffset[type] =
+          troop_class.visuals.selection_ring_ground_offset;
+    }
   }
 
   std::unordered_map<TroopType, int> m_individuals_per_unit;

+ 6 - 5
game/units/troop_type.h

@@ -9,14 +9,14 @@
 
 namespace Game::Units {
 
-enum class TroopType { Archer, Knight, Spearman, MountedKnight };
+enum class TroopType { Archer, Swordsman, Spearman, MountedKnight };
 
 inline auto troop_typeToQString(TroopType type) -> QString {
   switch (type) {
   case TroopType::Archer:
     return QStringLiteral("archer");
-  case TroopType::Knight:
-    return QStringLiteral("knight");
+  case TroopType::Swordsman:
+    return QStringLiteral("swordsman");
   case TroopType::Spearman:
     return QStringLiteral("spearman");
   case TroopType::MountedKnight:
@@ -35,8 +35,9 @@ inline auto tryParseTroopType(const QString &value, TroopType &out) -> bool {
     out = TroopType::Archer;
     return true;
   }
-  if (lowered == QStringLiteral("knight")) {
-    out = TroopType::Knight;
+  if (lowered == QStringLiteral("swordsman") ||
+      lowered == QStringLiteral("knight")) {
+    out = TroopType::Swordsman;
     return true;
   }
   if (lowered == QStringLiteral("spearman")) {

+ 19 - 0
game/units/unit.cpp

@@ -2,6 +2,7 @@
 
 #include "../core/component.h"
 #include "../core/world.h"
+#include "../systems/nation_registry.h"
 #include "units/troop_type.h"
 #include <qvectornd.h>
 #include <string>
@@ -19,6 +20,24 @@ auto Unit::entity() const -> Engine::Core::Entity * {
   return (m_world != nullptr) ? m_world->getEntity(m_id) : nullptr;
 }
 
+auto Unit::resolve_nation_id(const SpawnParams &params) -> std::string {
+  if (!params.nation_id.empty()) {
+    return params.nation_id;
+  }
+
+  auto &registry = Game::Systems::NationRegistry::instance();
+  if (const auto *nation = registry.getNationForPlayer(params.player_id)) {
+    return nation->id;
+  }
+
+  const auto &fallback_id = registry.default_nation_id();
+  if (const auto *nation = registry.getNation(fallback_id)) {
+    return nation->id;
+  }
+
+  return fallback_id;
+}
+
 void Unit::ensureCoreComponents() {
   if (m_world == nullptr) {
     return;

+ 3 - 0
game/units/unit.h

@@ -27,6 +27,7 @@ struct SpawnParams {
   SpawnType spawn_type = SpawnType::Archer;
   bool aiControlled = false;
   int maxPopulation = 100;
+  std::string nation_id;
 };
 
 class Unit {
@@ -52,6 +53,8 @@ protected:
 
   void ensureCoreComponents();
 
+  static auto resolve_nation_id(const SpawnParams &params) -> std::string;
+
   Engine::Core::World *m_world = nullptr;
   Engine::Core::EntityID m_id = 0;
   std::string m_type_string;

+ 24 - 6
render/CMakeLists.txt

@@ -31,11 +31,28 @@ add_library(render_gl STATIC
     ground/pine_renderer.cpp
     ground/firecamp_renderer.cpp
     entity/registry.cpp
-    entity/archer_renderer.cpp
-    entity/knight_renderer.cpp
-    entity/horse_renderer.cpp
-    entity/mounted_knight_renderer.cpp
-    entity/spearman_renderer.cpp
+    entity/nations/kingdom/archer_renderer.cpp
+    entity/nations/roman/archer_renderer.cpp
+    entity/nations/carthage/archer_renderer.cpp
+    entity/nations/kingdom/archer_style.cpp
+    entity/nations/roman/archer_style.cpp
+    entity/nations/carthage/archer_style.cpp
+    entity/nations/kingdom/spearman_style.cpp
+    entity/nations/roman/knight_renderer.cpp
+    entity/nations/kingdom/knight_renderer.cpp
+    entity/nations/carthage/knight_renderer.cpp
+    entity/nations/roman/knight_style.cpp
+    entity/nations/carthage/knight_style.cpp
+    entity/nations/kingdom/knight_style.cpp
+    entity/nations/roman/spearman_renderer.cpp
+    entity/nations/kingdom/spearman_renderer.cpp
+    entity/nations/carthage/spearman_renderer.cpp
+    entity/nations/roman/spearman_style.cpp
+    entity/nations/carthage/spearman_style.cpp
+    horse/rig.cpp
+    entity/nations/roman/mounted_knight_renderer.cpp
+    entity/nations/kingdom/mounted_knight_renderer.cpp
+    entity/nations/carthage/mounted_knight_renderer.cpp
     entity/barracks_renderer.cpp
     # entity/arrow.cpp removed; arrow VFX renderer code moved to geom/arrow.cpp
     geom/selection_ring.cpp
@@ -46,7 +63,8 @@ add_library(render_gl STATIC
     geom/transforms.cpp
     humanoid_math.cpp
     palette.cpp
-    humanoid_base.cpp
+    humanoid/rig.cpp
+    humanoid/style_palette.cpp
 )
 
 target_include_directories(render_gl PUBLIC .)

+ 0 - 9
render/entity/archer_renderer.h

@@ -1,9 +0,0 @@
-#pragma once
-
-#include "registry.h"
-
-namespace Render::GL {
-
-void registerArcherRenderer(EntityRendererRegistry &registry);
-
-}

+ 0 - 1
render/entity/arrow_vfx_renderer.cpp

@@ -9,7 +9,6 @@
 #include "../gl/primitives.h"
 #include "../gl/texture.h"
 #include "../humanoid_math.h"
-#include "archer_renderer.h"
 #include "registry.h"
 
 #include <QMatrix4x4>

+ 3 - 70
render/entity/horse_renderer.h

@@ -1,79 +1,12 @@
 #pragma once
 
-#include <QVector3D>
-#include <cstdint>
+#include "../horse/rig.h"
 
 namespace Render::GL {
 
-struct DrawContext;
-struct AnimationInputs;
-class ISubmitter;
-
-struct HorseDimensions {
-  float bodyLength;
-  float bodyWidth;
-  float bodyHeight;
-  float barrel_centerY;
-
-  float neckLength;
-  float neckRise;
-
-  float headLength;
-  float headWidth;
-  float headHeight;
-  float muzzleLength;
-
-  float legLength;
-  float hoofHeight;
-
-  float tailLength;
-
-  float saddle_height;
-  float saddleThickness;
-  float seatForwardOffset;
-
-  float stirrupDrop;
-  float stirrupOut;
-
-  float idleBobAmplitude;
-  float moveBobAmplitude;
-};
-
-struct HorseVariant {
-  QVector3D coatColor;
-  QVector3D mane_color;
-  QVector3D tail_color;
-  QVector3D muzzleColor;
-  QVector3D hoof_color;
-  QVector3D saddleColor;
-  QVector3D blanketColor;
-  QVector3D tack_color;
-};
-
-struct HorseGait {
-  float cycleTime;
-  float frontLegPhase;
-  float rearLegPhase;
-  float strideSwing;
-  float strideLift;
-};
-
-struct HorseProfile {
-  HorseDimensions dims{};
-  HorseVariant variant;
-  HorseGait gait{};
-};
-
-auto makeHorseDimensions(uint32_t seed) -> HorseDimensions;
-auto makeHorseVariant(uint32_t seed, const QVector3D &leatherBase,
-                      const QVector3D &clothBase) -> HorseVariant;
-auto makeHorseProfile(uint32_t seed, const QVector3D &leatherBase,
-                      const QVector3D &clothBase) -> HorseProfile;
-
-class HorseRenderer {
+class HorseRenderer : public HorseRendererBase {
 public:
-  static void render(const DrawContext &ctx, const AnimationInputs &anim,
-                     const HorseProfile &profile, ISubmitter &out);
+  using HorseRendererBase::render;
 };
 
 } // namespace Render::GL

+ 0 - 9
render/entity/knight_renderer.h

@@ -1,9 +0,0 @@
-#pragma once
-
-#include "registry.h"
-
-namespace Render::GL {
-
-void registerKnightRenderer(EntityRendererRegistry &registry);
-
-}

+ 824 - 0
render/entity/nations/carthage/archer_renderer.cpp

@@ -0,0 +1,824 @@
+#include "archer_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../../game/core/entity.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/render_constants.h"
+#include "../../../gl/shader.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
+#include "../../../humanoid_math.h"
+#include "../../../humanoid_specs.h"
+#include "../../../palette.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../registry.h"
+#include "../../renderer_constants.h"
+#include "archer_style.h"
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+#include <numbers>
+#include <optional>
+#include <qmatrix4x4.h>
+#include <qstringliteral.h>
+#include <qvectornd.h>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+namespace Render::GL::Carthage {
+
+namespace {
+
+constexpr std::string_view k_default_style_key = "default";
+constexpr std::string_view k_attachment_headwrap = "carthage_headwrap";
+
+auto style_registry() -> std::unordered_map<std::string, ArcherStyleConfig> & {
+  static std::unordered_map<std::string, ArcherStyleConfig> styles;
+  return styles;
+}
+
+void ensure_archer_styles_registered() {
+  static const bool registered = []() {
+    register_carthage_archer_style();
+    return true;
+  }();
+  (void)registered;
+}
+
+constexpr float k_team_mix_weight = 0.65F;
+constexpr float k_style_mix_weight = 0.35F;
+
+} // namespace
+
+void register_archer_style(const std::string &nation_id,
+                           const ArcherStyleConfig &style) {
+  style_registry()[nation_id] = style;
+}
+
+using Render::Geom::clamp01;
+using Render::Geom::clampf;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::sphereAt;
+using Render::GL::Humanoid::mix_palette_color;
+using Render::GL::Humanoid::saturate_color;
+
+struct ArcherExtras {
+  QVector3D stringCol;
+  QVector3D fletch;
+  QVector3D metalHead;
+  float bowRodR = 0.035F;
+  float stringR = 0.008F;
+  float bowDepth = 0.25F;
+  float bowX = 0.0F;
+  float bowTopY{};
+  float bowBotY{};
+};
+
+class ArcherRenderer : public HumanoidRendererBase {
+public:
+  auto getProportionScaling() const -> QVector3D override {
+
+    return {0.94F, 1.01F, 0.96F};
+  }
+
+  void getVariant(const DrawContext &ctx, uint32_t seed,
+                  HumanoidVariant &v) const override {
+    QVector3D const team_tint = resolveTeamTint(ctx);
+    v.palette = makeHumanoidPalette(team_tint, seed);
+    auto const &style = resolve_style(ctx);
+    apply_palette_overrides(style, team_tint, v);
+  }
+
+  void customizePose(const DrawContext &,
+                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                     HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    const AnimationInputs &anim = anim_ctx.inputs;
+
+    float const arm_height_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.03F;
+    float const arm_asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
+
+    float const bow_x = 0.0F;
+
+    if (anim.isInHoldMode || anim.isExitingHold) {
+
+      float const t = anim.isInHoldMode ? 1.0F : (1.0F - anim.holdExitProgress);
+
+      float const kneel_depth = 0.45F * t;
+
+      float const pelvis_y = HP::WAIST_Y - kneel_depth;
+      pose.pelvisPos.setY(pelvis_y);
+
+      float const stance_narrow = 0.12F;
+
+      float const left_knee_y = HP::GROUND_Y + 0.08F * t;
+      float const left_knee_z = -0.05F * t;
+
+      pose.knee_l = QVector3D(-stance_narrow, left_knee_y, left_knee_z);
+
+      pose.footL = QVector3D(-stance_narrow - 0.03F, HP::GROUND_Y,
+                             left_knee_z - HP::LOWER_LEG_LEN * 0.95F * t);
+
+      float const right_foot_z = 0.30F * t;
+      pose.foot_r = QVector3D(stance_narrow, HP::GROUND_Y + pose.footYOffset,
+                              right_foot_z);
+
+      float const right_knee_y = pelvis_y - 0.10F;
+      float const right_knee_z = right_foot_z - 0.05F;
+
+      pose.knee_r = QVector3D(stance_narrow, right_knee_y, right_knee_z);
+
+      float const upper_body_drop = kneel_depth;
+
+      pose.shoulderL.setY(HP::SHOULDER_Y - upper_body_drop);
+      pose.shoulderR.setY(HP::SHOULDER_Y - upper_body_drop);
+      pose.neck_base.setY(HP::NECK_BASE_Y - upper_body_drop);
+      pose.headPos.setY((HP::HEAD_TOP_Y + HP::CHIN_Y) * 0.5F - upper_body_drop);
+
+      float const forward_lean = 0.10F * t;
+      pose.shoulderL.setZ(pose.shoulderL.z() + forward_lean);
+      pose.shoulderR.setZ(pose.shoulderR.z() + forward_lean);
+      pose.neck_base.setZ(pose.neck_base.z() + forward_lean * 0.8F);
+      pose.headPos.setZ(pose.headPos.z() + forward_lean * 0.7F);
+
+      QVector3D const hold_hand_l(bow_x - 0.15F, pose.shoulderL.y() + 0.30F,
+                                  0.55F);
+      QVector3D const hold_hand_r(bow_x + 0.12F, pose.shoulderR.y() + 0.15F,
+                                  0.10F);
+      QVector3D const normal_hand_l(bow_x - 0.05F + arm_asymmetry,
+                                    HP::SHOULDER_Y + 0.05F + arm_height_jitter,
+                                    0.55F);
+      QVector3D const normal_hand_r(
+          0.15F - arm_asymmetry * 0.5F,
+          HP::SHOULDER_Y + 0.15F + arm_height_jitter * 0.8F, 0.20F);
+
+      pose.handL = normal_hand_l * (1.0F - t) + hold_hand_l * t;
+      pose.hand_r = normal_hand_r * (1.0F - t) + hold_hand_r * t;
+    } else {
+      pose.handL = QVector3D(bow_x - 0.05F + arm_asymmetry,
+                             HP::SHOULDER_Y + 0.05F + arm_height_jitter, 0.55F);
+      pose.hand_r =
+          QVector3D(0.15F - arm_asymmetry * 0.5F,
+                    HP::SHOULDER_Y + 0.15F + arm_height_jitter * 0.8F, 0.20F);
+    }
+
+    if (anim.is_attacking && !anim.isInHoldMode) {
+      float const attack_phase =
+          std::fmod(anim.time * ARCHER_INV_ATTACK_CYCLE_TIME, 1.0F);
+
+      if (anim.isMelee) {
+        QVector3D const rest_pos(0.25F, HP::SHOULDER_Y, 0.10F);
+        QVector3D const raised_pos(0.30F, HP::HEAD_TOP_Y + 0.2F, -0.05F);
+        QVector3D const strike_pos(0.35F, HP::WAIST_Y, 0.45F);
+
+        if (attack_phase < 0.25F) {
+          float t = attack_phase / 0.25F;
+          t = t * t;
+          pose.hand_r = rest_pos * (1.0F - t) + raised_pos * t;
+          pose.handL = QVector3D(-0.15F, HP::SHOULDER_Y - 0.1F * t, 0.20F);
+        } else if (attack_phase < 0.35F) {
+          pose.hand_r = raised_pos;
+          pose.handL = QVector3D(-0.15F, HP::SHOULDER_Y - 0.1F, 0.20F);
+        } else if (attack_phase < 0.55F) {
+          float t = (attack_phase - 0.35F) / 0.2F;
+          t = t * t * t;
+          pose.hand_r = raised_pos * (1.0F - t) + strike_pos * t;
+          pose.handL =
+              QVector3D(-0.15F, HP::SHOULDER_Y - 0.1F * (1.0F - t * 0.5F),
+                        0.20F + 0.15F * t);
+        } else {
+          float t = (attack_phase - 0.55F) / 0.45F;
+          t = 1.0F - (1.0F - t) * (1.0F - t);
+          pose.hand_r = strike_pos * (1.0F - t) + rest_pos * t;
+          pose.handL = QVector3D(-0.15F, HP::SHOULDER_Y - 0.05F * (1.0F - t),
+                                 0.35F * (1.0F - t) + 0.20F * t);
+        }
+      } else {
+        QVector3D const aim_pos(0.18F, HP::SHOULDER_Y + 0.18F, 0.35F);
+        QVector3D const draw_pos(0.22F, HP::SHOULDER_Y + 0.10F, -0.30F);
+        QVector3D const release_pos(0.18F, HP::SHOULDER_Y + 0.20F, 0.10F);
+
+        if (attack_phase < 0.20F) {
+          float t = attack_phase / 0.20F;
+          t = t * t;
+          pose.hand_r = aim_pos * (1.0F - t) + draw_pos * t;
+          pose.handL = QVector3D(bow_x - 0.05F, HP::SHOULDER_Y + 0.05F, 0.55F);
+
+          float const shoulder_twist = t * 0.08F;
+          pose.shoulderR.setY(pose.shoulderR.y() + shoulder_twist);
+          pose.shoulderL.setY(pose.shoulderL.y() - shoulder_twist * 0.5F);
+        } else if (attack_phase < 0.50F) {
+          pose.hand_r = draw_pos;
+          pose.handL = QVector3D(bow_x - 0.05F, HP::SHOULDER_Y + 0.05F, 0.55F);
+
+          float const shoulder_twist = 0.08F;
+          pose.shoulderR.setY(pose.shoulderR.y() + shoulder_twist);
+          pose.shoulderL.setY(pose.shoulderL.y() - shoulder_twist * 0.5F);
+        } else if (attack_phase < 0.58F) {
+          float t = (attack_phase - 0.50F) / 0.08F;
+          t = t * t * t;
+          pose.hand_r = draw_pos * (1.0F - t) + release_pos * t;
+          pose.handL = QVector3D(bow_x - 0.05F, HP::SHOULDER_Y + 0.05F, 0.55F);
+
+          float const shoulder_twist = 0.08F * (1.0F - t * 0.6F);
+          pose.shoulderR.setY(pose.shoulderR.y() + shoulder_twist);
+          pose.shoulderL.setY(pose.shoulderL.y() - shoulder_twist * 0.5F);
+
+          pose.headPos.setZ(pose.headPos.z() - t * 0.04F);
+        } else {
+          float t = (attack_phase - 0.58F) / 0.42F;
+          t = 1.0F - (1.0F - t) * (1.0F - t);
+          pose.hand_r = release_pos * (1.0F - t) + aim_pos * t;
+          pose.handL = QVector3D(bow_x - 0.05F, HP::SHOULDER_Y + 0.05F, 0.55F);
+
+          float const shoulder_twist = 0.08F * 0.4F * (1.0F - t);
+          pose.shoulderR.setY(pose.shoulderR.y() + shoulder_twist);
+          pose.shoulderL.setY(pose.shoulderL.y() - shoulder_twist * 0.5F);
+
+          pose.headPos.setZ(pose.headPos.z() - 0.04F * (1.0F - t));
+        }
+      }
+    }
+
+    QVector3D right_axis = pose.shoulderR - pose.shoulderL;
+    right_axis.setY(0.0F);
+    if (right_axis.lengthSquared() < 1e-8F) {
+      right_axis = QVector3D(1, 0, 0);
+    }
+    right_axis.normalize();
+    QVector3D const outward_l = -right_axis;
+    QVector3D const outward_r = right_axis;
+
+    pose.elbowL = elbowBendTorso(pose.shoulderL, pose.handL, outward_l, 0.45F,
+                                 0.15F, -0.08F, +1.0F);
+    pose.elbowR = elbowBendTorso(pose.shoulderR, pose.hand_r, outward_r, 0.48F,
+                                 0.12F, 0.02F, +1.0F);
+  }
+
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose,
+                      const HumanoidAnimationContext &anim_ctx,
+                      ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto const &style = resolve_style(ctx);
+    const AnimationInputs &anim = anim_ctx.inputs;
+    QVector3D team_tint = resolveTeamTint(ctx);
+    uint32_t seed = 0U;
+    if (ctx.entity != nullptr) {
+      auto *unit = ctx.entity->getComponent<Engine::Core::UnitComponent>();
+      if (unit != nullptr) {
+        seed ^= uint32_t(unit->owner_id * 2654435761U);
+      }
+      seed ^= uint32_t(reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU);
+    }
+
+    ArcherExtras extras;
+    auto it = m_extrasCache.find(seed);
+    if (it != m_extrasCache.end()) {
+      extras = it->second;
+    } else {
+      extras.metalHead = Render::Geom::clampVec01(v.palette.metal * 1.15F);
+      extras.stringCol = QVector3D(0.30F, 0.30F, 0.32F);
+      auto tint = [&](float k) {
+        return QVector3D(clamp01(team_tint.x() * k), clamp01(team_tint.y() * k),
+                         clamp01(team_tint.z() * k));
+      };
+      extras.fletch = tint(0.9F);
+      extras.bowTopY = HP::SHOULDER_Y + 0.55F;
+      extras.bowBotY = HP::WAIST_Y - 0.25F;
+
+      apply_extras_overrides(style, extras);
+      m_extrasCache[seed] = extras;
+
+      if (m_extrasCache.size() > MAX_EXTRAS_CACHE_SIZE) {
+        m_extrasCache.clear();
+      }
+    }
+    apply_extras_overrides(style, extras);
+
+    drawQuiver(ctx, v, pose, extras, seed, out);
+
+    float attack_phase = 0.0F;
+    if (anim.is_attacking && !anim.isMelee) {
+      attack_phase = std::fmod(anim.time * ARCHER_INV_ATTACK_CYCLE_TIME, 1.0F);
+    }
+    drawBowAndArrow(ctx, pose, v, extras, anim.is_attacking && !anim.isMelee,
+                    attack_phase, out);
+  }
+
+  void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose, ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto const &style = resolve_style(ctx);
+    if (!style.show_helmet) {
+      if (style.attachment_profile == std::string(k_attachment_headwrap)) {
+        draw_headwrap(ctx, v, pose, out);
+      }
+      return;
+    }
+
+    auto draw_montefortino = [&](const QVector3D &base_color) {
+      QVector3D bronze =
+          saturate_color(base_color * QVector3D(1.14F, 1.00F, 0.76F));
+      QVector3D tinned_highlight =
+          saturate_color(base_color * QVector3D(1.38F, 1.36F, 1.44F));
+      QVector3D patina =
+          saturate_color(base_color * QVector3D(0.92F, 1.05F, 1.04F));
+      QVector3D leather_band =
+          saturate_color(v.palette.leather * QVector3D(1.12F, 0.98F, 0.84F));
+
+      float const head_r = pose.headR;
+      QVector3D const bowl_center(0.0F, pose.headPos.y() + head_r * 0.72F,
+                                  0.0F);
+
+      QMatrix4x4 bowl = ctx.model;
+      bowl.translate(bowl_center);
+      bowl.scale(head_r * 1.08F, head_r * 1.24F, head_r * 1.02F);
+      bowl.rotate(-5.0F, 1.0F, 0.0F, 0.0F);
+      out.mesh(getUnitSphere(), bowl, bronze, nullptr, 1.0F);
+
+      QMatrix4x4 ridge = ctx.model;
+      ridge.translate(QVector3D(0.0F, pose.headPos.y() + head_r * 1.00F, 0.0F));
+      ridge.scale(head_r * 0.20F, head_r * 0.48F, head_r * 0.20F);
+      out.mesh(getUnitCone(), ridge, patina, nullptr, 1.0F);
+
+      QMatrix4x4 knob = ctx.model;
+      knob.translate(QVector3D(0.0F, pose.headPos.y() + head_r * 1.38F, 0.0F));
+      knob.scale(head_r * 0.22F, head_r * 0.32F, head_r * 0.22F);
+      out.mesh(getUnitSphere(), knob, tinned_highlight, nullptr, 1.0F);
+
+      QVector3D const brow_top(0.0F, pose.headPos.y() + head_r * 0.58F, 0.0F);
+      QVector3D const brow_bottom(0.0F, pose.headPos.y() + head_r * 0.46F,
+                                  0.0F);
+      QMatrix4x4 brow =
+          cylinderBetween(ctx.model, brow_bottom, brow_top, head_r * 1.20F);
+      brow.scale(1.04F, 1.0F, 0.86F);
+      out.mesh(getUnitCylinder(), brow, leather_band, nullptr, 1.0F);
+
+      QVector3D const rim_upper(0.0F, pose.headPos.y() + head_r * 0.44F, 0.0F);
+      QVector3D const rim_lower(0.0F, pose.headPos.y() + head_r * 0.34F, 0.0F);
+      QMatrix4x4 rim =
+          cylinderBetween(ctx.model, rim_lower, rim_upper, head_r * 1.30F);
+      rim.scale(1.06F, 1.0F, 0.90F);
+      out.mesh(getUnitCylinder(), rim, bronze * QVector3D(0.94F, 0.92F, 0.88F),
+               nullptr, 1.0F);
+
+      QVector3D const neck_base(0.0F, pose.headPos.y() + head_r * 0.32F,
+                                -head_r * 0.68F);
+      QVector3D const neck_drop(0.0F, pose.headPos.y() - head_r * 0.48F,
+                                -head_r * 0.96F);
+      QMatrix4x4 neck =
+          coneFromTo(ctx.model, neck_drop, neck_base, head_r * 1.34F);
+      neck.scale(1.0F, 1.0F, 0.94F);
+      out.mesh(getUnitCone(), neck, bronze * 0.96F, nullptr, 1.0F);
+
+      auto cheek_plate = [&](float sign) {
+        QVector3D const hinge(sign * head_r * 0.80F,
+                              pose.headPos.y() + head_r * 0.38F,
+                              head_r * 0.38F);
+        QVector3D const lobe =
+            hinge + QVector3D(sign * head_r * 0.20F, -head_r * 0.82F, 0.02F);
+        QMatrix4x4 cheek = coneFromTo(ctx.model, lobe, hinge, head_r * 0.46F);
+        cheek.scale(0.60F, 1.0F, 0.42F);
+        out.mesh(getUnitCone(), cheek, patina, nullptr, 1.0F);
+      };
+      cheek_plate(+1.0F);
+      cheek_plate(-1.0F);
+
+      QVector3D const crest_front(0.0F, pose.headPos.y() + head_r * 0.96F,
+                                  head_r * 0.82F);
+      QVector3D const crest_back(0.0F, pose.headPos.y() + head_r * 0.96F,
+                                 -head_r * 0.90F);
+      QMatrix4x4 crest =
+          cylinderBetween(ctx.model, crest_back, crest_front, head_r * 0.14F);
+      crest.scale(0.54F, 1.0F, 1.0F);
+      out.mesh(getUnitCylinder(), crest,
+               tinned_highlight * QVector3D(0.94F, 0.96F, 1.02F), nullptr,
+               1.0F);
+    };
+
+    draw_montefortino(v.palette.metal);
+  }
+
+  void draw_armorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, float y_top_cover,
+                         float torso_r, float, float, const QVector3D &,
+                         ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto const &style = resolve_style(ctx);
+    if (!style.show_armor) {
+      return;
+    }
+
+    float const waist_y = pose.pelvisPos.y();
+    float const cuirass_top = y_top_cover + 0.02F;
+    float const cuirass_bottom = waist_y - 0.10F;
+
+    QVector3D linen =
+        saturate_color(v.palette.cloth * QVector3D(1.12F, 1.04F, 0.88F));
+    QVector3D bronze =
+        saturate_color(v.palette.metal * QVector3D(1.18F, 1.02F, 0.74F));
+    QVector3D bronze_shadow =
+        saturate_color(bronze * QVector3D(0.90F, 0.94F, 0.98F));
+    QVector3D tinned =
+        saturate_color(v.palette.metal * QVector3D(1.36F, 1.36F, 1.42F));
+    QVector3D leather =
+        saturate_color(v.palette.leatherDark * QVector3D(1.06F, 0.98F, 0.84F));
+
+    QVector3D const tunic_top(0.0F, cuirass_top + 0.04F, 0.0F);
+    QVector3D const tunic_bot(0.0F, cuirass_bottom - 0.26F, 0.0F);
+    QMatrix4x4 tunic =
+        cylinderBetween(ctx.model, tunic_bot, tunic_top, torso_r * 0.94F);
+    tunic.scale(1.02F, 1.0F, 0.90F);
+    out.mesh(getUnitCylinder(), tunic, linen, nullptr, 1.0F);
+
+    constexpr int k_scale_rows = 6;
+    constexpr float k_tile_height = 0.085F;
+    constexpr float k_tile_width = 0.11F;
+    constexpr float k_tile_thickness = 0.020F;
+    constexpr float k_row_overlap = 0.032F;
+    constexpr float k_radial_push = 0.010F;
+
+    auto draw_scale_tile = [&](const QVector3D &center, float yaw,
+                               const QVector3D &color, float height,
+                               float width_scale) {
+      QVector3D const top = center + QVector3D(0.0F, height * 0.5F, 0.0F);
+      QVector3D const bot = center - QVector3D(0.0F, height * 0.5F, 0.0F);
+      float const radius = (k_tile_width * width_scale) * 0.5F;
+      QMatrix4x4 plate = cylinderBetween(ctx.model, bot, top, radius);
+      float const yaw_deg = yaw * (180.0F / std::numbers::pi_v<float>);
+      plate.rotate(yaw_deg, 0.0F, 1.0F, 0.0F);
+      plate.scale(0.92F, 1.0F,
+                  (k_tile_thickness / (k_tile_width * width_scale)));
+      out.mesh(getUnitCylinder(18), plate, color, nullptr, 1.0F);
+
+      QVector3D const lip_top = bot + QVector3D(0.0F, height * 0.22F, 0.0F);
+      QMatrix4x4 lip = cylinderBetween(ctx.model, bot, lip_top, radius * 0.92F);
+      lip.rotate(yaw_deg, 0.0F, 1.0F, 0.0F);
+      lip.scale(0.88F, 1.0F,
+                (k_tile_thickness / (k_tile_width * width_scale)) * 0.72F);
+      out.mesh(getUnitCylinder(16), lip, color * QVector3D(0.90F, 0.92F, 0.96F),
+               nullptr, 1.0F);
+    };
+
+    auto emit_scale_band = [&](float center_angle, float span, int columns,
+                               float radius_scale) {
+      float const start = center_angle - span * 0.5F;
+      float const step = columns > 1 ? span / float(columns - 1) : 0.0F;
+
+      for (int row = 0; row < k_scale_rows; ++row) {
+        float const row_y = cuirass_top - row * (k_tile_height - k_row_overlap);
+        float const layer_radius =
+            torso_r * radius_scale + row * (k_radial_push * radius_scale);
+        for (int col = 0; col < columns; ++col) {
+          float const angle = start + step * col;
+          QVector3D const radial(std::sin(angle), 0.0F, std::cos(angle));
+          QVector3D center(radial.x() * layer_radius, row_y,
+                           radial.z() * layer_radius);
+          center += radial * (row * 0.006F);
+          float const yaw = std::atan2(radial.x(), radial.z());
+          bool const tinned_tile = ((row + col) % 4) == 0;
+          QVector3D color =
+              tinned_tile ? tinned : (row % 2 == 0 ? bronze : bronze_shadow);
+          float const width_scale =
+              1.0F - 0.04F * std::abs((columns - 1) * 0.5F - col);
+          draw_scale_tile(center, yaw, color, k_tile_height,
+                          std::clamp(width_scale, 0.78F, 1.05F));
+        }
+      }
+    };
+
+    emit_scale_band(0.0F, 1.30F, 7, 1.08F);
+    emit_scale_band(std::numbers::pi_v<float>, 1.20F, 7, 1.04F);
+    emit_scale_band(std::numbers::pi_v<float> * 0.5F, 0.82F, 5, 1.02F);
+    emit_scale_band(-std::numbers::pi_v<float> * 0.5F, 0.82F, 5, 1.02F);
+
+    QVector3D const collar_top(0.0F, cuirass_top + 0.020F, 0.0F);
+    QVector3D const collar_bot(0.0F, cuirass_top - 0.010F, 0.0F);
+    QMatrix4x4 collar = cylinderBetween(ctx.model, collar_bot, collar_top,
+                                        HP::NECK_RADIUS * 1.90F);
+    collar.scale(1.04F, 1.0F, 0.90F);
+    out.mesh(getUnitCylinder(), collar, leather, nullptr, 1.0F);
+
+    QVector3D const waist_top(0.0F, waist_y + 0.05F, 0.0F);
+    QVector3D const waist_bot(0.0F, waist_y - 0.02F, 0.0F);
+    QMatrix4x4 waist =
+        cylinderBetween(ctx.model, waist_bot, waist_top, torso_r * 1.16F);
+    waist.scale(1.06F, 1.0F, 0.90F);
+    out.mesh(getUnitCylinder(), waist, leather, nullptr, 1.0F);
+
+    auto draw_pteruge = [&](float angle, float length) {
+      QVector3D const radial(std::sin(angle), 0.0F, std::cos(angle));
+      QVector3D const base =
+          QVector3D(radial.x() * torso_r * 1.18F, waist_y - 0.03F,
+                    radial.z() * torso_r * 1.18F);
+      QVector3D const tip =
+          base + QVector3D(radial.x() * 0.02F, -length, radial.z() * 0.02F);
+      QMatrix4x4 strap =
+          cylinderBetween(ctx.model, tip, base, k_tile_width * 0.18F);
+      float const yaw = std::atan2(radial.x(), radial.z());
+      strap.rotate(yaw * (180.0F / std::numbers::pi_v<float>), 0.0F, 1.0F,
+                   0.0F);
+      strap.scale(0.65F, 1.0F, 0.40F);
+      out.mesh(getUnitCylinder(12), strap,
+               leather * QVector3D(0.90F, 0.92F, 0.96F), nullptr, 1.0F);
+    };
+
+    constexpr int k_pteruge_count = 12;
+    for (int i = 0; i < k_pteruge_count; ++i) {
+      float const angle = (static_cast<float>(i) / k_pteruge_count) * 2.0F *
+                          std::numbers::pi_v<float>;
+      draw_pteruge(angle, 0.18F);
+    }
+  }
+
+  void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
+                               const HumanoidPose &pose, float, float y_neck,
+                               const QVector3D &,
+                               ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto const &style = resolve_style(ctx);
+    if (!style.show_shoulder_decor && !style.show_cape) {
+      return;
+    }
+
+    QVector3D brass_color = v.palette.metal * QVector3D(1.2F, 1.0F, 0.65F);
+
+    auto draw_phalera = [&](const QVector3D &pos) {
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.025F);
+      out.mesh(getUnitSphere(), m, brass_color, nullptr, 1.0F);
+    };
+
+    if (style.show_shoulder_decor) {
+      draw_phalera(pose.shoulderL + QVector3D(0, 0.05F, 0.02F));
+      draw_phalera(pose.shoulderR + QVector3D(0, 0.05F, 0.02F));
+    }
+
+    if (!style.show_cape) {
+      return;
+    }
+
+    QVector3D const clasp_pos(0, y_neck + 0.02F, 0.08F);
+    QMatrix4x4 clasp_m = ctx.model;
+    clasp_m.translate(clasp_pos);
+    clasp_m.scale(0.020F);
+    out.mesh(getUnitSphere(), clasp_m, brass_color * 1.1F, nullptr, 1.0F);
+
+    QVector3D const cape_top = clasp_pos + QVector3D(0, -0.02F, -0.05F);
+    QVector3D const cape_bot = clasp_pos + QVector3D(0, -0.25F, -0.15F);
+    QVector3D cape_fabric = v.palette.cloth * QVector3D(1.2F, 0.3F, 0.3F);
+    if (style.cape_color) {
+      cape_fabric = saturate_color(*style.cape_color);
+    }
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, cape_top, cape_bot, 0.025F),
+             cape_fabric * 0.85F, nullptr, 1.0F);
+  }
+
+private:
+  mutable std::unordered_map<uint32_t, ArcherExtras> m_extrasCache;
+
+  auto
+  resolve_style(const DrawContext &ctx) const -> const ArcherStyleConfig & {
+    ensure_archer_styles_registered();
+    auto &styles = style_registry();
+    std::string nation_id;
+    if (ctx.entity != nullptr) {
+      if (auto *unit =
+              ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+        nation_id = unit->nation_id;
+      }
+    }
+    if (!nation_id.empty()) {
+      auto it = styles.find(nation_id);
+      if (it != styles.end()) {
+        return it->second;
+      }
+    }
+    auto fallback = styles.find(std::string(k_default_style_key));
+    if (fallback != styles.end()) {
+      return fallback->second;
+    }
+    static const ArcherStyleConfig default_style{};
+    return default_style;
+  }
+
+public:
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    const ArcherStyleConfig &style = resolve_style(ctx);
+    if (!style.shader_id.empty()) {
+      return QString::fromStdString(style.shader_id);
+    }
+    return QStringLiteral("archer");
+  }
+
+private:
+  void apply_palette_overrides(const ArcherStyleConfig &style,
+                               const QVector3D &team_tint,
+                               HumanoidVariant &variant) const {
+    auto apply_color = [&](const std::optional<QVector3D> &override_color,
+                           QVector3D &target) {
+      target = mix_palette_color(target, override_color, team_tint,
+                                 k_team_mix_weight, k_style_mix_weight);
+    };
+
+    apply_color(style.cloth_color, variant.palette.cloth);
+    apply_color(style.leather_color, variant.palette.leather);
+    apply_color(style.leather_dark_color, variant.palette.leatherDark);
+    apply_color(style.metal_color, variant.palette.metal);
+    apply_color(style.wood_color, variant.palette.wood);
+  }
+
+  void apply_extras_overrides(const ArcherStyleConfig &style,
+                              ArcherExtras &extras) const {
+    if (style.fletching_color) {
+      extras.fletch = saturate_color(*style.fletching_color);
+    }
+    if (style.bow_string_color) {
+      extras.stringCol = saturate_color(*style.bow_string_color);
+    }
+  }
+
+  void draw_headwrap(const DrawContext &ctx, const HumanoidVariant &v,
+                     const HumanoidPose &pose, ISubmitter &out) const {
+    QVector3D const cloth_color =
+        saturate_color(v.palette.cloth * QVector3D(0.9F, 1.05F, 1.05F));
+    float const head_r = pose.headR;
+
+    QVector3D const band_top(0, pose.headPos.y() + head_r * 0.70F, 0);
+    QVector3D const band_bot(0, pose.headPos.y() + head_r * 0.30F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, band_bot, band_top, head_r * 1.08F),
+             cloth_color, nullptr, 1.0F);
+
+    QVector3D const knot_center(0.10F, pose.headPos.y() + head_r * 0.60F,
+                                head_r * 0.72F);
+    QMatrix4x4 knot_m = ctx.model;
+    knot_m.translate(knot_center);
+    knot_m.scale(head_r * 0.32F);
+    out.mesh(getUnitSphere(), knot_m, cloth_color * 1.05F, nullptr, 1.0F);
+
+    QVector3D const tail_top = knot_center + QVector3D(-0.08F, -0.05F, -0.06F);
+    QVector3D const tail_bot = tail_top + QVector3D(0.02F, -0.28F, -0.08F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, tail_top, tail_bot, head_r * 0.28F),
+             cloth_color * QVector3D(0.92F, 0.98F, 1.05F), nullptr, 1.0F);
+  }
+
+  static void drawQuiver(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, const ArcherExtras &extras,
+                         uint32_t seed, ISubmitter &out) {
+    using HP = HumanProportions;
+
+    QVector3D const spine_mid = (pose.shoulderL + pose.shoulderR) * 0.5F;
+    QVector3D const quiver_offset(-0.08F, 0.10F, -0.25F);
+    QVector3D const q_top = spine_mid + quiver_offset;
+    QVector3D const q_base = q_top + QVector3D(-0.02F, -0.30F, 0.03F);
+
+    float const quiver_r = HP::HEAD_RADIUS * 0.45F;
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, q_base, q_top, quiver_r),
+             v.palette.leather, nullptr, 1.0F);
+
+    float const j = (hash_01(seed) - 0.5F) * 0.04F;
+    float const k =
+        (hash_01(seed ^ HashXorShift::k_golden_ratio) - 0.5F) * 0.04F;
+
+    QVector3D const a1 = q_top + QVector3D(0.00F + j, 0.08F, 0.00F + k);
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, q_top, a1, 0.010F),
+             v.palette.wood, nullptr, 1.0F);
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, a1, a1 + QVector3D(0, 0.05F, 0), 0.025F),
+             extras.fletch, nullptr, 1.0F);
+
+    QVector3D const a2 = q_top + QVector3D(0.02F - j, 0.07F, 0.02F - k);
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, q_top, a2, 0.010F),
+             v.palette.wood, nullptr, 1.0F);
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, a2, a2 + QVector3D(0, 0.05F, 0), 0.025F),
+             extras.fletch, nullptr, 1.0F);
+  }
+
+  static void drawBowAndArrow(const DrawContext &ctx, const HumanoidPose &pose,
+                              const HumanoidVariant &v,
+                              const ArcherExtras &extras, bool is_attacking,
+                              float attack_phase, ISubmitter &out) {
+    const QVector3D up(0.0F, 1.0F, 0.0F);
+    const QVector3D forward(0.0F, 0.0F, 1.0F);
+
+    QVector3D const grip = pose.handL;
+
+    float const bow_plane_z = 0.45F;
+    QVector3D const top_end(extras.bowX, extras.bowTopY, bow_plane_z);
+    QVector3D const bot_end(extras.bowX, extras.bowBotY, bow_plane_z);
+
+    QVector3D const nock(
+        extras.bowX,
+        clampf(pose.hand_r.y(), extras.bowBotY + 0.05F, extras.bowTopY - 0.05F),
+        clampf(pose.hand_r.z(), bow_plane_z - 0.30F, bow_plane_z + 0.30F));
+
+    constexpr int k_bowstring_segments = 22;
+    auto q_bezier = [](const QVector3D &a, const QVector3D &c,
+                       const QVector3D &b, float t) {
+      float const u = 1.0F - t;
+      return u * u * a + 2.0F * u * t * c + t * t * b;
+    };
+
+    float const bow_mid_y = (top_end.y() + bot_end.y()) * 0.5F;
+
+    float const ctrl_y = bow_mid_y + 0.45F;
+
+    QVector3D const ctrl(extras.bowX, ctrl_y,
+                         bow_plane_z + extras.bowDepth * 0.6F);
+
+    QVector3D prev = bot_end;
+    for (int i = 1; i <= k_bowstring_segments; ++i) {
+      float const t = float(i) / float(k_bowstring_segments);
+      QVector3D const cur = q_bezier(bot_end, ctrl, top_end, t);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, prev, cur, extras.bowRodR),
+               v.palette.wood, nullptr, 1.0F);
+      prev = cur;
+    }
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, grip - up * 0.05F, grip + up * 0.05F,
+                             extras.bowRodR * 1.45F),
+             v.palette.wood, nullptr, 1.0F);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, top_end, nock, extras.stringR),
+             extras.stringCol, nullptr, 1.0F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, nock, bot_end, extras.stringR),
+             extras.stringCol, nullptr, 1.0F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, pose.hand_r, nock, 0.0045F),
+             extras.stringCol * 0.9F, nullptr, 1.0F);
+
+    bool const show_arrow =
+        !is_attacking ||
+        (is_attacking && attack_phase >= 0.0F && attack_phase < 0.52F);
+
+    if (show_arrow) {
+      QVector3D const tail = nock - forward * 0.06F;
+      QVector3D const tip = tail + forward * 0.90F;
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, tail, tip, 0.018F),
+               v.palette.wood, nullptr, 1.0F);
+      QVector3D const head_base = tip - forward * 0.10F;
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, head_base, tip, 0.05F),
+               extras.metalHead, nullptr, 1.0F);
+      QVector3D const f1b = tail - forward * 0.02F;
+      QVector3D const f1a = f1b - forward * 0.06F;
+      QVector3D const f2b = tail + forward * 0.02F;
+      QVector3D const f2a = f2b + forward * 0.06F;
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, f1b, f1a, 0.04F),
+               extras.fletch, nullptr, 1.0F);
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, f2a, f2b, 0.04F),
+               extras.fletch, nullptr, 1.0F);
+    }
+  }
+};
+
+void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
+  ensure_archer_styles_registered();
+  static ArcherRenderer const renderer;
+  registry.registerRenderer(
+      "troops/carthage/archer", [](const DrawContext &ctx, ISubmitter &out) {
+        static ArcherRenderer const static_renderer;
+        Shader *archer_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          archer_shader = ctx.backend->shader(shader_key);
+          if (archer_shader == nullptr) {
+            archer_shader = ctx.backend->shader(QStringLiteral("archer"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) && (archer_shader != nullptr)) {
+          scene_renderer->setCurrentShader(archer_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Carthage

+ 15 - 0
render/entity/nations/carthage/archer_renderer.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include "../../registry.h"
+#include <string>
+
+namespace Render::GL::Carthage {
+
+struct ArcherStyleConfig;
+
+void register_archer_style(const std::string &nation_id,
+                           const ArcherStyleConfig &style);
+
+void registerArcherRenderer(EntityRendererRegistry &registry);
+
+} // namespace Render::GL::Carthage

+ 39 - 0
render/entity/nations/carthage/archer_style.cpp

@@ -0,0 +1,39 @@
+#include "archer_style.h"
+#include "archer_renderer.h"
+
+#include <QVector3D>
+#include <string>
+#include <string_view>
+
+namespace {
+constexpr QVector3D k_carthage_cloth{0.12F, 0.36F, 0.52F};
+constexpr QVector3D k_carthage_leather{0.36F, 0.24F, 0.12F};
+constexpr QVector3D k_carthage_leather_dark{0.22F, 0.16F, 0.10F};
+constexpr QVector3D k_carthage_metal{0.75F, 0.66F, 0.42F};
+constexpr QVector3D k_carthage_wood{0.38F, 0.28F, 0.18F};
+constexpr QVector3D k_carthage_fletch{0.90F, 0.82F, 0.28F};
+constexpr QVector3D k_carthage_string{0.32F, 0.30F, 0.26F};
+} // namespace
+
+namespace Render::GL::Carthage {
+
+void register_carthage_archer_style() {
+  ArcherStyleConfig style;
+  style.cloth_color = k_carthage_cloth;
+  style.leather_color = k_carthage_leather;
+  style.leather_dark_color = k_carthage_leather_dark;
+  style.metal_color = k_carthage_metal;
+  style.wood_color = k_carthage_wood;
+  style.fletching_color = k_carthage_fletch;
+  style.bow_string_color = k_carthage_string;
+  style.show_helmet = true;
+  style.show_armor = true;
+  style.show_shoulder_decor = false;
+  style.show_cape = false;
+  style.attachment_profile.clear();
+  style.shader_id = "archer_carthage";
+
+  register_archer_style("carthage", style);
+}
+
+} // namespace Render::GL::Carthage

+ 30 - 0
render/entity/nations/carthage/archer_style.h

@@ -0,0 +1,30 @@
+#pragma once
+
+#include <QVector3D>
+#include <optional>
+#include <string>
+
+namespace Render::GL::Carthage {
+
+struct ArcherStyleConfig {
+  std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> leather_color;
+  std::optional<QVector3D> leather_dark_color;
+  std::optional<QVector3D> metal_color;
+  std::optional<QVector3D> wood_color;
+  std::optional<QVector3D> cape_color;
+  std::optional<QVector3D> fletching_color;
+  std::optional<QVector3D> bow_string_color;
+
+  bool show_helmet = true;
+  bool show_armor = true;
+  bool show_shoulder_decor = true;
+  bool show_cape = true;
+
+  std::string attachment_profile;
+  std::string shader_id;
+};
+
+void register_carthage_archer_style();
+
+} // namespace Render::GL::Carthage

+ 978 - 0
render/entity/nations/carthage/knight_renderer.cpp

@@ -0,0 +1,978 @@
+#include "knight_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/shader.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
+#include "../../../humanoid_math.h"
+#include "../../../humanoid_specs.h"
+#include "../../../palette.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../registry.h"
+#include "../../renderer_constants.h"
+#include "knight_style.h"
+#include <numbers>
+#include <qmatrix4x4.h>
+#include <qstringliteral.h>
+#include <qvectornd.h>
+#include <unordered_map>
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <string_view>
+
+namespace Render::GL::Carthage {
+
+namespace {
+
+constexpr std::string_view k_knight_default_style_key = "default";
+constexpr float k_knight_team_mix_weight = 0.6F;
+constexpr float k_knight_style_mix_weight = 0.4F;
+
+auto knight_style_registry()
+    -> std::unordered_map<std::string, KnightStyleConfig> & {
+  static std::unordered_map<std::string, KnightStyleConfig> styles;
+  return styles;
+}
+
+void ensure_knight_styles_registered() {
+  static const bool registered = []() {
+    register_carthage_knight_style();
+    return true;
+  }();
+  (void)registered;
+}
+
+} // namespace
+
+void register_knight_style(const std::string &nation_id,
+                           const KnightStyleConfig &style) {
+  knight_style_registry()[nation_id] = style;
+}
+
+using Render::Geom::clamp01;
+using Render::Geom::clampf;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::easeInOutCubic;
+using Render::Geom::lerp;
+using Render::Geom::nlerp;
+using Render::Geom::smoothstep;
+using Render::Geom::sphereAt;
+using Render::GL::Humanoid::mix_palette_color;
+using Render::GL::Humanoid::saturate_color;
+
+struct KnightExtras {
+  QVector3D metalColor;
+  QVector3D shieldColor;
+  QVector3D shieldTrimColor;
+  float swordLength = 0.80F;
+  float swordWidth = 0.065F;
+  float shieldRadius = 0.18F;
+  float shieldAspect = 1.0F;
+
+  float guard_half_width = 0.12F;
+  float handleRadius = 0.016F;
+  float pommelRadius = 0.045F;
+  float bladeRicasso = 0.16F;
+  float bladeTaperBias = 0.65F;
+  bool shieldCrossDecal = false;
+  bool hasScabbard = true;
+};
+
+class KnightRenderer : public HumanoidRendererBase {
+public:
+  auto getProportionScaling() const -> QVector3D override {
+    return {1.40F, 1.05F, 1.10F};
+  }
+
+private:
+  mutable std::unordered_map<uint32_t, KnightExtras> m_extrasCache;
+
+public:
+  void getVariant(const DrawContext &ctx, uint32_t seed,
+                  HumanoidVariant &v) const override {
+    QVector3D const team_tint = resolveTeamTint(ctx);
+    v.palette = makeHumanoidPalette(team_tint, seed);
+    auto const &style = resolve_style(ctx);
+    apply_palette_overrides(style, team_tint, v);
+  }
+
+  void customizePose(const DrawContext &,
+                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                     HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    const AnimationInputs &anim = anim_ctx.inputs;
+
+    float const arm_height_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.03F;
+    float const arm_asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
+
+    if (anim.is_attacking && anim.isMelee) {
+      float const attack_phase =
+          std::fmod(anim.time * KNIGHT_INV_ATTACK_CYCLE_TIME, 1.0F);
+
+      QVector3D const rest_pos(0.20F, HP::SHOULDER_Y + 0.05F, 0.15F);
+      QVector3D const prepare_pos(0.26F, HP::HEAD_TOP_Y + 0.18F, -0.06F);
+      QVector3D const raised_pos(0.25F, HP::HEAD_TOP_Y + 0.22F, 0.02F);
+      QVector3D const strike_pos(0.30F, HP::WAIST_Y - 0.05F, 0.50F);
+      QVector3D const recover_pos(0.22F, HP::SHOULDER_Y + 0.02F, 0.22F);
+
+      if (attack_phase < 0.18F) {
+
+        float const t = easeInOutCubic(attack_phase / 0.18F);
+        pose.hand_r = rest_pos * (1.0F - t) + prepare_pos * t;
+        pose.handL =
+            QVector3D(-0.21F, HP::SHOULDER_Y - 0.02F - 0.03F * t, 0.15F);
+      } else if (attack_phase < 0.32F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.18F) / 0.14F);
+        pose.hand_r = prepare_pos * (1.0F - t) + raised_pos * t;
+        pose.handL = QVector3D(-0.21F, HP::SHOULDER_Y - 0.05F, 0.17F);
+      } else if (attack_phase < 0.52F) {
+
+        float t = (attack_phase - 0.32F) / 0.20F;
+        t = t * t * t;
+        pose.hand_r = raised_pos * (1.0F - t) + strike_pos * t;
+        pose.handL =
+            QVector3D(-0.21F, HP::SHOULDER_Y - 0.03F * (1.0F - 0.5F * t),
+                      0.17F + 0.20F * t);
+      } else if (attack_phase < 0.72F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.52F) / 0.20F);
+        pose.hand_r = strike_pos * (1.0F - t) + recover_pos * t;
+        pose.handL = QVector3D(-0.20F, HP::SHOULDER_Y - 0.015F * (1.0F - t),
+                               lerp(0.37F, 0.20F, t));
+      } else {
+
+        float const t = smoothstep(0.72F, 1.0F, attack_phase);
+        pose.hand_r = recover_pos * (1.0F - t) + rest_pos * t;
+        pose.handL = QVector3D(-0.20F - 0.02F * (1.0F - t),
+                               HP::SHOULDER_Y + arm_height_jitter * (1.0F - t),
+                               lerp(0.20F, 0.15F, t));
+      }
+    } else {
+
+      pose.hand_r =
+          QVector3D(0.30F + arm_asymmetry,
+                    HP::SHOULDER_Y - 0.02F + arm_height_jitter, 0.35F);
+      pose.handL = QVector3D(-0.22F - 0.5F * arm_asymmetry,
+                             HP::SHOULDER_Y + 0.5F * arm_height_jitter, 0.18F);
+    }
+  }
+
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose,
+                      const HumanoidAnimationContext &anim_ctx,
+                      ISubmitter &out) const override {
+    const AnimationInputs &anim = anim_ctx.inputs;
+    uint32_t const seed = reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU;
+    auto const &style = resolve_style(ctx);
+    QVector3D const team_tint = resolveTeamTint(ctx);
+
+    KnightExtras extras;
+    auto it = m_extrasCache.find(seed);
+    if (it != m_extrasCache.end()) {
+      extras = it->second;
+    } else {
+      extras = computeKnightExtras(seed, v);
+      apply_extras_overrides(style, team_tint, v, extras);
+      m_extrasCache[seed] = extras;
+
+      if (m_extrasCache.size() > MAX_EXTRAS_CACHE_SIZE) {
+        m_extrasCache.clear();
+      }
+    }
+    apply_extras_overrides(style, team_tint, v, extras);
+
+    bool const is_attacking = anim.is_attacking && anim.isMelee;
+    float attack_phase = 0.0F;
+    if (is_attacking) {
+      attack_phase = std::fmod(anim.time * KNIGHT_INV_ATTACK_CYCLE_TIME, 1.0F);
+    }
+
+    drawSword(ctx, pose, v, extras, is_attacking, attack_phase, out);
+    drawShield(ctx, pose, v, extras, out);
+
+    if (!is_attacking && extras.hasScabbard) {
+      drawScabbard(ctx, pose, v, extras, out);
+    }
+  }
+
+  void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose, ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    QVector3D const steel_color =
+        v.palette.metal * QVector3D(0.95F, 0.96F, 1.0F);
+
+    float helm_r = pose.headR * 1.15F;
+    QVector3D const helm_bot(0, pose.headPos.y() - pose.headR * 0.20F, 0);
+    QVector3D const helm_top(0, pose.headPos.y() + pose.headR * 1.40F, 0);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
+             steel_color, nullptr, 1.0F);
+
+    QVector3D const cap_top(0, pose.headPos.y() + pose.headR * 1.48F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
+             steel_color * 1.05F, nullptr, 1.0F);
+
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 1.25F, 0), helm_r * 1.02F,
+         0.015F, steel_color * 1.08F);
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.50F, 0), helm_r * 1.02F,
+         0.015F, steel_color * 1.08F);
+    ring(QVector3D(0, pose.headPos.y() - pose.headR * 0.05F, 0), helm_r * 1.02F,
+         0.015F, steel_color * 1.08F);
+
+    float const visor_y = pose.headPos.y() + pose.headR * 0.15F;
+    float const visor_z = helm_r * 0.72F;
+
+    QVector3D const visor_hl(-helm_r * 0.35F, visor_y, visor_z);
+    QVector3D const visor_hr(helm_r * 0.35F, visor_y, visor_z);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, visor_hl, visor_hr, 0.012F),
+             QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
+
+    QVector3D const visor_vt(0, visor_y + helm_r * 0.25F, visor_z);
+    QVector3D const visor_vb(0, visor_y - helm_r * 0.25F, visor_z);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, visor_vb, visor_vt, 0.012F),
+             QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
+
+    auto draw_breathing_hole = [&](float x, float y) {
+      QVector3D const pos(x, pose.headPos.y() + y, helm_r * 0.70F);
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.010F);
+      out.mesh(getUnitSphere(), m, QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
+    };
+
+    for (int i = 0; i < 4; ++i) {
+      draw_breathing_hole(helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+    }
+
+    for (int i = 0; i < 4; ++i) {
+      draw_breathing_hole(-helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+    }
+
+    QVector3D const cross_center(0, pose.headPos.y() + pose.headR * 0.60F,
+                                 helm_r * 0.75F);
+    QVector3D const brass_color = v.palette.metal * QVector3D(1.3F, 1.1F, 0.7F);
+
+    QVector3D const cross_h1 = cross_center + QVector3D(-0.04F, 0, 0);
+    QVector3D const cross_h2 = cross_center + QVector3D(0.04F, 0, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, cross_h1, cross_h2, 0.008F),
+             brass_color, nullptr, 1.0F);
+
+    QVector3D const cross_v1 = cross_center + QVector3D(0, -0.04F, 0);
+    QVector3D const cross_v2 = cross_center + QVector3D(0, 0.04F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, cross_v1, cross_v2, 0.008F),
+             brass_color, nullptr, 1.0F);
+  }
+
+  void draw_armorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, float y_top_cover,
+                         float torso_r, float, float upper_arm_r,
+                         const QVector3D &right_axis,
+                         ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    QVector3D steel_color = v.palette.metal * QVector3D(0.95F, 0.96F, 1.0F);
+    QVector3D const dark_steel = steel_color * 0.85F;
+    QVector3D brass_color = v.palette.metal * QVector3D(1.3F, 1.1F, 0.7F);
+
+    QVector3D const bp_top(0, y_top_cover + 0.02F, 0);
+    QVector3D const bp_mid(0, (y_top_cover + HP::WAIST_Y) * 0.5F + 0.04F, 0);
+    QVector3D const bp_bot(0, HP::WAIST_Y + 0.06F, 0);
+    float const r_chest = torso_r * 1.18F;
+    float const r_waist = torso_r * 1.14F;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, bp_top, bp_mid, r_chest), steel_color,
+             nullptr, 1.0F);
+
+    QVector3D const bp_mid_low(0, (bp_mid.y() + bp_bot.y()) * 0.5F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, bp_mid, bp_mid_low, r_chest * 0.98F),
+             steel_color * 0.99F, nullptr, 1.0F);
+
+    out.mesh(getUnitCone(), coneFromTo(ctx.model, bp_bot, bp_mid_low, r_waist),
+             steel_color * 0.98F, nullptr, 1.0F);
+
+    auto draw_rivet = [&](const QVector3D &pos) {
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.012F);
+      out.mesh(getUnitSphere(), m, brass_color, nullptr, 1.0F);
+    };
+
+    for (int i = 0; i < 8; ++i) {
+      float const angle = (i / 8.0F) * 2.0F * std::numbers::pi_v<float>;
+      float const x = r_chest * std::sin(angle) * 0.95F;
+      float const z = r_chest * std::cos(angle) * 0.95F;
+      draw_rivet(QVector3D(x, bp_mid.y() + 0.08F, z));
+    }
+
+    auto draw_pauldron = [&](const QVector3D &shoulder,
+                             const QVector3D &outward) {
+      for (int i = 0; i < 4; ++i) {
+        float const seg_y = shoulder.y() + 0.04F - i * 0.045F;
+        float const seg_r = upper_arm_r * (2.5F - i * 0.12F);
+        QVector3D seg_pos = shoulder + outward * (0.02F + i * 0.008F);
+        seg_pos.setY(seg_y);
+
+        out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_pos, seg_r),
+                 i == 0 ? steel_color * 1.05F
+                        : steel_color * (1.0F - i * 0.03F),
+                 nullptr, 1.0F);
+
+        if (i < 3) {
+          draw_rivet(seg_pos + QVector3D(0, 0.015F, 0.03F));
+        }
+      }
+    };
+
+    draw_pauldron(pose.shoulderL, -right_axis);
+    draw_pauldron(pose.shoulderR, right_axis);
+
+    auto draw_arm_plate = [&](const QVector3D &shoulder,
+                              const QVector3D &elbow) {
+      QVector3D dir = (elbow - shoulder);
+      float const len = dir.length();
+      if (len < 1e-5F) {
+        return;
+      }
+      dir /= len;
+
+      for (int i = 0; i < 3; ++i) {
+        float const t0 = 0.10F + i * 0.25F;
+        float const t1 = t0 + 0.22F;
+        QVector3D const a = shoulder + dir * (t0 * len);
+        QVector3D const b = shoulder + dir * (t1 * len);
+        float const r = upper_arm_r * (1.32F - i * 0.04F);
+
+        out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+                 steel_color * (0.98F - i * 0.02F), nullptr, 1.0F);
+
+        if (i < 2) {
+          draw_rivet(b);
+        }
+      }
+    };
+
+    draw_arm_plate(pose.shoulderL, pose.elbowL);
+    draw_arm_plate(pose.shoulderR, pose.elbowR);
+
+    for (int i = 0; i < 4; ++i) {
+      float const y0 = HP::WAIST_Y + 0.04F - i * 0.038F;
+      float const y1 = y0 - 0.032F;
+      float const r0 = r_waist * (1.06F + i * 0.025F);
+      out.mesh(
+          getUnitCone(),
+          coneFromTo(ctx.model, QVector3D(0, y0, 0), QVector3D(0, y1, 0), r0),
+          steel_color * (0.96F - i * 0.02F), nullptr, 1.0F);
+
+      if (i < 3) {
+        draw_rivet(QVector3D(r0 * 0.90F, y0 - 0.016F, 0));
+      }
+    }
+
+    QVector3D const gorget_top(0, y_top_cover + 0.025F, 0);
+    QVector3D const gorget_bot(0, y_top_cover - 0.012F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, gorget_bot, gorget_top,
+                             HP::NECK_RADIUS * 2.6F),
+             steel_color * 1.08F, nullptr, 1.0F);
+
+    ring(gorget_top, HP::NECK_RADIUS * 2.62F, 0.010F, brass_color);
+  }
+
+  void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
+                               const HumanoidPose &pose, float y_top_cover,
+                               float y_neck, const QVector3D &right_axis,
+                               ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    QVector3D brass_color = v.palette.metal * QVector3D(1.3F, 1.1F, 0.7F);
+    QVector3D const chainmail_color =
+        v.palette.metal * QVector3D(0.85F, 0.88F, 0.92F);
+    QVector3D mantling_color = v.palette.cloth;
+
+    for (int i = 0; i < 5; ++i) {
+      float const y = y_neck - i * 0.022F;
+      float const r = HP::NECK_RADIUS * (1.85F + i * 0.08F);
+      QVector3D const ring_pos(0, y, 0);
+      QVector3D const a = ring_pos + QVector3D(0, 0.010F, 0);
+      QVector3D const b = ring_pos - QVector3D(0, 0.010F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+               chainmail_color * (1.0F - i * 0.04F), nullptr, 1.0F);
+    }
+
+    QVector3D const helm_top(0, HP::HEAD_TOP_Y - HP::HEAD_RADIUS * 0.15F, 0);
+    QMatrix4x4 crest_base = ctx.model;
+    crest_base.translate(helm_top);
+    crest_base.scale(0.025F, 0.015F, 0.025F);
+    out.mesh(getUnitSphere(), crest_base, brass_color * 1.2F, nullptr, 1.0F);
+
+    auto draw_stud = [&](const QVector3D &pos) {
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.008F);
+      out.mesh(getUnitSphere(), m, brass_color * 1.3F, nullptr, 1.0F);
+    };
+
+    draw_stud(helm_top + QVector3D(0.020F, 0, 0.020F));
+    draw_stud(helm_top + QVector3D(-0.020F, 0, 0.020F));
+    draw_stud(helm_top + QVector3D(0.020F, 0, -0.020F));
+    draw_stud(helm_top + QVector3D(-0.020F, 0, -0.020F));
+
+    auto draw_mantling = [&](const QVector3D &startPos,
+                             const QVector3D &direction) {
+      QVector3D current_pos = startPos;
+      for (int i = 0; i < 4; ++i) {
+        float const seg_len = 0.035F - i * 0.005F;
+        float const seg_r = 0.020F - i * 0.003F;
+        QVector3D next_pos = current_pos + direction * seg_len;
+        next_pos.setY(next_pos.y() - 0.025F);
+
+        out.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, current_pos, next_pos, seg_r),
+                 mantling_color * (1.1F - i * 0.06F), nullptr, 1.0F);
+
+        current_pos = next_pos;
+      }
+    };
+
+    QVector3D const mantling_start(0, HP::CHIN_Y + HP::HEAD_RADIUS * 0.25F, 0);
+    draw_mantling(mantling_start + right_axis * HP::HEAD_RADIUS * 0.95F,
+                  right_axis * 0.5F + QVector3D(0, -0.1F, -0.3F));
+    draw_mantling(mantling_start - right_axis * HP::HEAD_RADIUS * 0.95F,
+                  -right_axis * 0.5F + QVector3D(0, -0.1F, -0.3F));
+
+    auto draw_pauldron_rivet = [&](const QVector3D &shoulder,
+                                   const QVector3D &outward) {
+      for (int i = 0; i < 3; ++i) {
+        float const seg_y = shoulder.y() + 0.025F - i * 0.045F;
+        QVector3D rivet_pos = shoulder + outward * (0.04F + i * 0.008F);
+        rivet_pos.setY(seg_y);
+
+        draw_stud(rivet_pos);
+      }
+    };
+
+    draw_pauldron_rivet(pose.shoulderL, -right_axis);
+    draw_pauldron_rivet(pose.shoulderR, right_axis);
+
+    QVector3D const gorget_top(0, y_top_cover + 0.045F, 0);
+    for (int i = 0; i < 6; ++i) {
+      float const angle = (i / 6.0F) * 2.0F * std::numbers::pi_v<float>;
+      float const x = HP::NECK_RADIUS * 2.58F * std::sin(angle);
+      float const z = HP::NECK_RADIUS * 2.58F * std::cos(angle);
+      draw_stud(gorget_top + QVector3D(x, 0, z));
+    }
+
+    QVector3D const belt_center(0, HP::WAIST_Y + 0.03F,
+                                HP::TORSO_BOT_R * 1.15F);
+    QMatrix4x4 buckle = ctx.model;
+    buckle.translate(belt_center);
+    buckle.scale(0.035F, 0.025F, 0.012F);
+    out.mesh(getUnitSphere(), buckle, brass_color * 1.25F, nullptr, 1.0F);
+
+    QVector3D const buckle_h1 = belt_center + QVector3D(-0.025F, 0, 0.005F);
+    QVector3D const buckle_h2 = belt_center + QVector3D(0.025F, 0, 0.005F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, buckle_h1, buckle_h2, 0.006F),
+             brass_color * 1.4F, nullptr, 1.0F);
+
+    QVector3D const buckle_v1 = belt_center + QVector3D(0, -0.018F, 0.005F);
+    QVector3D const buckle_v2 = belt_center + QVector3D(0, 0.018F, 0.005F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, buckle_v1, buckle_v2, 0.006F),
+             brass_color * 1.4F, nullptr, 1.0F);
+  }
+
+private:
+  static auto computeKnightExtras(uint32_t seed,
+                                  const HumanoidVariant &v) -> KnightExtras {
+    KnightExtras e;
+
+    e.metalColor = QVector3D(0.72F, 0.73F, 0.78F);
+
+    float const shield_hue = hash_01(seed ^ 0x12345U);
+    if (shield_hue < 0.45F) {
+      e.shieldColor = v.palette.cloth * 1.10F;
+    } else if (shield_hue < 0.90F) {
+      e.shieldColor = v.palette.leather * 1.25F;
+    } else {
+
+      e.shieldColor = e.metalColor * 0.95F;
+    }
+
+    e.swordLength = 0.80F + (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.16F;
+    e.swordWidth = 0.060F + (hash_01(seed ^ 0x7777U) - 0.5F) * 0.010F;
+    e.shieldRadius = 0.16F + (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
+
+    e.guard_half_width = 0.120F + (hash_01(seed ^ 0x3456U) - 0.5F) * 0.020F;
+    e.handleRadius = 0.016F + (hash_01(seed ^ 0x88AAU) - 0.5F) * 0.003F;
+    e.pommelRadius = 0.045F + (hash_01(seed ^ 0x19C3U) - 0.5F) * 0.006F;
+
+    e.bladeRicasso =
+        clampf(0.14F + (hash_01(seed ^ 0xBEEFU) - 0.5F) * 0.04F, 0.10F, 0.20F);
+    e.bladeTaperBias = clamp01(0.6F + (hash_01(seed ^ 0xFACEU) - 0.5F) * 0.2F);
+
+    e.shieldCrossDecal = (hash_01(seed ^ 0xA11CU) > 0.55F);
+    e.hasScabbard = (hash_01(seed ^ 0x5CABU) > 0.15F);
+    e.shieldTrimColor = e.metalColor * 0.95F;
+    e.shieldAspect = 1.0F;
+    return e;
+  }
+
+  static void drawSword(const DrawContext &ctx, const HumanoidPose &pose,
+                        const HumanoidVariant &v, const KnightExtras &extras,
+                        bool is_attacking, float attack_phase,
+                        ISubmitter &out) {
+    QVector3D const grip_pos = pose.hand_r;
+
+    constexpr float k_sword_yaw_deg = 25.0F;
+    QMatrix4x4 yaw_m;
+    yaw_m.rotate(k_sword_yaw_deg, 0.0F, 1.0F, 0.0F);
+
+    QVector3D upish = yaw_m.map(QVector3D(0.05F, 1.0F, 0.15F));
+    QVector3D midish = yaw_m.map(QVector3D(0.08F, 0.20F, 1.0F));
+    QVector3D downish = yaw_m.map(QVector3D(0.10F, -1.0F, 0.25F));
+    if (upish.lengthSquared() > 1e-6F) {
+      upish.normalize();
+    }
+    if (midish.lengthSquared() > 1e-6F) {
+      midish.normalize();
+    }
+    if (downish.lengthSquared() > 1e-6F) {
+      downish.normalize();
+    }
+
+    QVector3D sword_dir = upish;
+
+    if (is_attacking) {
+      if (attack_phase < 0.18F) {
+        float const t = easeInOutCubic(attack_phase / 0.18F);
+        sword_dir = nlerp(upish, upish, t);
+      } else if (attack_phase < 0.32F) {
+        float const t = easeInOutCubic((attack_phase - 0.18F) / 0.14F);
+        sword_dir = nlerp(upish, midish, t * 0.35F);
+      } else if (attack_phase < 0.52F) {
+        float t = (attack_phase - 0.32F) / 0.20F;
+        t = t * t * t;
+        if (t < 0.5F) {
+          float const u = t / 0.5F;
+          sword_dir = nlerp(upish, midish, u);
+        } else {
+          float const u = (t - 0.5F) / 0.5F;
+          sword_dir = nlerp(midish, downish, u);
+        }
+      } else if (attack_phase < 0.72F) {
+        float const t = easeInOutCubic((attack_phase - 0.52F) / 0.20F);
+        sword_dir = nlerp(downish, midish, t);
+      } else {
+        float const t = smoothstep(0.72F, 1.0F, attack_phase);
+        sword_dir = nlerp(midish, upish, t);
+      }
+    }
+
+    QVector3D const handle_end = grip_pos - sword_dir * 0.10F;
+    QVector3D const blade_base = grip_pos;
+    QVector3D const blade_tip = grip_pos + sword_dir * extras.swordLength;
+
+    out.mesh(
+        getUnitCylinder(),
+        cylinderBetween(ctx.model, handle_end, blade_base, extras.handleRadius),
+        v.palette.leather, nullptr, 1.0F);
+
+    QVector3D const guard_center = blade_base;
+    float const gw = extras.guard_half_width;
+
+    QVector3D guard_right =
+        QVector3D::crossProduct(QVector3D(0, 1, 0), sword_dir);
+    if (guard_right.lengthSquared() < 1e-6F) {
+      guard_right = QVector3D::crossProduct(QVector3D(1, 0, 0), sword_dir);
+    }
+    guard_right.normalize();
+
+    QVector3D const guard_l = guard_center - guard_right * gw;
+    QVector3D const guard_r = guard_center + guard_right * gw;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, guard_l, guard_r, 0.014F),
+             extras.metalColor, nullptr, 1.0F);
+
+    QMatrix4x4 gl = ctx.model;
+    gl.translate(guard_l);
+    gl.scale(0.018F);
+    out.mesh(getUnitSphere(), gl, extras.metalColor, nullptr, 1.0F);
+    QMatrix4x4 gr = ctx.model;
+    gr.translate(guard_r);
+    gr.scale(0.018F);
+    out.mesh(getUnitSphere(), gr, extras.metalColor, nullptr, 1.0F);
+
+    float const l = extras.swordLength;
+    float const base_w = extras.swordWidth;
+    float blade_thickness = base_w * 0.15F;
+
+    float const ricasso_len = clampf(extras.bladeRicasso, 0.10F, l * 0.30F);
+    QVector3D const ricasso_end = blade_base + sword_dir * ricasso_len;
+
+    float const mid_w = base_w * 0.95F;
+    float const tip_w = base_w * 0.28F;
+    float const tip_start_dist = lerp(ricasso_len, l, 0.70F);
+    QVector3D const tip_start = blade_base + sword_dir * tip_start_dist;
+
+    auto draw_flat_section = [&](const QVector3D &start, const QVector3D &end,
+                                 float width, const QVector3D &color) {
+      QVector3D right = QVector3D::crossProduct(sword_dir, QVector3D(0, 1, 0));
+      if (right.lengthSquared() < 0.001F) {
+        right = QVector3D::crossProduct(sword_dir, QVector3D(1, 0, 0));
+      }
+      right.normalize();
+
+      float const offset = width * 0.33F;
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, start, end, blade_thickness), color,
+               nullptr, 1.0F);
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, start + right * offset,
+                               end + right * offset, blade_thickness * 0.8F),
+               color * 0.92F, nullptr, 1.0F);
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, start - right * offset,
+                               end - right * offset, blade_thickness * 0.8F),
+               color * 0.92F, nullptr, 1.0F);
+    };
+
+    draw_flat_section(blade_base, ricasso_end, base_w, extras.metalColor);
+
+    draw_flat_section(ricasso_end, tip_start, mid_w, extras.metalColor);
+
+    int const tip_segments = 3;
+    for (int i = 0; i < tip_segments; ++i) {
+      float const t0 = (float)i / tip_segments;
+      float const t1 = (float)(i + 1) / tip_segments;
+      QVector3D const seg_start =
+          tip_start + sword_dir * ((blade_tip - tip_start).length() * t0);
+      QVector3D const seg_end =
+          tip_start + sword_dir * ((blade_tip - tip_start).length() * t1);
+      float const w = lerp(mid_w, tip_w, t1);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, seg_start, seg_end, blade_thickness),
+               extras.metalColor * (1.0F - i * 0.03F), nullptr, 1.0F);
+    }
+
+    QVector3D const fuller_start =
+        blade_base + sword_dir * (ricasso_len + 0.02F);
+    QVector3D const fuller_end =
+        blade_base + sword_dir * (tip_start_dist - 0.06F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, fuller_start, fuller_end,
+                             blade_thickness * 0.6F),
+             extras.metalColor * 0.65F, nullptr, 1.0F);
+
+    QVector3D const pommel = handle_end - sword_dir * 0.02F;
+    QMatrix4x4 pommel_mat = ctx.model;
+    pommel_mat.translate(pommel);
+    pommel_mat.scale(extras.pommelRadius);
+    out.mesh(getUnitSphere(), pommel_mat, extras.metalColor, nullptr, 1.0F);
+
+    if (is_attacking && attack_phase >= 0.32F && attack_phase < 0.56F) {
+      float const t = (attack_phase - 0.32F) / 0.24F;
+      float const alpha = clamp01(0.35F * (1.0F - t));
+      QVector3D const trail_start = blade_base - sword_dir * 0.05F;
+      QVector3D const trail_end = blade_base - sword_dir * (0.28F + 0.15F * t);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, trail_end, trail_start, base_w * 0.9F),
+               extras.metalColor * 0.9F, nullptr, alpha);
+    }
+  }
+
+  static void drawShield(const DrawContext &ctx, const HumanoidPose &pose,
+                         const HumanoidVariant &v, const KnightExtras &extras,
+                         ISubmitter &out) {
+
+    constexpr float k_scale_factor = 2.5F;
+    constexpr float k_shield_yaw_degrees = -70.0F;
+
+    QMatrix4x4 rot;
+    rot.rotate(k_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+
+    const QVector3D n = rot.map(QVector3D(0.0F, 0.0F, 1.0F));
+    const QVector3D axis_x = rot.map(QVector3D(1.0F, 0.0F, 0.0F));
+    const QVector3D axis_y = rot.map(QVector3D(0.0F, 1.0F, 0.0F));
+
+    float const base_extent = extras.shieldRadius * k_scale_factor;
+    float const shield_width = base_extent;
+    float const shield_height = base_extent * extras.shieldAspect;
+    float const min_extent = std::min(shield_width, shield_height);
+
+    QVector3D shield_center = pose.handL + axis_x * (-shield_width * 0.35F) +
+                              axis_y * (-0.05F) + n * (0.06F);
+
+    const float plate_half = 0.0015F;
+    const float plate_full = plate_half * 2.0F;
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center + n * plate_half);
+      m.rotate(k_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+      m.scale(shield_width, shield_height, plate_full);
+      out.mesh(getUnitCylinder(), m, extras.shieldColor, nullptr, 1.0F);
+    }
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center - n * plate_half);
+      m.rotate(k_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+      m.scale(shield_width * 0.985F, shield_height * 0.985F, plate_full);
+      out.mesh(getUnitCylinder(), m, v.palette.leather * 0.8F, nullptr, 1.0F);
+    }
+
+    auto draw_ring_rotated = [&](float width, float height, float thickness,
+                                 const QVector3D &color) {
+      constexpr int k_segments = 18;
+      for (int i = 0; i < k_segments; ++i) {
+        float const a0 =
+            (float)i / k_segments * 2.0F * std::numbers::pi_v<float>;
+        float const a1 =
+            (float)(i + 1) / k_segments * 2.0F * std::numbers::pi_v<float>;
+
+        QVector3D const v0(width * std::cos(a0), height * std::sin(a0), 0.0F);
+        QVector3D const v1(width * std::cos(a1), height * std::sin(a1), 0.0F);
+
+        QVector3D const p0 = shield_center + rot.map(v0);
+        QVector3D const p1 = shield_center + rot.map(v1);
+
+        out.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, p0, p1, thickness), color, nullptr,
+                 1.0F);
+      }
+    };
+
+    draw_ring_rotated(shield_width, shield_height, min_extent * 0.010F,
+                      extras.shieldTrimColor * 0.95F);
+    draw_ring_rotated(shield_width * 0.72F, shield_height * 0.72F,
+                      min_extent * 0.006F, v.palette.leather * 0.90F);
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center + n * (0.02F * k_scale_factor));
+      m.scale(0.045F * k_scale_factor);
+      out.mesh(getUnitSphere(), m, extras.metalColor, nullptr, 1.0F);
+    }
+
+    {
+      QVector3D const grip_a = shield_center - axis_x * 0.035F - n * 0.030F;
+      QVector3D const grip_b = shield_center + axis_x * 0.035F - n * 0.030F;
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, grip_a, grip_b, 0.010F),
+               v.palette.leather, nullptr, 1.0F);
+    }
+
+    if (extras.shieldCrossDecal) {
+      QVector3D const center_front =
+          shield_center + n * (plate_full * 0.5F + 0.0015F);
+      float const bar_radius = min_extent * 0.10F;
+
+      QVector3D const top = center_front + axis_y * (shield_height * 0.90F);
+      QVector3D const bot = center_front - axis_y * (shield_height * 0.90F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, top, bot, bar_radius),
+               extras.shieldTrimColor, nullptr, 1.0F);
+
+      QVector3D const left = center_front - axis_x * (shield_width * 0.90F);
+      QVector3D const right = center_front + axis_x * (shield_width * 0.90F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, left, right, bar_radius),
+               extras.shieldTrimColor, nullptr, 1.0F);
+    }
+  }
+
+  static void drawScabbard(const DrawContext &ctx, const HumanoidPose &,
+                           const HumanoidVariant &v, const KnightExtras &extras,
+                           ISubmitter &out) {
+    using HP = HumanProportions;
+
+    QVector3D const hip(0.10F, HP::WAIST_Y - 0.04F, -0.02F);
+    QVector3D const tip = hip + QVector3D(-0.05F, -0.22F, -0.12F);
+    float const sheath_r = extras.swordWidth * 0.85F;
+
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, hip, tip, sheath_r),
+             v.palette.leather * 0.9F, nullptr, 1.0F);
+
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, tip, tip + QVector3D(-0.02F, -0.02F, -0.02F),
+                        sheath_r),
+             extras.metalColor, nullptr, 1.0F);
+
+    QVector3D const strap_a = hip + QVector3D(0.00F, 0.03F, 0.00F);
+    QVector3D const belt = QVector3D(0.12F, HP::WAIST_Y + 0.01F, 0.02F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, strap_a, belt, 0.006F),
+             v.palette.leather, nullptr, 1.0F);
+  }
+
+  auto
+  resolve_style(const DrawContext &ctx) const -> const KnightStyleConfig & {
+    ensure_knight_styles_registered();
+    auto &styles = knight_style_registry();
+    std::string nation_id;
+    if (ctx.entity != nullptr) {
+      if (auto *unit =
+              ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+        nation_id = unit->nation_id;
+      }
+    }
+    if (!nation_id.empty()) {
+      auto it = styles.find(nation_id);
+      if (it != styles.end()) {
+        return it->second;
+      }
+    }
+    auto it_default = styles.find(std::string(k_knight_default_style_key));
+    if (it_default != styles.end()) {
+      return it_default->second;
+    }
+    static const KnightStyleConfig k_empty{};
+    return k_empty;
+  }
+
+public:
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    const KnightStyleConfig &style = resolve_style(ctx);
+    if (!style.shader_id.empty()) {
+      return QString::fromStdString(style.shader_id);
+    }
+    return QStringLiteral("knight");
+  }
+
+private:
+  void apply_palette_overrides(const KnightStyleConfig &style,
+                               const QVector3D &team_tint,
+                               HumanoidVariant &variant) const {
+    auto apply_color = [&](const std::optional<QVector3D> &override_color,
+                           QVector3D &target) {
+      target = mix_palette_color(target, override_color, team_tint,
+                                 k_knight_team_mix_weight,
+                                 k_knight_style_mix_weight);
+    };
+
+    apply_color(style.cloth_color, variant.palette.cloth);
+    apply_color(style.leather_color, variant.palette.leather);
+    apply_color(style.leather_dark_color, variant.palette.leatherDark);
+    apply_color(style.metal_color, variant.palette.metal);
+  }
+
+  void apply_extras_overrides(const KnightStyleConfig &style,
+                              const QVector3D &team_tint,
+                              const HumanoidVariant &variant,
+                              KnightExtras &extras) const {
+    extras.metalColor = saturate_color(variant.palette.metal);
+    extras.shieldColor = saturate_color(extras.shieldColor);
+    extras.shieldTrimColor = saturate_color(extras.shieldTrimColor);
+
+    auto apply_shield_color =
+        [&](const std::optional<QVector3D> &override_color, QVector3D &target) {
+          target = mix_palette_color(target, override_color, team_tint,
+                                     k_knight_team_mix_weight,
+                                     k_knight_style_mix_weight);
+        };
+
+    apply_shield_color(style.shield_color, extras.shieldColor);
+    apply_shield_color(style.shield_trim_color, extras.shieldTrimColor);
+
+    if (style.shield_radius_scale) {
+      extras.shieldRadius =
+          std::max(0.10F, extras.shieldRadius * *style.shield_radius_scale);
+    }
+    if (style.shield_aspect_ratio) {
+      extras.shieldAspect = std::max(0.40F, *style.shield_aspect_ratio);
+    }
+    if (style.has_scabbard) {
+      extras.hasScabbard = *style.has_scabbard;
+    }
+    if (style.shield_cross_decal) {
+      extras.shieldCrossDecal = *style.shield_cross_decal;
+    }
+  }
+};
+
+void registerKnightRenderer(Render::GL::EntityRendererRegistry &registry) {
+  ensure_knight_styles_registered();
+  static KnightRenderer const renderer;
+  registry.registerRenderer(
+      "troops/carthage/swordsman", [](const DrawContext &ctx, ISubmitter &out) {
+        static KnightRenderer const static_renderer;
+        Shader *knight_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          knight_shader = ctx.backend->shader(shader_key);
+          if (knight_shader == nullptr) {
+            knight_shader = ctx.backend->shader(QStringLiteral("knight"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) && (knight_shader != nullptr)) {
+          scene_renderer->setCurrentShader(knight_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+  registry.registerRenderer(
+      "troops/carthage/knight", [](const DrawContext &ctx, ISubmitter &out) {
+        static KnightRenderer const static_renderer;
+        Shader *knight_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          knight_shader = ctx.backend->shader(QStringLiteral("knight"));
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) && (knight_shader != nullptr)) {
+          scene_renderer->setCurrentShader(knight_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Carthage

+ 15 - 0
render/entity/nations/carthage/knight_renderer.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include "../../registry.h"
+#include <string>
+
+namespace Render::GL::Carthage {
+
+struct KnightStyleConfig;
+
+void register_knight_style(const std::string &nation_id,
+                           const KnightStyleConfig &style);
+
+void registerKnightRenderer(EntityRendererRegistry &registry);
+
+} // namespace Render::GL::Carthage

+ 34 - 0
render/entity/nations/carthage/knight_style.cpp

@@ -0,0 +1,34 @@
+#include "knight_style.h"
+#include "knight_renderer.h"
+
+#include <QVector3D>
+
+namespace {
+constexpr QVector3D k_carthage_cloth{0.15F, 0.36F, 0.55F};
+constexpr QVector3D k_carthage_leather{0.32F, 0.22F, 0.12F};
+constexpr QVector3D k_carthage_leather_dark{0.20F, 0.14F, 0.09F};
+constexpr QVector3D k_carthage_metal{0.70F, 0.68F, 0.52F};
+constexpr QVector3D k_carthage_shield{0.20F, 0.46F, 0.62F};
+constexpr QVector3D k_carthage_trim{0.76F, 0.68F, 0.42F};
+} // namespace
+
+namespace Render::GL::Carthage {
+
+void register_carthage_knight_style() {
+  KnightStyleConfig style;
+  style.cloth_color = k_carthage_cloth;
+  style.leather_color = k_carthage_leather;
+  style.leather_dark_color = k_carthage_leather_dark;
+  style.metal_color = k_carthage_metal;
+  style.shield_color = k_carthage_shield;
+  style.shield_trim_color = k_carthage_trim;
+  style.shield_radius_scale = 0.9F;
+  style.shield_aspect_ratio = 0.85F;
+  style.has_scabbard = false;
+  style.shield_cross_decal = false;
+  style.shader_id = "knight_carthage";
+
+  register_knight_style("carthage", style);
+}
+
+} // namespace Render::GL::Carthage

+ 27 - 0
render/entity/nations/carthage/knight_style.h

@@ -0,0 +1,27 @@
+#pragma once
+
+#include <QVector3D>
+#include <optional>
+#include <string>
+
+namespace Render::GL::Carthage {
+
+struct KnightStyleConfig {
+  std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> leather_color;
+  std::optional<QVector3D> leather_dark_color;
+  std::optional<QVector3D> metal_color;
+  std::optional<QVector3D> shield_color;
+  std::optional<QVector3D> shield_trim_color;
+
+  std::optional<float> shield_radius_scale;
+  std::optional<float> shield_aspect_ratio;
+  std::optional<bool> shield_cross_decal;
+  std::optional<bool> has_scabbard;
+
+  std::string shader_id;
+};
+
+void register_carthage_knight_style();
+
+} // namespace Render::GL::Carthage

+ 807 - 0
render/entity/nations/carthage/mounted_knight_renderer.cpp

@@ -0,0 +1,807 @@
+#include "mounted_knight_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../../game/core/entity.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/shader.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid_math.h"
+#include "../../../humanoid_specs.h"
+#include "../../../palette.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../horse_renderer.h"
+#include "../../registry.h"
+#include "../../renderer_constants.h"
+#include <numbers>
+#include <qmatrix4x4.h>
+#include <qstringliteral.h>
+#include <qvectornd.h>
+#include <unordered_map>
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+
+namespace Render::GL::Carthage {
+
+using Render::Geom::clamp01;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::easeInOutCubic;
+using Render::Geom::smoothstep;
+using Render::Geom::sphereAt;
+
+struct MountedKnightExtras {
+  QVector3D metalColor;
+  HorseProfile horseProfile;
+  float swordLength = 0.85F;
+  float swordWidth = 0.045F;
+  bool hasSword = true;
+  bool hasCavalryShield = false;
+};
+
+class MountedKnightRenderer : public HumanoidRendererBase {
+public:
+  auto getProportionScaling() const -> QVector3D override {
+    return {1.40F, 1.05F, 1.10F};
+  }
+
+private:
+  mutable std::unordered_map<uint32_t, MountedKnightExtras> m_extrasCache;
+  HorseRenderer m_horseRenderer;
+
+public:
+  void getVariant(const DrawContext &ctx, uint32_t seed,
+                  HumanoidVariant &v) const override {
+    QVector3D const team_tint = resolveTeamTint(ctx);
+    v.palette = makeHumanoidPalette(team_tint, seed);
+  }
+
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    std::string nation;
+    if (ctx.entity != nullptr) {
+      if (auto *unit =
+              ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+        nation = unit->nation_id;
+      }
+    }
+    if (!nation.empty()) {
+      return QString::fromStdString(std::string("mounted_knight_") + nation);
+    }
+    return QStringLiteral("mounted_knight");
+  }
+
+  void customizePose(const DrawContext &ctx,
+                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                     HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    const AnimationInputs &anim = anim_ctx.inputs;
+
+    const float arm_height_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.03F;
+    const float arm_asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
+
+    uint32_t horse_seed = seed;
+    if (ctx.entity != nullptr) {
+      horse_seed = static_cast<uint32_t>(
+          reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU);
+    }
+
+    const HorseDimensions dims = makeHorseDimensions(horse_seed);
+    HorseProfile mount_profile{};
+    mount_profile.dims = dims;
+    const HorseMountFrame mount = compute_mount_frame(mount_profile);
+
+    const float saddle_height = mount.seat_position.y();
+    const float offset_y = saddle_height - pose.pelvisPos.y();
+
+    pose.pelvisPos.setY(pose.pelvisPos.y() + offset_y);
+    pose.headPos.setY(pose.headPos.y() + offset_y);
+    pose.neck_base.setY(pose.neck_base.y() + offset_y);
+    pose.shoulderL.setY(pose.shoulderL.y() + offset_y);
+    pose.shoulderR.setY(pose.shoulderR.y() + offset_y);
+
+    float const speed_norm = anim_ctx.locomotion_normalized_speed();
+    float const speed_lean = std::clamp(
+        anim_ctx.locomotion_speed() * 0.10F + speed_norm * 0.05F, 0.0F, 0.22F);
+    const float lean_forward = dims.seatForwardOffset * 0.08F + speed_lean;
+    pose.shoulderL.setZ(pose.shoulderL.z() + lean_forward);
+    pose.shoulderR.setZ(pose.shoulderR.z() + lean_forward);
+
+    pose.footYOffset = 0.0F;
+    pose.footL = mount.stirrup_bottom_left;
+    pose.foot_r = mount.stirrup_bottom_right;
+
+    const float knee_y =
+        mount.stirrup_bottom_left.y() +
+        (saddle_height - mount.stirrup_bottom_left.y()) * 0.62F;
+    const float knee_z = mount.stirrup_bottom_left.z() * 0.60F + 0.06F;
+
+    QVector3D knee_left = mount.stirrup_attach_left;
+    knee_left.setY(knee_y);
+    knee_left.setZ(knee_z);
+    pose.knee_l = knee_left;
+
+    QVector3D knee_right = mount.stirrup_attach_right;
+    knee_right.setY(knee_y);
+    knee_right.setZ(knee_z);
+    pose.knee_r = knee_right;
+
+    float const shoulder_height = pose.shoulderL.y();
+    float const rein_extension = std::clamp(
+        speed_norm * 0.14F + anim_ctx.locomotion_speed() * 0.015F, 0.0F, 0.12F);
+    float const rein_drop = std::clamp(
+        speed_norm * 0.06F + anim_ctx.locomotion_speed() * 0.008F, 0.0F, 0.04F);
+
+    QVector3D forward = anim_ctx.heading_forward();
+    QVector3D right = anim_ctx.heading_right();
+    QVector3D up = anim_ctx.heading_up();
+    float const rein_spread =
+        std::abs(mount.rein_attach_right.x() - mount.rein_attach_left.x()) *
+        0.5F;
+
+    QVector3D rest_hand_r = mount.rein_attach_right;
+    rest_hand_r += forward * (0.08F + rein_extension);
+    rest_hand_r -= right * (0.10F - arm_asymmetry * 0.05F);
+    rest_hand_r += up * (0.05F + arm_height_jitter * 0.6F - rein_drop);
+
+    QVector3D rest_hand_l = mount.rein_attach_left;
+    rest_hand_l += forward * (0.05F + rein_extension * 0.6F);
+    rest_hand_l += right * (0.08F + arm_asymmetry * 0.04F);
+    rest_hand_l += up * (0.04F - arm_height_jitter * 0.5F - rein_drop * 0.6F);
+
+    float const rein_forward = rest_hand_r.z();
+
+    pose.elbowL =
+        QVector3D(pose.shoulderL.x() * 0.4F + rest_hand_l.x() * 0.6F,
+                  (pose.shoulderL.y() + rest_hand_l.y()) * 0.5F - 0.08F,
+                  (pose.shoulderL.z() + rest_hand_l.z()) * 0.5F);
+    pose.elbowR =
+        QVector3D(pose.shoulderR.x() * 0.4F + rest_hand_r.x() * 0.6F,
+                  (pose.shoulderR.y() + rest_hand_r.y()) * 0.5F - 0.08F,
+                  (pose.shoulderR.z() + rest_hand_r.z()) * 0.5F);
+
+    if (anim.is_attacking && anim.isMelee) {
+      float const attack_phase =
+          std::fmod(anim.time * MOUNTED_KNIGHT_INV_ATTACK_CYCLE_TIME, 1.0F);
+
+      QVector3D const rest_pos = rest_hand_r;
+      QVector3D const windup_pos =
+          QVector3D(rest_hand_r.x() + 0.32F, shoulder_height + 0.15F,
+                    rein_forward - 0.35F);
+      QVector3D const raised_pos = QVector3D(
+          rein_spread + 0.38F, shoulder_height + 0.28F, rein_forward - 0.25F);
+      QVector3D const slash_pos = QVector3D(
+          -rein_spread * 0.65F, shoulder_height - 0.08F, rein_forward + 0.85F);
+      QVector3D const follow_through = QVector3D(
+          -rein_spread * 0.85F, shoulder_height - 0.15F, rein_forward + 0.60F);
+      QVector3D const recover_pos = QVector3D(
+          rein_spread * 0.45F, shoulder_height - 0.05F, rein_forward + 0.25F);
+
+      if (attack_phase < 0.18F) {
+
+        float const t = easeInOutCubic(attack_phase / 0.18F);
+        pose.hand_r = rest_pos * (1.0F - t) + windup_pos * t;
+      } else if (attack_phase < 0.30F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.18F) / 0.12F);
+        pose.hand_r = windup_pos * (1.0F - t) + raised_pos * t;
+      } else if (attack_phase < 0.48F) {
+
+        float t = (attack_phase - 0.30F) / 0.18F;
+        t = t * t * t;
+        pose.hand_r = raised_pos * (1.0F - t) + slash_pos * t;
+      } else if (attack_phase < 0.62F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.48F) / 0.14F);
+        pose.hand_r = slash_pos * (1.0F - t) + follow_through * t;
+      } else if (attack_phase < 0.80F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.62F) / 0.18F);
+        pose.hand_r = follow_through * (1.0F - t) + recover_pos * t;
+      } else {
+
+        float const t = smoothstep(0.80F, 1.0F, attack_phase);
+        pose.hand_r = recover_pos * (1.0F - t) + rest_pos * t;
+      }
+
+      float const rein_tension = clamp01((attack_phase - 0.10F) * 2.2F);
+      pose.handL = rest_hand_l + QVector3D(0.0F, -0.015F * rein_tension,
+                                           0.10F * rein_tension);
+
+      pose.elbowR =
+          QVector3D(pose.shoulderR.x() * 0.3F + pose.hand_r.x() * 0.7F,
+                    (pose.shoulderR.y() + pose.hand_r.y()) * 0.5F - 0.12F,
+                    (pose.shoulderR.z() + pose.hand_r.z()) * 0.5F);
+      pose.elbowL =
+          QVector3D(pose.shoulderL.x() * 0.4F + pose.handL.x() * 0.6F,
+                    (pose.shoulderL.y() + pose.handL.y()) * 0.5F - 0.08F,
+                    (pose.shoulderL.z() + pose.handL.z()) * 0.5F);
+    } else {
+      pose.hand_r = rest_hand_r;
+      pose.handL = rest_hand_l;
+    }
+  }
+
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose,
+                      const HumanoidAnimationContext &anim_ctx,
+                      ISubmitter &out) const override {
+    const AnimationInputs &anim = anim_ctx.inputs;
+    uint32_t horse_seed = 0U;
+    if (ctx.entity != nullptr) {
+      horse_seed = static_cast<uint32_t>(
+          reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU);
+    }
+
+    MountedKnightExtras extras;
+    auto it = m_extrasCache.find(horse_seed);
+    if (it != m_extrasCache.end()) {
+      extras = it->second;
+    } else {
+      extras = computeMountedKnightExtras(horse_seed, v);
+      m_extrasCache[horse_seed] = extras;
+
+      if (m_extrasCache.size() > MAX_EXTRAS_CACHE_SIZE) {
+        m_extrasCache.clear();
+      }
+    }
+
+    m_horseRenderer.render(ctx, anim, anim_ctx, extras.horseProfile, out);
+
+    bool const is_attacking = anim.is_attacking && anim.isMelee;
+    float attack_phase = 0.0F;
+    if (is_attacking) {
+      attack_phase =
+          std::fmod(anim.time * MOUNTED_KNIGHT_INV_ATTACK_CYCLE_TIME, 1.0F);
+    }
+
+    if (extras.hasSword) {
+      drawSword(ctx, pose, v, extras, is_attacking, attack_phase, out);
+    }
+
+    if (extras.hasCavalryShield) {
+      drawCavalryShield(ctx, pose, v, extras, out);
+    }
+  }
+
+  void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose, ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    const QVector3D steel_color = v.palette.metal * STEEL_TINT;
+
+    float helm_r = pose.headR * 1.15F;
+    QVector3D const helm_bot(0, pose.headPos.y() - pose.headR * 0.20F, 0);
+    QVector3D const helm_top(0, pose.headPos.y() + pose.headR * 1.40F, 0);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
+             steel_color, nullptr, 1.0F);
+
+    QVector3D const cap_top(0, pose.headPos.y() + pose.headR * 1.48F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
+             steel_color * 1.05F, nullptr, 1.0F);
+
+    const QVector3D ring_color = steel_color * 1.08F;
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 1.25F, 0), helm_r * 1.02F,
+         0.015F, ring_color);
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.50F, 0), helm_r * 1.02F,
+         0.015F, ring_color);
+    ring(QVector3D(0, pose.headPos.y() - pose.headR * 0.05F, 0), helm_r * 1.02F,
+         0.015F, ring_color);
+
+    const float visor_y = pose.headPos.y() + pose.headR * 0.15F;
+    const float visor_z = helm_r * 0.72F;
+    static const QVector3D visor_color(0.1F, 0.1F, 0.1F);
+
+    QVector3D const visor_hl(-helm_r * 0.35F, visor_y, visor_z);
+    QVector3D const visor_hr(helm_r * 0.35F, visor_y, visor_z);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, visor_hl, visor_hr, 0.012F),
+             visor_color, nullptr, 1.0F);
+
+    QVector3D const visor_vt(0, visor_y + helm_r * 0.25F, visor_z);
+    QVector3D const visor_vb(0, visor_y - helm_r * 0.25F, visor_z);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, visor_vb, visor_vt, 0.012F),
+             visor_color, nullptr, 1.0F);
+
+    auto draw_breathing_hole = [&](float x, float y) {
+      QVector3D const pos(x, pose.headPos.y() + y, helm_r * 0.70F);
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.010F);
+      out.mesh(getUnitSphere(), m, QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
+    };
+
+    for (int i = 0; i < 4; ++i) {
+      draw_breathing_hole(helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+    }
+
+    for (int i = 0; i < 4; ++i) {
+      draw_breathing_hole(-helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+    }
+
+    const QVector3D plume_base(0, pose.headPos.y() + pose.headR * 1.50F, 0);
+    const QVector3D brass_color = v.palette.metal * BRASS_TINT;
+
+    QMatrix4x4 plume = ctx.model;
+    plume.translate(plume_base);
+    plume.scale(0.030F, 0.015F, 0.030F);
+    out.mesh(getUnitSphere(), plume, brass_color * 1.2F, nullptr, 1.0F);
+
+    for (int i = 0; i < 5; ++i) {
+      float const offset = i * 0.025F;
+      QVector3D const feather_start =
+          plume_base + QVector3D(0, 0.005F, -0.020F + offset * 0.5F);
+      QVector3D const feather_end =
+          feather_start +
+          QVector3D(0, 0.15F - i * 0.015F, -0.08F + offset * 0.3F);
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, feather_start, feather_end, 0.008F),
+               v.palette.cloth * (1.1F - i * 0.05F), nullptr, 1.0F);
+    }
+  }
+
+  void draw_armorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, float y_top_cover,
+                         float torso_r, float, float upper_arm_r,
+                         const QVector3D &right_axis,
+                         ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    const QVector3D steel_color = v.palette.metal * STEEL_TINT;
+    const QVector3D dark_steel = steel_color * 0.85F;
+    const QVector3D brass_color = v.palette.metal * BRASS_TINT;
+
+    QVector3D const bp_top(0, y_top_cover + 0.02F, 0);
+    QVector3D const bp_mid(0, (y_top_cover + pose.pelvisPos.y()) * 0.5F + 0.04F,
+                           0);
+    QVector3D const bp_bot(0, pose.pelvisPos.y() + 0.06F, 0);
+    float const r_chest = torso_r * 1.18F;
+    float const r_waist = torso_r * 1.14F;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, bp_top, bp_mid, r_chest), steel_color,
+             nullptr, 1.0F);
+
+    QVector3D const bp_mid_low(0, (bp_mid.y() + bp_bot.y()) * 0.5F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, bp_mid, bp_mid_low, r_chest * 0.98F),
+             steel_color * 0.99F, nullptr, 1.0F);
+
+    out.mesh(getUnitCone(), coneFromTo(ctx.model, bp_bot, bp_mid_low, r_waist),
+             steel_color * 0.98F, nullptr, 1.0F);
+
+    auto draw_rivet = [&](const QVector3D &pos) {
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.012F);
+      out.mesh(getUnitSphere(), m, brass_color, nullptr, 1.0F);
+    };
+
+    for (int i = 0; i < 8; ++i) {
+      float const angle = (i / 8.0F) * 2.0F * std::numbers::pi_v<float>;
+      float const x = r_chest * std::sin(angle) * 0.95F;
+      float const z = r_chest * std::cos(angle) * 0.95F;
+      draw_rivet(QVector3D(x, bp_mid.y() + 0.08F, z));
+    }
+
+    auto draw_pauldron = [&](const QVector3D &shoulder,
+                             const QVector3D &outward) {
+      for (int i = 0; i < 4; ++i) {
+        float const seg_y = shoulder.y() + 0.04F - i * 0.045F;
+        float const seg_r = upper_arm_r * (2.5F - i * 0.12F);
+        QVector3D seg_pos = shoulder + outward * (0.02F + i * 0.008F);
+        seg_pos.setY(seg_y);
+
+        out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_pos, seg_r),
+                 i == 0 ? steel_color * 1.05F
+                        : steel_color * (1.0F - i * 0.03F),
+                 nullptr, 1.0F);
+
+        if (i < 3) {
+          draw_rivet(seg_pos + QVector3D(0, 0.015F, 0.03F));
+        }
+      }
+    };
+
+    draw_pauldron(pose.shoulderL, -right_axis);
+    draw_pauldron(pose.shoulderR, right_axis);
+
+    auto draw_arm_plate = [&](const QVector3D &shoulder,
+                              const QVector3D &elbow) {
+      QVector3D dir = (elbow - shoulder);
+      float const len = dir.length();
+      if (len < 1e-5F) {
+        return;
+      }
+      dir /= len;
+
+      for (int i = 0; i < 3; ++i) {
+        float const t0 = 0.10F + i * 0.25F;
+        float const t1 = t0 + 0.22F;
+        QVector3D const a = shoulder + dir * (t0 * len);
+        QVector3D const b = shoulder + dir * (t1 * len);
+        float const r = upper_arm_r * (1.32F - i * 0.04F);
+
+        out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+                 steel_color * (0.98F - i * 0.02F), nullptr, 1.0F);
+
+        if (i < 2) {
+          draw_rivet(b);
+        }
+      }
+    };
+
+    draw_arm_plate(pose.shoulderL, pose.elbowL);
+    draw_arm_plate(pose.shoulderR, pose.elbowR);
+
+    QVector3D const gorget_top(0, y_top_cover + 0.025F, 0);
+    QVector3D const gorget_bot(0, y_top_cover - 0.012F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, gorget_bot, gorget_top,
+                             HP::NECK_RADIUS * 2.6F),
+             steel_color * 1.08F, nullptr, 1.0F);
+
+    ring(gorget_top, HP::NECK_RADIUS * 2.62F, 0.010F, brass_color);
+  }
+
+  void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
+                               const HumanoidPose &, float, float y_neck,
+                               const QVector3D &,
+                               ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    const QVector3D brass_color = v.palette.metal * BRASS_TINT;
+    const QVector3D chainmail_color = v.palette.metal * CHAINMAIL_TINT;
+    const QVector3D mantling_color = v.palette.cloth;
+
+    for (int i = 0; i < 5; ++i) {
+      float const y = y_neck - i * 0.022F;
+      float const r = HP::NECK_RADIUS * (1.85F + i * 0.08F);
+      QVector3D const ring_pos(0, y, 0);
+      QVector3D const a = ring_pos + QVector3D(0, 0.010F, 0);
+      QVector3D const b = ring_pos - QVector3D(0, 0.010F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+               chainmail_color * (1.0F - i * 0.04F), nullptr, 1.0F);
+    }
+
+    auto draw_stud = [&](const QVector3D &pos) {
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.008F);
+      out.mesh(getUnitSphere(), m, brass_color * 1.3F, nullptr, 1.0F);
+    };
+
+    QVector3D const belt_center(0, HP::WAIST_Y + 0.03F,
+                                HP::TORSO_BOT_R * 1.15F);
+    QMatrix4x4 buckle = ctx.model;
+    buckle.translate(belt_center);
+    buckle.scale(0.035F, 0.025F, 0.012F);
+    out.mesh(getUnitSphere(), buckle, brass_color * 1.25F, nullptr, 1.0F);
+
+    QVector3D const buckle_h1 = belt_center + QVector3D(-0.025F, 0, 0.005F);
+    QVector3D const buckle_h2 = belt_center + QVector3D(0.025F, 0, 0.005F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, buckle_h1, buckle_h2, 0.006F),
+             brass_color * 1.4F, nullptr, 1.0F);
+
+    QVector3D const buckle_v1 = belt_center + QVector3D(0, -0.018F, 0.005F);
+    QVector3D const buckle_v2 = belt_center + QVector3D(0, 0.018F, 0.005F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, buckle_v1, buckle_v2, 0.006F),
+             brass_color * 1.4F, nullptr, 1.0F);
+  }
+
+private:
+  static auto
+  computeMountedKnightExtras(uint32_t seed,
+                             const HumanoidVariant &v) -> MountedKnightExtras {
+    MountedKnightExtras e;
+
+    e.metalColor = QVector3D(0.72F, 0.73F, 0.78F);
+
+    e.horseProfile = makeHorseProfile(seed, v.palette.leather, v.palette.cloth);
+
+    e.swordLength = 0.82F + (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.12F;
+    e.swordWidth = 0.042F + (hash_01(seed ^ 0x7777U) - 0.5F) * 0.008F;
+
+    e.hasSword = (hash_01(seed ^ 0xFACEU) > 0.15F);
+    e.hasCavalryShield = (hash_01(seed ^ 0xCAFEU) > 0.60F);
+
+    return e;
+  }
+
+  static void drawSword(const DrawContext &ctx, const HumanoidPose &pose,
+                        const HumanoidVariant &v,
+                        const MountedKnightExtras &extras, bool is_attacking,
+                        float attack_phase, ISubmitter &out) {
+
+    const QVector3D grip_pos = pose.hand_r;
+
+    QVector3D sword_dir(0.0F, 0.15F, 1.0F);
+    sword_dir.normalize();
+
+    QVector3D const world_up(0.0F, 1.0F, 0.0F);
+    QVector3D right_axis = QVector3D::crossProduct(world_up, sword_dir);
+    if (right_axis.lengthSquared() < 1e-6F) {
+      right_axis = QVector3D(1.0F, 0.0F, 0.0F);
+    }
+    right_axis.normalize();
+    QVector3D up_axis = QVector3D::crossProduct(sword_dir, right_axis);
+    up_axis.normalize();
+
+    const QVector3D steel = extras.metalColor;
+    const QVector3D steel_hi = steel * 1.18F;
+    const QVector3D steel_lo = steel * 0.92F;
+    const QVector3D leather = v.palette.leather;
+    const QVector3D pommel_col =
+        v.palette.metal * QVector3D(1.25F, 1.10F, 0.75F);
+
+    const float pommel_offset = 0.10F;
+    const float grip_len = 0.16F;
+    const float grip_rad = 0.017F;
+    const float guard_half = 0.11F;
+    const float guard_rad = 0.012F;
+    const float guard_curve = 0.03F;
+
+    const QVector3D pommel_pos = grip_pos - sword_dir * pommel_offset;
+    out.mesh(getUnitSphere(), sphereAt(ctx.model, pommel_pos, 0.028F),
+             pommel_col, nullptr, 1.0F);
+
+    {
+      QVector3D const neck_a = pommel_pos + sword_dir * 0.010F;
+      QVector3D const neck_b = grip_pos - sword_dir * 0.005F;
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, neck_a, neck_b, 0.0125F), steel_lo,
+               nullptr, 1.0F);
+
+      QVector3D const peen = pommel_pos - sword_dir * 0.012F;
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, peen, pommel_pos, 0.010F),
+               steel, nullptr, 1.0F);
+    }
+
+    const QVector3D grip_a = grip_pos - sword_dir * 0.005F;
+    const QVector3D grip_b = grip_pos + sword_dir * (grip_len - 0.005F);
+    const int wrap_rings = 5;
+    for (int i = 0; i < wrap_rings; ++i) {
+      float const t0 = (float)i / wrap_rings;
+      float const t1 = (float)(i + 1) / wrap_rings;
+      QVector3D const a = grip_a + sword_dir * (grip_len * t0);
+      QVector3D const b = grip_a + sword_dir * (grip_len * t1);
+
+      float const r_mid =
+          grip_rad *
+          (0.96F + 0.08F * std::sin((t0 + t1) * std::numbers::pi_v<float>));
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r_mid),
+               leather * 0.98F, nullptr, 1.0F);
+    }
+
+    const QVector3D guard_center = grip_b + sword_dir * 0.010F;
+    {
+      const int segs = 4;
+      QVector3D prev =
+          guard_center - right_axis * guard_half + (-up_axis * guard_curve);
+      for (int s = 1; s <= segs; ++s) {
+        float const u = -1.0F + 2.0F * (float)s / segs;
+        QVector3D const p = guard_center + right_axis * (guard_half * u) +
+                            (-up_axis * guard_curve * (1.0F - u * u));
+        out.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, prev, p, guard_rad), steel_hi,
+                 nullptr, 1.0F);
+        prev = p;
+      }
+
+      QVector3D const lend =
+          guard_center - right_axis * guard_half + (-up_axis * guard_curve);
+      QVector3D const rend =
+          guard_center + right_axis * guard_half + (-up_axis * guard_curve);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, lend - right_axis * 0.030F, lend,
+                          guard_rad * 1.12F),
+               steel_hi, nullptr, 1.0F);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, rend + right_axis * 0.030F, rend,
+                          guard_rad * 1.12F),
+               steel_hi, nullptr, 1.0F);
+
+      out.mesh(getUnitSphere(),
+               sphereAt(ctx.model, guard_center, guard_rad * 0.9F), steel,
+               nullptr, 1.0F);
+    }
+
+    const float blade_len = std::max(0.0F, extras.swordLength - 0.14F);
+    const QVector3D blade_root = guard_center + sword_dir * 0.020F;
+    const QVector3D blade_tip = blade_root + sword_dir * blade_len;
+
+    const QVector3D ricasso_end = blade_root + sword_dir * (blade_len * 0.08F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, blade_root, ricasso_end,
+                             extras.swordWidth * 0.32F),
+             steel_hi, nullptr, 1.0F);
+
+    const QVector3D fuller_a = blade_root + sword_dir * (blade_len * 0.10F);
+    const QVector3D fuller_b = blade_root + sword_dir * (blade_len * 0.80F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, fuller_a, fuller_b,
+                             extras.swordWidth * 0.10F),
+             steel_lo, nullptr, 1.0F);
+
+    const float base_r = extras.swordWidth * 0.26F;
+    const float mid_r = extras.swordWidth * 0.16F;
+    const float pre_tip_r = extras.swordWidth * 0.09F;
+
+    QVector3D const s0 = ricasso_end;
+    QVector3D const s1 = blade_root + sword_dir * (blade_len * 0.55F);
+    QVector3D const s2 = blade_root + sword_dir * (blade_len * 0.88F);
+
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, s0, s1, base_r),
+             steel_hi, nullptr, 1.0F);
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, s1, s2, mid_r),
+             steel_hi, nullptr, 1.0F);
+
+    {
+      float const edge_r = extras.swordWidth * 0.03F;
+      QVector3D const e_a = blade_root + sword_dir * (blade_len * 0.10F);
+      QVector3D const e_b = blade_tip - sword_dir * (blade_len * 0.06F);
+      QVector3D const left_edge_a = e_a + right_axis * (base_r * 0.95F);
+      QVector3D const left_edge_b = e_b + right_axis * (pre_tip_r * 0.95F);
+      QVector3D const right_edge_a = e_a - right_axis * (base_r * 0.95F);
+      QVector3D const right_edge_b = e_b - right_axis * (pre_tip_r * 0.95F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, left_edge_a, left_edge_b, edge_r),
+               steel * 1.08F, nullptr, 1.0F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, right_edge_a, right_edge_b, edge_r),
+               steel * 1.08F, nullptr, 1.0F);
+    }
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, s2, blade_tip - sword_dir * 0.020F,
+                             pre_tip_r),
+             steel_hi, nullptr, 1.0F);
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, blade_tip, blade_tip - sword_dir * 0.060F,
+                        pre_tip_r * 0.95F),
+             steel_hi * 1.04F, nullptr, 1.0F);
+
+    {
+      QVector3D const shoulder_l0 = blade_root + right_axis * (base_r * 1.05F);
+      QVector3D const shoulder_l1 = shoulder_l0 - right_axis * (base_r * 0.45F);
+      QVector3D const shoulder_r0 = blade_root - right_axis * (base_r * 1.05F);
+      QVector3D const shoulder_r1 = shoulder_r0 + right_axis * (base_r * 0.45F);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, shoulder_l1, shoulder_l0, base_r * 0.22F),
+               steel, nullptr, 1.0F);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, shoulder_r1, shoulder_r0, base_r * 0.22F),
+               steel, nullptr, 1.0F);
+    }
+
+    if (is_attacking && attack_phase >= 0.28F && attack_phase < 0.58F) {
+      float const t = (attack_phase - 0.28F) / 0.30F;
+      float const alpha = clamp01(0.40F * (1.0F - t * t));
+      QVector3D const sweep = (-right_axis * 0.18F - sword_dir * 0.10F) * t;
+
+      QVector3D const trail_tip = blade_tip + sweep;
+      QVector3D const trail_root = blade_root + sweep * 0.6F;
+
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, trail_root, trail_tip, base_r * 1.10F),
+               steel * 0.90F, nullptr, alpha);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, trail_root + up_axis * 0.01F, trail_tip,
+                          base_r * 0.75F),
+               steel * 0.80F, nullptr, alpha * 0.7F);
+    }
+  }
+
+  static void drawCavalryShield(const DrawContext &ctx,
+                                const HumanoidPose &pose,
+                                const HumanoidVariant &v,
+                                const MountedKnightExtras &extras,
+                                ISubmitter &out) {
+    const float scale_factor = 2.0F;
+    const float r = 0.15F * scale_factor;
+
+    constexpr float k_mounted_shield_yaw_degrees = -70.0F;
+    QMatrix4x4 rot;
+    rot.rotate(k_mounted_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+
+    const QVector3D n = rot.map(QVector3D(0.0F, 0.0F, 1.0F));
+    const QVector3D axis_x = rot.map(QVector3D(1.0F, 0.0F, 0.0F));
+    const QVector3D axis_y = rot.map(QVector3D(0.0F, 1.0F, 0.0F));
+
+    QVector3D const shield_center =
+        pose.handL + axis_x * (-r * 0.30F) + axis_y * (-0.05F) + n * (0.05F);
+
+    const float plate_half = 0.0012F;
+    const float plate_full = plate_half * 2.0F;
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center + n * plate_half);
+      m.rotate(k_mounted_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+      m.scale(r, r, plate_full);
+      out.mesh(getUnitCylinder(), m, v.palette.cloth * 1.15F, nullptr, 1.0F);
+    }
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center - n * plate_half);
+      m.rotate(k_mounted_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+      m.scale(r * 0.985F, r * 0.985F, plate_full);
+      out.mesh(getUnitCylinder(), m, v.palette.leather * 0.8F, nullptr, 1.0F);
+    }
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center + n * (0.015F * scale_factor));
+      m.scale(0.035F * scale_factor);
+      out.mesh(getUnitSphere(), m, extras.metalColor, nullptr, 1.0F);
+    }
+
+    {
+      QVector3D const grip_a = shield_center - axis_x * 0.025F - n * 0.025F;
+      QVector3D const grip_b = shield_center + axis_x * 0.025F - n * 0.025F;
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, grip_a, grip_b, 0.008F),
+               v.palette.leather, nullptr, 1.0F);
+    }
+  }
+};
+
+void registerMountedKnightRenderer(
+    Render::GL::EntityRendererRegistry &registry) {
+  static MountedKnightRenderer const renderer;
+  registry.registerRenderer(
+      "troops/carthage/mounted_knight",
+      [](const DrawContext &ctx, ISubmitter &out) {
+        static MountedKnightRenderer const static_renderer;
+        Shader *mounted_knight_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          mounted_knight_shader = ctx.backend->shader(shader_key);
+          if (mounted_knight_shader == nullptr) {
+            mounted_knight_shader =
+                ctx.backend->shader(QStringLiteral("mounted_knight"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) && (mounted_knight_shader != nullptr)) {
+          scene_renderer->setCurrentShader(mounted_knight_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Carthage

+ 9 - 0
render/entity/nations/carthage/mounted_knight_renderer.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include "../../registry.h"
+
+namespace Render::GL::Carthage {
+
+void registerMountedKnightRenderer(EntityRendererRegistry &registry);
+
+}

+ 578 - 0
render/entity/nations/carthage/spearman_renderer.cpp

@@ -0,0 +1,578 @@
+#include "spearman_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/shader.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
+#include "../../../humanoid_math.h"
+#include "../../../humanoid_specs.h"
+#include "../../../palette.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../registry.h"
+#include "../../renderer_constants.h"
+#include "spearman_style.h"
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+#include <optional>
+#include <qstringliteral.h>
+#include <qvectornd.h>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+namespace Render::GL::Carthage {
+
+namespace {
+
+constexpr std::string_view k_spearman_default_style_key = "default";
+constexpr float k_spearman_team_mix_weight = 0.6F;
+constexpr float k_spearman_style_mix_weight = 0.4F;
+
+auto spearman_style_registry()
+    -> std::unordered_map<std::string, SpearmanStyleConfig> & {
+  static std::unordered_map<std::string, SpearmanStyleConfig> styles;
+  return styles;
+}
+
+void ensure_spearman_styles_registered() {
+  static const bool registered = []() {
+    register_carthage_spearman_style();
+    return true;
+  }();
+  (void)registered;
+}
+
+} // namespace
+
+void register_spearman_style(const std::string &nation_id,
+                             const SpearmanStyleConfig &style) {
+  spearman_style_registry()[nation_id] = style;
+}
+
+using Render::Geom::clamp01;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::easeInOutCubic;
+using Render::Geom::lerp;
+using Render::Geom::smoothstep;
+using Render::Geom::sphereAt;
+using Render::GL::Humanoid::mix_palette_color;
+using Render::GL::Humanoid::saturate_color;
+
+struct SpearmanExtras {
+  QVector3D spearShaftColor;
+  QVector3D spearheadColor;
+  float spearLength = 1.20F;
+  float spearShaftRadius = 0.020F;
+  float spearheadLength = 0.18F;
+};
+
+class SpearmanRenderer : public HumanoidRendererBase {
+public:
+  auto getProportionScaling() const -> QVector3D override {
+    return {1.10F, 1.02F, 1.05F};
+  }
+
+private:
+  mutable std::unordered_map<uint32_t, SpearmanExtras> m_extrasCache;
+
+public:
+  void getVariant(const DrawContext &ctx, uint32_t seed,
+                  HumanoidVariant &v) const override {
+    QVector3D const team_tint = resolveTeamTint(ctx);
+    v.palette = makeHumanoidPalette(team_tint, seed);
+    auto const &style = resolve_style(ctx);
+    apply_palette_overrides(style, team_tint, v);
+  }
+
+  void customizePose(const DrawContext &,
+                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                     HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    const AnimationInputs &anim = anim_ctx.inputs;
+
+    float const arm_height_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.03F;
+    float const arm_asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
+
+    if (anim.isInHoldMode || anim.isExitingHold) {
+      float const t = anim.isInHoldMode ? 1.0F : (1.0F - anim.holdExitProgress);
+
+      float const kneel_depth = 0.35F * t;
+      float const pelvis_y = HP::WAIST_Y - kneel_depth;
+      pose.pelvisPos.setY(pelvis_y);
+
+      float const stance_narrow = 0.10F;
+
+      float const left_knee_y = HP::GROUND_Y + 0.06F * t;
+      float const left_knee_z = -0.08F * t;
+      pose.knee_l = QVector3D(-stance_narrow, left_knee_y, left_knee_z);
+      pose.footL = QVector3D(-stance_narrow - 0.02F, HP::GROUND_Y,
+                             left_knee_z - HP::LOWER_LEG_LEN * 0.90F * t);
+
+      float const right_knee_y =
+          HP::WAIST_Y * 0.45F * (1.0F - t) + HP::WAIST_Y * 0.30F * t;
+      pose.knee_r = QVector3D(stance_narrow + 0.05F, right_knee_y, 0.15F * t);
+      pose.foot_r = QVector3D(stance_narrow + 0.08F, HP::GROUND_Y, 0.25F * t);
+
+      float const upper_body_drop = kneel_depth;
+      pose.shoulderL.setY(HP::SHOULDER_Y - upper_body_drop);
+      pose.shoulderR.setY(HP::SHOULDER_Y - upper_body_drop);
+      pose.neck_base.setY(HP::NECK_BASE_Y - upper_body_drop);
+
+      float const lowered_chin_y = HP::CHIN_Y - upper_body_drop;
+
+      pose.headPos.setY(lowered_chin_y + pose.headR);
+
+      float const forward_lean = 0.08F * t;
+      pose.shoulderL.setZ(pose.shoulderL.z() + forward_lean);
+      pose.shoulderR.setZ(pose.shoulderR.z() + forward_lean);
+      pose.neck_base.setZ(pose.neck_base.z() + forward_lean * 0.8F);
+      pose.headPos.setZ(pose.headPos.z() + forward_lean * 0.7F);
+
+      float const lowered_shoulder_y = HP::SHOULDER_Y - upper_body_drop;
+
+      pose.hand_r =
+          QVector3D(0.18F * (1.0F - t) + 0.22F * t,
+                    lowered_shoulder_y * (1.0F - t) + (pelvis_y + 0.05F) * t,
+                    0.15F * (1.0F - t) + 0.20F * t);
+
+      pose.handL = QVector3D(0.0F,
+                             lowered_shoulder_y * (1.0F - t) +
+                                 (lowered_shoulder_y - 0.10F) * t,
+                             0.30F * (1.0F - t) + 0.55F * t);
+
+      QVector3D const shoulder_to_hand_r = pose.hand_r - pose.shoulderR;
+      float const arm_length_r = shoulder_to_hand_r.length();
+      QVector3D const arm_dir_r = shoulder_to_hand_r.normalized();
+      pose.elbowR = pose.shoulderR + arm_dir_r * (arm_length_r * 0.5F) +
+                    QVector3D(0.08F, -0.15F, -0.05F);
+
+      QVector3D const shoulder_to_hand_l = pose.handL - pose.shoulderL;
+      float const arm_length_l = shoulder_to_hand_l.length();
+      QVector3D const arm_dir_l = shoulder_to_hand_l.normalized();
+      pose.elbowL = pose.shoulderL + arm_dir_l * (arm_length_l * 0.5F) +
+                    QVector3D(-0.08F, -0.12F, 0.05F);
+
+    } else if (anim.is_attacking && anim.isMelee && !anim.isInHoldMode) {
+      float const attack_phase =
+          std::fmod(anim.time * SPEARMAN_INV_ATTACK_CYCLE_TIME, 1.0F);
+
+      QVector3D const guard_pos(0.28F, HP::SHOULDER_Y + 0.05F, 0.25F);
+      QVector3D const prepare_pos(0.35F, HP::SHOULDER_Y + 0.08F, 0.05F);
+      QVector3D const thrust_pos(0.32F, HP::SHOULDER_Y + 0.10F, 0.90F);
+      QVector3D const recover_pos(0.28F, HP::SHOULDER_Y + 0.06F, 0.40F);
+
+      if (attack_phase < 0.20F) {
+
+        float const t = easeInOutCubic(attack_phase / 0.20F);
+        pose.hand_r = guard_pos * (1.0F - t) + prepare_pos * t;
+
+        pose.handL = QVector3D(-0.10F, HP::SHOULDER_Y - 0.05F,
+                               0.20F * (1.0F - t) + 0.08F * t);
+      } else if (attack_phase < 0.30F) {
+
+        pose.hand_r = prepare_pos;
+        pose.handL = QVector3D(-0.10F, HP::SHOULDER_Y - 0.05F, 0.08F);
+      } else if (attack_phase < 0.50F) {
+
+        float t = (attack_phase - 0.30F) / 0.20F;
+        t = t * t * t;
+        pose.hand_r = prepare_pos * (1.0F - t) + thrust_pos * t;
+
+        pose.handL =
+            QVector3D(-0.10F + 0.05F * t, HP::SHOULDER_Y - 0.05F + 0.03F * t,
+                      0.08F + 0.45F * t);
+      } else if (attack_phase < 0.70F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.50F) / 0.20F);
+        pose.hand_r = thrust_pos * (1.0F - t) + recover_pos * t;
+        pose.handL = QVector3D(-0.05F * (1.0F - t) - 0.10F * t,
+                               HP::SHOULDER_Y - 0.02F * (1.0F - t) - 0.06F * t,
+                               lerp(0.53F, 0.35F, t));
+      } else {
+
+        float const t = smoothstep(0.70F, 1.0F, attack_phase);
+        pose.hand_r = recover_pos * (1.0F - t) + guard_pos * t;
+        pose.handL = QVector3D(-0.10F - 0.02F * (1.0F - t),
+                               HP::SHOULDER_Y - 0.06F + 0.01F * t +
+                                   arm_height_jitter * (1.0F - t),
+                               lerp(0.35F, 0.25F, t));
+      }
+    } else {
+      pose.hand_r =
+          QVector3D(0.28F + arm_asymmetry,
+                    HP::SHOULDER_Y - 0.02F + arm_height_jitter, 0.30F);
+
+      pose.handL =
+          QVector3D(-0.08F - 0.5F * arm_asymmetry,
+                    HP::SHOULDER_Y - 0.08F + 0.5F * arm_height_jitter, 0.45F);
+
+      QVector3D const shoulder_to_hand = pose.hand_r - pose.shoulderR;
+      float const arm_length = shoulder_to_hand.length();
+      QVector3D const arm_dir = shoulder_to_hand.normalized();
+
+      pose.elbowR = pose.shoulderR + arm_dir * (arm_length * 0.5F) +
+                    QVector3D(0.06F, -0.12F, -0.04F);
+    }
+  }
+
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose,
+                      const HumanoidAnimationContext &anim_ctx,
+                      ISubmitter &out) const override {
+    const AnimationInputs &anim = anim_ctx.inputs;
+    uint32_t const seed = reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU;
+    auto const &style = resolve_style(ctx);
+    QVector3D const team_tint = resolveTeamTint(ctx);
+
+    SpearmanExtras extras;
+    auto it = m_extrasCache.find(seed);
+    if (it != m_extrasCache.end()) {
+      extras = it->second;
+    } else {
+      extras = computeSpearmanExtras(seed, v);
+      apply_extras_overrides(style, team_tint, v, extras);
+      m_extrasCache[seed] = extras;
+
+      if (m_extrasCache.size() > MAX_EXTRAS_CACHE_SIZE) {
+        m_extrasCache.clear();
+      }
+    }
+    apply_extras_overrides(style, team_tint, v, extras);
+
+    bool const is_attacking = anim.is_attacking && anim.isMelee;
+    float attack_phase = 0.0F;
+    if (is_attacking) {
+      attack_phase =
+          std::fmod(anim.time * SPEARMAN_INV_ATTACK_CYCLE_TIME, 1.0F);
+    }
+
+    drawSpear(ctx, pose, v, extras, anim, is_attacking, attack_phase, out);
+  }
+
+  void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose, ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    const QVector3D iron_color = v.palette.metal * IRON_TINT;
+
+    const float helm_r = pose.headR * 1.12F;
+
+    QVector3D const helm_bot(pose.headPos.x(),
+                             pose.headPos.y() - pose.headR * 0.15F,
+                             pose.headPos.z());
+    QVector3D const helm_top(pose.headPos.x(),
+                             pose.headPos.y() + pose.headR * 1.25F,
+                             pose.headPos.z());
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_bot, helm_top, helm_r), iron_color,
+             nullptr, 1.0F);
+
+    QVector3D const cap_top(pose.headPos.x(),
+                            pose.headPos.y() + pose.headR * 1.32F,
+                            pose.headPos.z());
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.96F),
+             iron_color * 1.04F, nullptr, 1.0F);
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    ring(QVector3D(pose.headPos.x(), pose.headPos.y() + pose.headR * 0.95F,
+                   pose.headPos.z()),
+         helm_r * 1.01F, 0.012F, iron_color * 1.06F);
+    ring(QVector3D(pose.headPos.x(), pose.headPos.y() - pose.headR * 0.02F,
+                   pose.headPos.z()),
+         helm_r * 1.01F, 0.012F, iron_color * 1.06F);
+
+    float const visor_y = pose.headPos.y() + pose.headR * 0.10F;
+    float const visor_z = pose.headPos.z() + helm_r * 0.68F;
+
+    for (int i = 0; i < 3; ++i) {
+      float const y = visor_y + pose.headR * (0.18F - i * 0.12F);
+      QVector3D const visor_l(pose.headPos.x() - helm_r * 0.30F, y, visor_z);
+      QVector3D const visor_r(pose.headPos.x() + helm_r * 0.30F, y, visor_z);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, visor_l, visor_r, 0.010F), DARK_METAL,
+               nullptr, 1.0F);
+    }
+  }
+
+  void draw_armorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, float y_top_cover,
+                         float torso_r, float, float upper_arm_r,
+                         const QVector3D &right_axis,
+                         ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    const QVector3D iron_color = v.palette.metal * IRON_TINT;
+    const QVector3D leather_color = v.palette.leather * 0.95F;
+
+    QVector3D const chest_top(0, y_top_cover + 0.02F, 0);
+    QVector3D const chest_bot(0, HP::WAIST_Y + 0.08F, 0);
+    float const r_chest = torso_r * 1.14F;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, chest_top, chest_bot, r_chest),
+             iron_color, nullptr, 1.0F);
+
+    auto draw_pauldron = [&](const QVector3D &shoulder,
+                             const QVector3D &outward) {
+      for (int i = 0; i < 3; ++i) {
+        float const seg_y = shoulder.y() + 0.03F - i * 0.040F;
+        float const seg_r = upper_arm_r * (2.2F - i * 0.10F);
+        QVector3D seg_pos = shoulder + outward * (0.015F + i * 0.006F);
+        seg_pos.setY(seg_y);
+
+        out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_pos, seg_r),
+                 i == 0 ? iron_color * 1.04F : iron_color * (1.0F - i * 0.02F),
+                 nullptr, 1.0F);
+      }
+    };
+
+    draw_pauldron(pose.shoulderL, -right_axis);
+    draw_pauldron(pose.shoulderR, right_axis);
+
+    auto draw_arm_plate = [&](const QVector3D &shoulder,
+                              const QVector3D &elbow) {
+      QVector3D dir = (elbow - shoulder);
+      float const len = dir.length();
+      if (len < 1e-5F) {
+        return;
+      }
+      dir /= len;
+
+      for (int i = 0; i < 2; ++i) {
+        float const t0 = 0.12F + i * 0.28F;
+        float const t1 = t0 + 0.24F;
+        QVector3D const a = shoulder + dir * (t0 * len);
+        QVector3D const b = shoulder + dir * (t1 * len);
+        float const r = upper_arm_r * (1.26F - i * 0.03F);
+
+        out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+                 iron_color * (0.96F - i * 0.02F), nullptr, 1.0F);
+      }
+    };
+
+    draw_arm_plate(pose.shoulderL, pose.elbowL);
+    draw_arm_plate(pose.shoulderR, pose.elbowR);
+
+    for (int i = 0; i < 3; ++i) {
+      float const y = HP::WAIST_Y + 0.06F - i * 0.035F;
+      float const r = torso_r * (1.12F + i * 0.020F);
+      QVector3D const strip_top(0, y, 0);
+      QVector3D const strip_bot(0, y - 0.030F, 0);
+
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, strip_top, strip_bot, r),
+               leather_color * (0.98F - i * 0.02F), nullptr, 1.0F);
+    }
+  }
+
+  void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
+                               const HumanoidPose &pose, float y_top_cover,
+                               float y_neck, const QVector3D &right_axis,
+                               ISubmitter &out) const override {}
+
+private:
+  static auto computeSpearmanExtras(uint32_t seed, const HumanoidVariant &v)
+      -> SpearmanExtras {
+    SpearmanExtras e;
+
+    e.spearShaftColor = v.palette.leather * QVector3D(0.85F, 0.75F, 0.65F);
+    e.spearheadColor = QVector3D(0.75F, 0.76F, 0.80F);
+
+    e.spearLength = 1.15F + (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.10F;
+    e.spearShaftRadius = 0.018F + (hash_01(seed ^ 0x7777U) - 0.5F) * 0.003F;
+    e.spearheadLength = 0.16F + (hash_01(seed ^ 0xBEEFU) - 0.5F) * 0.04F;
+
+    return e;
+  }
+
+  static void drawSpear(const DrawContext &ctx, const HumanoidPose &pose,
+                        const HumanoidVariant &v, const SpearmanExtras &extras,
+                        const AnimationInputs &anim, bool is_attacking,
+                        float attack_phase, ISubmitter &out) {
+    QVector3D const grip_pos = pose.hand_r;
+
+    QVector3D spear_dir = QVector3D(0.05F, 0.55F, 0.85F);
+    if (spear_dir.lengthSquared() > 1e-6F) {
+      spear_dir.normalize();
+    }
+
+    if (anim.isInHoldMode || anim.isExitingHold) {
+      float const t = anim.isInHoldMode ? 1.0F : (1.0F - anim.holdExitProgress);
+
+      QVector3D braced_dir = QVector3D(0.05F, 0.40F, 0.91F);
+      if (braced_dir.lengthSquared() > 1e-6F) {
+        braced_dir.normalize();
+      }
+
+      spear_dir = spear_dir * (1.0F - t) + braced_dir * t;
+      if (spear_dir.lengthSquared() > 1e-6F) {
+        spear_dir.normalize();
+      }
+    } else if (is_attacking) {
+      if (attack_phase >= 0.30F && attack_phase < 0.50F) {
+        float const t = (attack_phase - 0.30F) / 0.20F;
+
+        QVector3D attack_dir = QVector3D(0.03F, -0.15F, 1.0F);
+        if (attack_dir.lengthSquared() > 1e-6F) {
+          attack_dir.normalize();
+        }
+
+        spear_dir = spear_dir * (1.0F - t) + attack_dir * t;
+        if (spear_dir.lengthSquared() > 1e-6F) {
+          spear_dir.normalize();
+        }
+      }
+    }
+
+    QVector3D const shaft_base = grip_pos - spear_dir * 0.28F;
+    QVector3D shaft_mid = grip_pos + spear_dir * (extras.spearLength * 0.5F);
+    QVector3D const shaft_tip = grip_pos + spear_dir * extras.spearLength;
+
+    shaft_mid.setY(shaft_mid.y() + 0.02F);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, shaft_base, shaft_mid,
+                             extras.spearShaftRadius),
+             extras.spearShaftColor, nullptr, 1.0F);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, shaft_mid, shaft_tip,
+                             extras.spearShaftRadius * 0.95F),
+             extras.spearShaftColor * 0.98F, nullptr, 1.0F);
+
+    QVector3D const spearhead_base = shaft_tip;
+    QVector3D const spearhead_tip =
+        shaft_tip + spear_dir * extras.spearheadLength;
+
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, spearhead_base, spearhead_tip,
+                        extras.spearShaftRadius * 1.8F),
+             extras.spearheadColor, nullptr, 1.0F);
+
+    QVector3D const grip_end = grip_pos + spear_dir * 0.10F;
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, grip_pos, grip_end,
+                             extras.spearShaftRadius * 1.5F),
+             v.palette.leather * 0.92F, nullptr, 1.0F);
+  }
+
+  auto
+  resolve_style(const DrawContext &ctx) const -> const SpearmanStyleConfig & {
+    ensure_spearman_styles_registered();
+    auto &styles = spearman_style_registry();
+    std::string nation_id;
+    if (ctx.entity != nullptr) {
+      if (auto *unit =
+              ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+        nation_id = unit->nation_id;
+      }
+    }
+    if (!nation_id.empty()) {
+      auto it = styles.find(nation_id);
+      if (it != styles.end()) {
+        return it->second;
+      }
+    }
+    auto it_default = styles.find(std::string(k_spearman_default_style_key));
+    if (it_default != styles.end()) {
+      return it_default->second;
+    }
+    static const SpearmanStyleConfig k_empty{};
+    return k_empty;
+  }
+
+public:
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    const SpearmanStyleConfig &style = resolve_style(ctx);
+    if (!style.shader_id.empty()) {
+      return QString::fromStdString(style.shader_id);
+    }
+    return QStringLiteral("spearman");
+  }
+
+private:
+  void apply_palette_overrides(const SpearmanStyleConfig &style,
+                               const QVector3D &team_tint,
+                               HumanoidVariant &variant) const {
+    auto apply_color = [&](const std::optional<QVector3D> &override_color,
+                           QVector3D &target) {
+      target = mix_palette_color(target, override_color, team_tint,
+                                 k_spearman_team_mix_weight,
+                                 k_spearman_style_mix_weight);
+    };
+
+    apply_color(style.cloth_color, variant.palette.cloth);
+    apply_color(style.leather_color, variant.palette.leather);
+    apply_color(style.leather_dark_color, variant.palette.leatherDark);
+    apply_color(style.metal_color, variant.palette.metal);
+  }
+
+  void apply_extras_overrides(const SpearmanStyleConfig &style,
+                              const QVector3D &team_tint,
+                              [[maybe_unused]] const HumanoidVariant &variant,
+                              SpearmanExtras &extras) const {
+    extras.spearShaftColor = saturate_color(extras.spearShaftColor);
+    extras.spearheadColor = saturate_color(extras.spearheadColor);
+
+    auto apply_color = [&](const std::optional<QVector3D> &override_color,
+                           QVector3D &target) {
+      target = mix_palette_color(target, override_color, team_tint,
+                                 k_spearman_team_mix_weight,
+                                 k_spearman_style_mix_weight);
+    };
+
+    apply_color(style.spear_shaft_color, extras.spearShaftColor);
+    apply_color(style.spearhead_color, extras.spearheadColor);
+
+    if (style.spear_length_scale) {
+      extras.spearLength =
+          std::max(0.80F, extras.spearLength * *style.spear_length_scale);
+    }
+  }
+};
+
+void registerSpearmanRenderer(Render::GL::EntityRendererRegistry &registry) {
+  ensure_spearman_styles_registered();
+  static SpearmanRenderer const renderer;
+  registry.registerRenderer(
+      "troops/carthage/spearman", [](const DrawContext &ctx, ISubmitter &out) {
+        static SpearmanRenderer const static_renderer;
+        Shader *spearman_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          spearman_shader = ctx.backend->shader(shader_key);
+          if (spearman_shader == nullptr) {
+            spearman_shader = ctx.backend->shader(QStringLiteral("spearman"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) && (spearman_shader != nullptr)) {
+          scene_renderer->setCurrentShader(spearman_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Carthage

+ 14 - 0
render/entity/nations/carthage/spearman_renderer.h

@@ -0,0 +1,14 @@
+#pragma once
+
+#include "../../registry.h"
+#include "spearman_style.h"
+#include <string>
+
+namespace Render::GL::Carthage {
+
+void register_spearman_style(const std::string &nation_id,
+                             const SpearmanStyleConfig &style);
+
+void registerSpearmanRenderer(EntityRendererRegistry &registry);
+
+} // namespace Render::GL::Carthage

+ 31 - 0
render/entity/nations/carthage/spearman_style.cpp

@@ -0,0 +1,31 @@
+#include "spearman_style.h"
+#include "spearman_renderer.h"
+
+#include <QVector3D>
+
+namespace {
+constexpr QVector3D k_carthage_cloth{0.15F, 0.36F, 0.55F};
+constexpr QVector3D k_carthage_leather{0.30F, 0.20F, 0.12F};
+constexpr QVector3D k_carthage_leather_dark{0.18F, 0.12F, 0.08F};
+constexpr QVector3D k_carthage_metal{0.68F, 0.66F, 0.52F};
+constexpr QVector3D k_carthage_spear_shaft{0.40F, 0.26F, 0.14F};
+constexpr QVector3D k_carthage_spearhead{0.74F, 0.72F, 0.60F};
+} // namespace
+
+namespace Render::GL::Carthage {
+
+void register_carthage_spearman_style() {
+  SpearmanStyleConfig style;
+  style.cloth_color = k_carthage_cloth;
+  style.leather_color = k_carthage_leather;
+  style.leather_dark_color = k_carthage_leather_dark;
+  style.metal_color = k_carthage_metal;
+  style.spear_shaft_color = k_carthage_spear_shaft;
+  style.spearhead_color = k_carthage_spearhead;
+  style.spear_length_scale = 1.08F;
+  style.shader_id = "spearman_carthage";
+
+  register_spearman_style("carthage", style);
+}
+
+} // namespace Render::GL::Carthage

+ 23 - 0
render/entity/nations/carthage/spearman_style.h

@@ -0,0 +1,23 @@
+#pragma once
+
+#include <QVector3D>
+#include <optional>
+#include <string>
+
+namespace Render::GL::Carthage {
+
+struct SpearmanStyleConfig {
+  std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> leather_color;
+  std::optional<QVector3D> leather_dark_color;
+  std::optional<QVector3D> metal_color;
+  std::optional<QVector3D> spear_shaft_color;
+  std::optional<QVector3D> spearhead_color;
+  std::optional<float> spear_length_scale;
+  std::optional<float> spear_shaft_radius_scale;
+  std::string shader_id;
+};
+
+void register_carthage_spearman_style();
+
+} // namespace Render::GL::Carthage

+ 816 - 0
render/entity/nations/kingdom/archer_renderer.cpp

@@ -0,0 +1,816 @@
+#include "archer_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../../game/core/entity.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/render_constants.h"
+#include "../../../gl/shader.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
+#include "../../../humanoid_math.h"
+#include "../../../humanoid_specs.h"
+#include "../../../palette.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../registry.h"
+#include "../../renderer_constants.h"
+#include "archer_style.h"
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <cmath>
+#include <cstdint>
+#include <numbers>
+#include <optional>
+#include <qmatrix4x4.h>
+#include <qstringliteral.h>
+#include <qvectornd.h>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+namespace Render::GL::Kingdom {
+
+namespace {
+
+constexpr std::string_view k_default_style_key = "default";
+constexpr std::string_view k_attachment_headwrap = "carthage_headwrap";
+
+auto style_registry() -> std::unordered_map<std::string, ArcherStyleConfig> & {
+  static std::unordered_map<std::string, ArcherStyleConfig> styles;
+  return styles;
+}
+
+void ensure_archer_styles_registered() {
+  static const bool registered = []() {
+    register_kingdom_archer_style();
+    return true;
+  }();
+  (void)registered;
+}
+
+constexpr float k_team_mix_weight = 0.65F;
+constexpr float k_style_mix_weight = 0.35F;
+
+} // namespace
+
+void register_archer_style(const std::string &nation_id,
+                           const ArcherStyleConfig &style) {
+  style_registry()[nation_id] = style;
+}
+
+using Render::Geom::clamp01;
+using Render::Geom::clampf;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::sphereAt;
+using Render::GL::Humanoid::mix_palette_color;
+using Render::GL::Humanoid::saturate_color;
+
+struct ArcherExtras {
+  QVector3D stringCol;
+  QVector3D fletch;
+  QVector3D metalHead;
+  float bowRodR = 0.035F;
+  float stringR = 0.008F;
+  float bowDepth = 0.25F;
+  float bowX = 0.0F;
+  float bowTopY{};
+  float bowBotY{};
+};
+
+class ArcherRenderer : public HumanoidRendererBase {
+public:
+  auto getProportionScaling() const -> QVector3D override {
+
+    return {0.94F, 1.01F, 0.96F};
+  }
+
+  void getVariant(const DrawContext &ctx, uint32_t seed,
+                  HumanoidVariant &v) const override {
+    QVector3D const team_tint = resolveTeamTint(ctx);
+    v.palette = makeHumanoidPalette(team_tint, seed);
+    auto const &style = resolve_style(ctx);
+    apply_palette_overrides(style, team_tint, v);
+  }
+
+  void customizePose(const DrawContext &,
+                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                     HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    const AnimationInputs &anim = anim_ctx.inputs;
+
+    float const arm_height_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.03F;
+    float const arm_asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
+
+    float const bow_x = 0.0F;
+
+    if (anim.isInHoldMode || anim.isExitingHold) {
+
+      float const t = anim.isInHoldMode ? 1.0F : (1.0F - anim.holdExitProgress);
+
+      float const kneel_depth = 0.45F * t;
+
+      float const pelvis_y = HP::WAIST_Y - kneel_depth;
+      pose.pelvisPos.setY(pelvis_y);
+
+      float const stance_narrow = 0.12F;
+
+      float const left_knee_y = HP::GROUND_Y + 0.08F * t;
+      float const left_knee_z = -0.05F * t;
+
+      pose.knee_l = QVector3D(-stance_narrow, left_knee_y, left_knee_z);
+
+      pose.footL = QVector3D(-stance_narrow - 0.03F, HP::GROUND_Y,
+                             left_knee_z - HP::LOWER_LEG_LEN * 0.95F * t);
+
+      float const right_foot_z = 0.30F * t;
+      pose.foot_r = QVector3D(stance_narrow, HP::GROUND_Y + pose.footYOffset,
+                              right_foot_z);
+
+      float const right_knee_y = pelvis_y - 0.10F;
+      float const right_knee_z = right_foot_z - 0.05F;
+
+      pose.knee_r = QVector3D(stance_narrow, right_knee_y, right_knee_z);
+
+      float const upper_body_drop = kneel_depth;
+
+      pose.shoulderL.setY(HP::SHOULDER_Y - upper_body_drop);
+      pose.shoulderR.setY(HP::SHOULDER_Y - upper_body_drop);
+      pose.neck_base.setY(HP::NECK_BASE_Y - upper_body_drop);
+      pose.headPos.setY((HP::HEAD_TOP_Y + HP::CHIN_Y) * 0.5F - upper_body_drop);
+
+      float const forward_lean = 0.10F * t;
+      pose.shoulderL.setZ(pose.shoulderL.z() + forward_lean);
+      pose.shoulderR.setZ(pose.shoulderR.z() + forward_lean);
+      pose.neck_base.setZ(pose.neck_base.z() + forward_lean * 0.8F);
+      pose.headPos.setZ(pose.headPos.z() + forward_lean * 0.7F);
+
+      QVector3D const hold_hand_l(bow_x - 0.15F, pose.shoulderL.y() + 0.30F,
+                                  0.55F);
+      QVector3D const hold_hand_r(bow_x + 0.12F, pose.shoulderR.y() + 0.15F,
+                                  0.10F);
+      QVector3D const normal_hand_l(bow_x - 0.05F + arm_asymmetry,
+                                    HP::SHOULDER_Y + 0.05F + arm_height_jitter,
+                                    0.55F);
+      QVector3D const normal_hand_r(
+          0.15F - arm_asymmetry * 0.5F,
+          HP::SHOULDER_Y + 0.15F + arm_height_jitter * 0.8F, 0.20F);
+
+      pose.handL = normal_hand_l * (1.0F - t) + hold_hand_l * t;
+      pose.hand_r = normal_hand_r * (1.0F - t) + hold_hand_r * t;
+    } else {
+      pose.handL = QVector3D(bow_x - 0.05F + arm_asymmetry,
+                             HP::SHOULDER_Y + 0.05F + arm_height_jitter, 0.55F);
+      pose.hand_r =
+          QVector3D(0.15F - arm_asymmetry * 0.5F,
+                    HP::SHOULDER_Y + 0.15F + arm_height_jitter * 0.8F, 0.20F);
+    }
+
+    if (anim.is_attacking && !anim.isInHoldMode) {
+      float const attack_phase =
+          std::fmod(anim.time * ARCHER_INV_ATTACK_CYCLE_TIME, 1.0F);
+
+      if (anim.isMelee) {
+        QVector3D const rest_pos(0.25F, HP::SHOULDER_Y, 0.10F);
+        QVector3D const raised_pos(0.30F, HP::HEAD_TOP_Y + 0.2F, -0.05F);
+        QVector3D const strike_pos(0.35F, HP::WAIST_Y, 0.45F);
+
+        if (attack_phase < 0.25F) {
+          float t = attack_phase / 0.25F;
+          t = t * t;
+          pose.hand_r = rest_pos * (1.0F - t) + raised_pos * t;
+          pose.handL = QVector3D(-0.15F, HP::SHOULDER_Y - 0.1F * t, 0.20F);
+        } else if (attack_phase < 0.35F) {
+          pose.hand_r = raised_pos;
+          pose.handL = QVector3D(-0.15F, HP::SHOULDER_Y - 0.1F, 0.20F);
+        } else if (attack_phase < 0.55F) {
+          float t = (attack_phase - 0.35F) / 0.2F;
+          t = t * t * t;
+          pose.hand_r = raised_pos * (1.0F - t) + strike_pos * t;
+          pose.handL =
+              QVector3D(-0.15F, HP::SHOULDER_Y - 0.1F * (1.0F - t * 0.5F),
+                        0.20F + 0.15F * t);
+        } else {
+          float t = (attack_phase - 0.55F) / 0.45F;
+          t = 1.0F - (1.0F - t) * (1.0F - t);
+          pose.hand_r = strike_pos * (1.0F - t) + rest_pos * t;
+          pose.handL = QVector3D(-0.15F, HP::SHOULDER_Y - 0.05F * (1.0F - t),
+                                 0.35F * (1.0F - t) + 0.20F * t);
+        }
+      } else {
+        QVector3D const aim_pos(0.18F, HP::SHOULDER_Y + 0.18F, 0.35F);
+        QVector3D const draw_pos(0.22F, HP::SHOULDER_Y + 0.10F, -0.30F);
+        QVector3D const release_pos(0.18F, HP::SHOULDER_Y + 0.20F, 0.10F);
+
+        if (attack_phase < 0.20F) {
+          float t = attack_phase / 0.20F;
+          t = t * t;
+          pose.hand_r = aim_pos * (1.0F - t) + draw_pos * t;
+          pose.handL = QVector3D(bow_x - 0.05F, HP::SHOULDER_Y + 0.05F, 0.55F);
+
+          float const shoulder_twist = t * 0.08F;
+          pose.shoulderR.setY(pose.shoulderR.y() + shoulder_twist);
+          pose.shoulderL.setY(pose.shoulderL.y() - shoulder_twist * 0.5F);
+        } else if (attack_phase < 0.50F) {
+          pose.hand_r = draw_pos;
+          pose.handL = QVector3D(bow_x - 0.05F, HP::SHOULDER_Y + 0.05F, 0.55F);
+
+          float const shoulder_twist = 0.08F;
+          pose.shoulderR.setY(pose.shoulderR.y() + shoulder_twist);
+          pose.shoulderL.setY(pose.shoulderL.y() - shoulder_twist * 0.5F);
+        } else if (attack_phase < 0.58F) {
+          float t = (attack_phase - 0.50F) / 0.08F;
+          t = t * t * t;
+          pose.hand_r = draw_pos * (1.0F - t) + release_pos * t;
+          pose.handL = QVector3D(bow_x - 0.05F, HP::SHOULDER_Y + 0.05F, 0.55F);
+
+          float const shoulder_twist = 0.08F * (1.0F - t * 0.6F);
+          pose.shoulderR.setY(pose.shoulderR.y() + shoulder_twist);
+          pose.shoulderL.setY(pose.shoulderL.y() - shoulder_twist * 0.5F);
+
+          pose.headPos.setZ(pose.headPos.z() - t * 0.04F);
+        } else {
+          float t = (attack_phase - 0.58F) / 0.42F;
+          t = 1.0F - (1.0F - t) * (1.0F - t);
+          pose.hand_r = release_pos * (1.0F - t) + aim_pos * t;
+          pose.handL = QVector3D(bow_x - 0.05F, HP::SHOULDER_Y + 0.05F, 0.55F);
+
+          float const shoulder_twist = 0.08F * 0.4F * (1.0F - t);
+          pose.shoulderR.setY(pose.shoulderR.y() + shoulder_twist);
+          pose.shoulderL.setY(pose.shoulderL.y() - shoulder_twist * 0.5F);
+
+          pose.headPos.setZ(pose.headPos.z() - 0.04F * (1.0F - t));
+        }
+      }
+    }
+
+    QVector3D right_axis = pose.shoulderR - pose.shoulderL;
+    right_axis.setY(0.0F);
+    if (right_axis.lengthSquared() < 1e-8F) {
+      right_axis = QVector3D(1, 0, 0);
+    }
+    right_axis.normalize();
+    QVector3D const outward_l = -right_axis;
+    QVector3D const outward_r = right_axis;
+
+    pose.elbowL = elbowBendTorso(pose.shoulderL, pose.handL, outward_l, 0.45F,
+                                 0.15F, -0.08F, +1.0F);
+    pose.elbowR = elbowBendTorso(pose.shoulderR, pose.hand_r, outward_r, 0.48F,
+                                 0.12F, 0.02F, +1.0F);
+  }
+
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose,
+                      const HumanoidAnimationContext &anim_ctx,
+                      ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto const &style = resolve_style(ctx);
+    const AnimationInputs &anim = anim_ctx.inputs;
+    QVector3D team_tint = resolveTeamTint(ctx);
+    uint32_t seed = 0U;
+    if (ctx.entity != nullptr) {
+      auto *unit = ctx.entity->getComponent<Engine::Core::UnitComponent>();
+      if (unit != nullptr) {
+        seed ^= uint32_t(unit->owner_id * 2654435761U);
+      }
+      seed ^= uint32_t(reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU);
+    }
+
+    ArcherExtras extras;
+    auto it = m_extrasCache.find(seed);
+    if (it != m_extrasCache.end()) {
+      extras = it->second;
+    } else {
+      extras.metalHead = Render::Geom::clampVec01(v.palette.metal * 1.15F);
+      extras.stringCol = QVector3D(0.30F, 0.30F, 0.32F);
+      auto tint = [&](float k) {
+        return QVector3D(clamp01(team_tint.x() * k), clamp01(team_tint.y() * k),
+                         clamp01(team_tint.z() * k));
+      };
+      extras.fletch = tint(0.9F);
+      extras.bowTopY = HP::SHOULDER_Y + 0.55F;
+      extras.bowBotY = HP::WAIST_Y - 0.25F;
+
+      apply_extras_overrides(style, extras);
+      m_extrasCache[seed] = extras;
+
+      if (m_extrasCache.size() > MAX_EXTRAS_CACHE_SIZE) {
+        m_extrasCache.clear();
+      }
+    }
+    apply_extras_overrides(style, extras);
+
+    drawQuiver(ctx, v, pose, extras, seed, out);
+
+    float attack_phase = 0.0F;
+    if (anim.is_attacking && !anim.isMelee) {
+      attack_phase = std::fmod(anim.time * ARCHER_INV_ATTACK_CYCLE_TIME, 1.0F);
+    }
+    drawBowAndArrow(ctx, pose, v, extras, anim.is_attacking && !anim.isMelee,
+                    attack_phase, out);
+  }
+
+  void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose, ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto const &style = resolve_style(ctx);
+    if (!style.show_helmet) {
+      if (style.attachment_profile == std::string(k_attachment_headwrap)) {
+        draw_headwrap(ctx, v, pose, out);
+      }
+      return;
+    }
+
+    QVector3D const helmet_color =
+        v.palette.metal * QVector3D(1.08F, 0.98F, 0.78F);
+    QVector3D const helmet_accent = helmet_color * 1.12F;
+
+    QVector3D const helmet_top(0, pose.headPos.y() + pose.headR * 1.28F, 0);
+    QVector3D const helmet_bot(0, pose.headPos.y() + pose.headR * 0.08F, 0);
+    float const helmet_r = pose.headR * 1.10F;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helmet_bot, helmet_top, helmet_r),
+             helmet_color, nullptr, 1.0F);
+
+    QVector3D const apex_pos(0, pose.headPos.y() + pose.headR * 1.48F, 0);
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, helmet_top, apex_pos, helmet_r * 0.97F),
+             helmet_accent, nullptr, 1.0F);
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    QVector3D const brow_pos(0, pose.headPos.y() + pose.headR * 0.35F, 0);
+    ring(brow_pos, helmet_r * 1.07F, 0.020F, helmet_accent);
+
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.65F, 0),
+         helmet_r * 1.03F, 0.015F, helmet_color * 1.05F);
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.95F, 0),
+         helmet_r * 1.01F, 0.012F, helmet_color * 1.03F);
+
+    float const cheek_w = pose.headR * 0.48F;
+    QVector3D const cheek_top(0, pose.headPos.y() + pose.headR * 0.22F, 0);
+    QVector3D const cheek_bot(0, pose.headPos.y() - pose.headR * 0.42F, 0);
+
+    QVector3D const cheek_ltop =
+        cheek_top + QVector3D(-cheek_w, 0, pose.headR * 0.38F);
+    QVector3D const cheek_lbot =
+        cheek_bot + QVector3D(-cheek_w * 0.82F, 0, pose.headR * 0.28F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, cheek_lbot, cheek_ltop, 0.028F),
+             helmet_color * 0.96F, nullptr, 1.0F);
+
+    QVector3D const cheek_rtop =
+        cheek_top + QVector3D(cheek_w, 0, pose.headR * 0.38F);
+    QVector3D const cheek_rbot =
+        cheek_bot + QVector3D(cheek_w * 0.82F, 0, pose.headR * 0.28F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, cheek_rbot, cheek_rtop, 0.028F),
+             helmet_color * 0.96F, nullptr, 1.0F);
+
+    QVector3D const neck_guard_top(0, pose.headPos.y() + pose.headR * 0.03F,
+                                   -pose.headR * 0.82F);
+    QVector3D const neck_guard_bot(0, pose.headPos.y() - pose.headR * 0.32F,
+                                   -pose.headR * 0.88F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, neck_guard_bot, neck_guard_top,
+                             helmet_r * 0.88F),
+             helmet_color * 0.93F, nullptr, 1.0F);
+
+    QVector3D const crest_base = apex_pos;
+    QVector3D const crest_mid = crest_base + QVector3D(0, 0.09F, 0);
+    QVector3D const crest_top = crest_mid + QVector3D(0, 0.12F, 0);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, crest_base, crest_mid, 0.018F),
+             helmet_accent, nullptr, 1.0F);
+
+    out.mesh(getUnitCone(), coneFromTo(ctx.model, crest_mid, crest_top, 0.042F),
+             QVector3D(0.88F, 0.18F, 0.18F), nullptr, 1.0F);
+
+    out.mesh(getUnitSphere(), sphereAt(ctx.model, crest_top, 0.020F),
+             helmet_accent, nullptr, 1.0F);
+  }
+
+  void draw_armorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, float y_top_cover,
+                         float torso_r, float, float upper_arm_r,
+                         const QVector3D &right_axis,
+                         ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    if (!resolve_style(ctx).show_armor) {
+      return;
+    }
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    QVector3D mail_color = v.palette.metal * QVector3D(0.85F, 0.87F, 0.92F);
+    QVector3D leather_trim = v.palette.leatherDark * 0.90F;
+
+    float const waist_y = pose.pelvisPos.y();
+
+    QVector3D const mail_top(0, y_top_cover + 0.01F, 0);
+    QVector3D const mail_mid(0, (y_top_cover + waist_y) * 0.5F, 0);
+    QVector3D const mail_bot(0, waist_y + 0.08F, 0);
+    float const r_top = torso_r * 1.10F;
+    float const r_mid = torso_r * 1.08F;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, mail_top, mail_mid, r_top), mail_color,
+             nullptr, 1.0F);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, mail_mid, mail_bot, r_mid),
+             mail_color * 0.95F, nullptr, 1.0F);
+
+    for (int i = 0; i < 3; ++i) {
+      float const y = mail_top.y() - (i * 0.12F);
+      ring(QVector3D(0, y, 0), r_top * (1.01F + i * 0.005F), 0.012F,
+           leather_trim);
+    }
+
+    auto draw_pauldron = [&](const QVector3D &shoulder,
+                             const QVector3D &outward) {
+      for (int i = 0; i < 3; ++i) {
+        float const seg_y = shoulder.y() + 0.02F - i * 0.035F;
+        float const seg_r = upper_arm_r * (2.2F - i * 0.15F);
+        QVector3D seg_top(shoulder.x(), seg_y + 0.025F, shoulder.z());
+        QVector3D seg_bot(shoulder.x(), seg_y - 0.010F, shoulder.z());
+
+        seg_top += outward * 0.02F;
+        seg_bot += outward * 0.02F;
+
+        out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_top, seg_r),
+                 mail_color * (1.0F - i * 0.05F), nullptr, 1.0F);
+      }
+    };
+
+    draw_pauldron(pose.shoulderL, -right_axis);
+    draw_pauldron(pose.shoulderR, right_axis);
+
+    auto draw_manica = [&](const QVector3D &shoulder, const QVector3D &elbow) {
+      QVector3D dir = (elbow - shoulder);
+      float const len = dir.length();
+      if (len < 1e-5F) {
+        return;
+      }
+      dir /= len;
+
+      for (int i = 0; i < 4; ++i) {
+        float const t0 = 0.08F + i * 0.18F;
+        float const t1 = t0 + 0.16F;
+        QVector3D const a = shoulder + dir * (t0 * len);
+        QVector3D const b = shoulder + dir * (t1 * len);
+        float const r = upper_arm_r * (1.25F - i * 0.03F);
+        out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+                 mail_color * (0.95F - i * 0.03F), nullptr, 1.0F);
+      }
+    };
+
+    draw_manica(pose.shoulderL, pose.elbowL);
+    draw_manica(pose.shoulderR, pose.elbowR);
+
+    QVector3D const belt_top(0, waist_y + 0.06F, 0);
+    QVector3D const belt_bot(0, waist_y - 0.02F, 0);
+    float const belt_r = torso_r * 1.12F;
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, belt_top, belt_bot, belt_r),
+             leather_trim, nullptr, 1.0F);
+
+    QVector3D const brass_color =
+        v.palette.metal * QVector3D(1.2F, 1.0F, 0.65F);
+    ring(QVector3D(0, waist_y + 0.02F, 0), belt_r * 1.02F, 0.010F, brass_color);
+
+    auto draw_pteruge = [&](float angle, float yStart, float length) {
+      float const rad = torso_r * 1.15F;
+      float const x = rad * std::sin(angle);
+      float const z = rad * std::cos(angle);
+      QVector3D const top(x, yStart, z);
+      QVector3D const bot(x * 0.95F, yStart - length, z * 0.95F);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, top, bot, 0.018F),
+               leather_trim * 0.85F, nullptr, 1.0F);
+    };
+
+    float const shoulder_pteruge_y = y_top_cover - 0.02F;
+    constexpr int k_shoulder_pteruge_count = 8;
+    constexpr float k_shoulder_pteruge_divisor = 8.0F;
+    for (int i = 0; i < k_shoulder_pteruge_count; ++i) {
+      float const angle =
+          (i / k_shoulder_pteruge_divisor) * 2.0F * std::numbers::pi_v<float>;
+      draw_pteruge(angle, shoulder_pteruge_y, 0.14F);
+    }
+
+    float const waist_pteruge_y = waist_y - 0.04F;
+    constexpr int k_waist_pteruge_count = 10;
+    constexpr float k_waist_pteruge_divisor = 10.0F;
+    for (int i = 0; i < k_waist_pteruge_count; ++i) {
+      float const angle =
+          (i / k_waist_pteruge_divisor) * 2.0F * std::numbers::pi_v<float>;
+      draw_pteruge(angle, waist_pteruge_y, 0.18F);
+    }
+
+    QVector3D const collar_top(0, y_top_cover + 0.018F, 0);
+    QVector3D const collar_bot(0, y_top_cover - 0.008F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, collar_top, collar_bot,
+                             HP::NECK_RADIUS * 1.8F),
+             mail_color * 1.05F, nullptr, 1.0F);
+  }
+
+  void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
+                               const HumanoidPose &pose, float, float y_neck,
+                               const QVector3D &,
+                               ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto const &style = resolve_style(ctx);
+    if (!style.show_shoulder_decor && !style.show_cape) {
+      return;
+    }
+
+    QVector3D brass_color = v.palette.metal * QVector3D(1.2F, 1.0F, 0.65F);
+
+    auto draw_phalera = [&](const QVector3D &pos) {
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.025F);
+      out.mesh(getUnitSphere(), m, brass_color, nullptr, 1.0F);
+    };
+
+    if (style.show_shoulder_decor) {
+      draw_phalera(pose.shoulderL + QVector3D(0, 0.05F, 0.02F));
+      draw_phalera(pose.shoulderR + QVector3D(0, 0.05F, 0.02F));
+    }
+
+    if (!style.show_cape) {
+      return;
+    }
+
+    QVector3D const clasp_pos(0, y_neck + 0.02F, 0.08F);
+    QMatrix4x4 clasp_m = ctx.model;
+    clasp_m.translate(clasp_pos);
+    clasp_m.scale(0.020F);
+    out.mesh(getUnitSphere(), clasp_m, brass_color * 1.1F, nullptr, 1.0F);
+
+    QVector3D const cape_top = clasp_pos + QVector3D(0, -0.02F, -0.05F);
+    QVector3D const cape_bot = clasp_pos + QVector3D(0, -0.25F, -0.15F);
+    QVector3D cape_fabric = v.palette.cloth * QVector3D(1.2F, 0.3F, 0.3F);
+    if (style.cape_color) {
+      cape_fabric = saturate_color(*style.cape_color);
+    }
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, cape_top, cape_bot, 0.025F),
+             cape_fabric * 0.85F, nullptr, 1.0F);
+  }
+
+private:
+  mutable std::unordered_map<uint32_t, ArcherExtras> m_extrasCache;
+
+  auto
+  resolve_style(const DrawContext &ctx) const -> const ArcherStyleConfig & {
+    ensure_archer_styles_registered();
+    auto &styles = style_registry();
+    std::string nation_id;
+    if (ctx.entity != nullptr) {
+      if (auto *unit =
+              ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+        nation_id = unit->nation_id;
+      }
+    }
+    if (!nation_id.empty()) {
+      auto it = styles.find(nation_id);
+      if (it != styles.end()) {
+        return it->second;
+      }
+    }
+    auto fallback = styles.find(std::string(k_default_style_key));
+    if (fallback != styles.end()) {
+      return fallback->second;
+    }
+    static const ArcherStyleConfig default_style{};
+    return default_style;
+  }
+
+public:
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    const ArcherStyleConfig &style = resolve_style(ctx);
+    if (!style.shader_id.empty()) {
+      return QString::fromStdString(style.shader_id);
+    }
+    return QStringLiteral("archer");
+  }
+
+private:
+  void apply_palette_overrides(const ArcherStyleConfig &style,
+                               const QVector3D &team_tint,
+                               HumanoidVariant &variant) const {
+    auto apply_color = [&](const std::optional<QVector3D> &override_color,
+                           QVector3D &target) {
+      target = mix_palette_color(target, override_color, team_tint,
+                                 k_team_mix_weight, k_style_mix_weight);
+    };
+
+    apply_color(style.cloth_color, variant.palette.cloth);
+    apply_color(style.leather_color, variant.palette.leather);
+    apply_color(style.leather_dark_color, variant.palette.leatherDark);
+    apply_color(style.metal_color, variant.palette.metal);
+    apply_color(style.wood_color, variant.palette.wood);
+  }
+
+  void apply_extras_overrides(const ArcherStyleConfig &style,
+                              ArcherExtras &extras) const {
+    if (style.fletching_color) {
+      extras.fletch = saturate_color(*style.fletching_color);
+    }
+    if (style.bow_string_color) {
+      extras.stringCol = saturate_color(*style.bow_string_color);
+    }
+  }
+
+  void draw_headwrap(const DrawContext &ctx, const HumanoidVariant &v,
+                     const HumanoidPose &pose, ISubmitter &out) const {
+    QVector3D const cloth_color =
+        saturate_color(v.palette.cloth * QVector3D(0.9F, 1.05F, 1.05F));
+    float const head_r = pose.headR;
+
+    QVector3D const band_top(0, pose.headPos.y() + head_r * 0.70F, 0);
+    QVector3D const band_bot(0, pose.headPos.y() + head_r * 0.30F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, band_bot, band_top, head_r * 1.08F),
+             cloth_color, nullptr, 1.0F);
+
+    QVector3D const knot_center(0.10F, pose.headPos.y() + head_r * 0.60F,
+                                head_r * 0.72F);
+    QMatrix4x4 knot_m = ctx.model;
+    knot_m.translate(knot_center);
+    knot_m.scale(head_r * 0.32F);
+    out.mesh(getUnitSphere(), knot_m, cloth_color * 1.05F, nullptr, 1.0F);
+
+    QVector3D const tail_top = knot_center + QVector3D(-0.08F, -0.05F, -0.06F);
+    QVector3D const tail_bot = tail_top + QVector3D(0.02F, -0.28F, -0.08F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, tail_top, tail_bot, head_r * 0.28F),
+             cloth_color * QVector3D(0.92F, 0.98F, 1.05F), nullptr, 1.0F);
+  }
+
+  static void drawQuiver(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, const ArcherExtras &extras,
+                         uint32_t seed, ISubmitter &out) {
+    using HP = HumanProportions;
+
+    QVector3D const spine_mid = (pose.shoulderL + pose.shoulderR) * 0.5F;
+    QVector3D const quiver_offset(-0.08F, 0.10F, -0.25F);
+    QVector3D const q_top = spine_mid + quiver_offset;
+    QVector3D const q_base = q_top + QVector3D(-0.02F, -0.30F, 0.03F);
+
+    float const quiver_r = HP::HEAD_RADIUS * 0.45F;
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, q_base, q_top, quiver_r),
+             v.palette.leather, nullptr, 1.0F);
+
+    float const j = (hash_01(seed) - 0.5F) * 0.04F;
+    float const k =
+        (hash_01(seed ^ HashXorShift::k_golden_ratio) - 0.5F) * 0.04F;
+
+    QVector3D const a1 = q_top + QVector3D(0.00F + j, 0.08F, 0.00F + k);
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, q_top, a1, 0.010F),
+             v.palette.wood, nullptr, 1.0F);
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, a1, a1 + QVector3D(0, 0.05F, 0), 0.025F),
+             extras.fletch, nullptr, 1.0F);
+
+    QVector3D const a2 = q_top + QVector3D(0.02F - j, 0.07F, 0.02F - k);
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, q_top, a2, 0.010F),
+             v.palette.wood, nullptr, 1.0F);
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, a2, a2 + QVector3D(0, 0.05F, 0), 0.025F),
+             extras.fletch, nullptr, 1.0F);
+  }
+
+  static void drawBowAndArrow(const DrawContext &ctx, const HumanoidPose &pose,
+                              const HumanoidVariant &v,
+                              const ArcherExtras &extras, bool is_attacking,
+                              float attack_phase, ISubmitter &out) {
+    const QVector3D up(0.0F, 1.0F, 0.0F);
+    const QVector3D forward(0.0F, 0.0F, 1.0F);
+
+    QVector3D const grip = pose.handL;
+
+    float const bow_plane_z = 0.45F;
+    QVector3D const top_end(extras.bowX, extras.bowTopY, bow_plane_z);
+    QVector3D const bot_end(extras.bowX, extras.bowBotY, bow_plane_z);
+
+    QVector3D const nock(
+        extras.bowX,
+        clampf(pose.hand_r.y(), extras.bowBotY + 0.05F, extras.bowTopY - 0.05F),
+        clampf(pose.hand_r.z(), bow_plane_z - 0.30F, bow_plane_z + 0.30F));
+
+    constexpr int k_bowstring_segments = 22;
+    auto q_bezier = [](const QVector3D &a, const QVector3D &c,
+                       const QVector3D &b, float t) {
+      float const u = 1.0F - t;
+      return u * u * a + 2.0F * u * t * c + t * t * b;
+    };
+
+    float const bow_mid_y = (top_end.y() + bot_end.y()) * 0.5F;
+
+    float const ctrl_y = bow_mid_y + 0.45F;
+
+    QVector3D const ctrl(extras.bowX, ctrl_y,
+                         bow_plane_z + extras.bowDepth * 0.6F);
+
+    QVector3D prev = bot_end;
+    for (int i = 1; i <= k_bowstring_segments; ++i) {
+      float const t = float(i) / float(k_bowstring_segments);
+      QVector3D const cur = q_bezier(bot_end, ctrl, top_end, t);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, prev, cur, extras.bowRodR),
+               v.palette.wood, nullptr, 1.0F);
+      prev = cur;
+    }
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, grip - up * 0.05F, grip + up * 0.05F,
+                             extras.bowRodR * 1.45F),
+             v.palette.wood, nullptr, 1.0F);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, top_end, nock, extras.stringR),
+             extras.stringCol, nullptr, 1.0F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, nock, bot_end, extras.stringR),
+             extras.stringCol, nullptr, 1.0F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, pose.hand_r, nock, 0.0045F),
+             extras.stringCol * 0.9F, nullptr, 1.0F);
+
+    bool const show_arrow =
+        !is_attacking ||
+        (is_attacking && attack_phase >= 0.0F && attack_phase < 0.52F);
+
+    if (show_arrow) {
+      QVector3D const tail = nock - forward * 0.06F;
+      QVector3D const tip = tail + forward * 0.90F;
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, tail, tip, 0.018F),
+               v.palette.wood, nullptr, 1.0F);
+      QVector3D const head_base = tip - forward * 0.10F;
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, head_base, tip, 0.05F),
+               extras.metalHead, nullptr, 1.0F);
+      QVector3D const f1b = tail - forward * 0.02F;
+      QVector3D const f1a = f1b - forward * 0.06F;
+      QVector3D const f2b = tail + forward * 0.02F;
+      QVector3D const f2a = f2b + forward * 0.06F;
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, f1b, f1a, 0.04F),
+               extras.fletch, nullptr, 1.0F);
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, f2a, f2b, 0.04F),
+               extras.fletch, nullptr, 1.0F);
+    }
+  }
+};
+
+void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
+  ensure_archer_styles_registered();
+  static ArcherRenderer const renderer;
+  registry.registerRenderer(
+      "troops/kingdom/archer", [](const DrawContext &ctx, ISubmitter &out) {
+        static ArcherRenderer const static_renderer;
+        Shader *archer_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          archer_shader = ctx.backend->shader(shader_key);
+          if (archer_shader == nullptr) {
+            archer_shader = ctx.backend->shader(QStringLiteral("archer"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) && (archer_shader != nullptr)) {
+          scene_renderer->setCurrentShader(archer_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Kingdom

+ 15 - 0
render/entity/nations/kingdom/archer_renderer.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include "../../registry.h"
+#include <string>
+
+namespace Render::GL::Kingdom {
+
+struct ArcherStyleConfig;
+
+void register_archer_style(const std::string &nation_id,
+                           const ArcherStyleConfig &style);
+
+void registerArcherRenderer(EntityRendererRegistry &registry);
+
+} // namespace Render::GL::Kingdom

+ 35 - 0
render/entity/nations/kingdom/archer_style.cpp

@@ -0,0 +1,35 @@
+#include "archer_style.h"
+#include "archer_renderer.h"
+
+#include <QVector3D>
+
+namespace {
+constexpr QVector3D k_kingdom_cloth{0.58F, 0.56F, 0.62F};
+constexpr QVector3D k_kingdom_leather{0.32F, 0.23F, 0.16F};
+constexpr QVector3D k_kingdom_leather_dark{0.18F, 0.16F, 0.14F};
+constexpr QVector3D k_kingdom_metal{0.70F, 0.70F, 0.72F};
+constexpr QVector3D k_kingdom_wood{0.40F, 0.30F, 0.18F};
+constexpr QVector3D k_kingdom_cape{0.20F, 0.24F, 0.32F};
+constexpr QVector3D k_kingdom_fletch{0.84F, 0.82F, 0.78F};
+constexpr QVector3D k_kingdom_string{0.22F, 0.22F, 0.24F};
+} // namespace
+
+namespace Render::GL::Kingdom {
+
+void register_kingdom_archer_style() {
+  ArcherStyleConfig style;
+  style.cloth_color = k_kingdom_cloth;
+  style.leather_color = k_kingdom_leather;
+  style.leather_dark_color = k_kingdom_leather_dark;
+  style.metal_color = k_kingdom_metal;
+  style.wood_color = k_kingdom_wood;
+  style.cape_color = k_kingdom_cape;
+  style.fletching_color = k_kingdom_fletch;
+  style.bow_string_color = k_kingdom_string;
+  style.shader_id = "archer_kingdom_of_iron";
+
+  register_archer_style("default", style);
+  register_archer_style("kingdom_of_iron", style);
+}
+
+} // namespace Render::GL::Kingdom

+ 30 - 0
render/entity/nations/kingdom/archer_style.h

@@ -0,0 +1,30 @@
+#pragma once
+
+#include <QVector3D>
+#include <optional>
+#include <string>
+
+namespace Render::GL::Kingdom {
+
+struct ArcherStyleConfig {
+  std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> leather_color;
+  std::optional<QVector3D> leather_dark_color;
+  std::optional<QVector3D> metal_color;
+  std::optional<QVector3D> wood_color;
+  std::optional<QVector3D> cape_color;
+  std::optional<QVector3D> fletching_color;
+  std::optional<QVector3D> bow_string_color;
+
+  bool show_helmet = true;
+  bool show_armor = true;
+  bool show_shoulder_decor = true;
+  bool show_cape = true;
+
+  std::string attachment_profile;
+  std::string shader_id;
+};
+
+void register_kingdom_archer_style();
+
+} // namespace Render::GL::Kingdom

+ 978 - 0
render/entity/nations/kingdom/knight_renderer.cpp

@@ -0,0 +1,978 @@
+#include "knight_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/shader.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
+#include "../../../humanoid_math.h"
+#include "../../../humanoid_specs.h"
+#include "../../../palette.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../registry.h"
+#include "../../renderer_constants.h"
+#include "knight_style.h"
+#include <numbers>
+#include <qmatrix4x4.h>
+#include <qstringliteral.h>
+#include <qvectornd.h>
+#include <unordered_map>
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <string_view>
+
+namespace Render::GL::Kingdom {
+
+namespace {
+
+constexpr std::string_view k_knight_default_style_key = "default";
+constexpr float k_knight_team_mix_weight = 0.6F;
+constexpr float k_knight_style_mix_weight = 0.4F;
+
+auto knight_style_registry()
+    -> std::unordered_map<std::string, KnightStyleConfig> & {
+  static std::unordered_map<std::string, KnightStyleConfig> styles;
+  return styles;
+}
+
+void ensure_knight_styles_registered() {
+  static const bool registered = []() {
+    register_kingdom_knight_style();
+    return true;
+  }();
+  (void)registered;
+}
+
+} // namespace
+
+void register_knight_style(const std::string &nation_id,
+                           const KnightStyleConfig &style) {
+  knight_style_registry()[nation_id] = style;
+}
+
+using Render::Geom::clamp01;
+using Render::Geom::clampf;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::easeInOutCubic;
+using Render::Geom::lerp;
+using Render::Geom::nlerp;
+using Render::Geom::smoothstep;
+using Render::Geom::sphereAt;
+using Render::GL::Humanoid::mix_palette_color;
+using Render::GL::Humanoid::saturate_color;
+
+struct KnightExtras {
+  QVector3D metalColor;
+  QVector3D shieldColor;
+  QVector3D shieldTrimColor;
+  float swordLength = 0.80F;
+  float swordWidth = 0.065F;
+  float shieldRadius = 0.18F;
+  float shieldAspect = 1.0F;
+
+  float guard_half_width = 0.12F;
+  float handleRadius = 0.016F;
+  float pommelRadius = 0.045F;
+  float bladeRicasso = 0.16F;
+  float bladeTaperBias = 0.65F;
+  bool shieldCrossDecal = false;
+  bool hasScabbard = true;
+};
+
+class KnightRenderer : public HumanoidRendererBase {
+public:
+  auto getProportionScaling() const -> QVector3D override {
+    return {1.40F, 1.05F, 1.10F};
+  }
+
+private:
+  mutable std::unordered_map<uint32_t, KnightExtras> m_extrasCache;
+
+public:
+  void getVariant(const DrawContext &ctx, uint32_t seed,
+                  HumanoidVariant &v) const override {
+    QVector3D const team_tint = resolveTeamTint(ctx);
+    v.palette = makeHumanoidPalette(team_tint, seed);
+    auto const &style = resolve_style(ctx);
+    apply_palette_overrides(style, team_tint, v);
+  }
+
+  void customizePose(const DrawContext &,
+                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                     HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    const AnimationInputs &anim = anim_ctx.inputs;
+
+    float const arm_height_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.03F;
+    float const arm_asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
+
+    if (anim.is_attacking && anim.isMelee) {
+      float const attack_phase =
+          std::fmod(anim.time * KNIGHT_INV_ATTACK_CYCLE_TIME, 1.0F);
+
+      QVector3D const rest_pos(0.20F, HP::SHOULDER_Y + 0.05F, 0.15F);
+      QVector3D const prepare_pos(0.26F, HP::HEAD_TOP_Y + 0.18F, -0.06F);
+      QVector3D const raised_pos(0.25F, HP::HEAD_TOP_Y + 0.22F, 0.02F);
+      QVector3D const strike_pos(0.30F, HP::WAIST_Y - 0.05F, 0.50F);
+      QVector3D const recover_pos(0.22F, HP::SHOULDER_Y + 0.02F, 0.22F);
+
+      if (attack_phase < 0.18F) {
+
+        float const t = easeInOutCubic(attack_phase / 0.18F);
+        pose.hand_r = rest_pos * (1.0F - t) + prepare_pos * t;
+        pose.handL =
+            QVector3D(-0.21F, HP::SHOULDER_Y - 0.02F - 0.03F * t, 0.15F);
+      } else if (attack_phase < 0.32F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.18F) / 0.14F);
+        pose.hand_r = prepare_pos * (1.0F - t) + raised_pos * t;
+        pose.handL = QVector3D(-0.21F, HP::SHOULDER_Y - 0.05F, 0.17F);
+      } else if (attack_phase < 0.52F) {
+
+        float t = (attack_phase - 0.32F) / 0.20F;
+        t = t * t * t;
+        pose.hand_r = raised_pos * (1.0F - t) + strike_pos * t;
+        pose.handL =
+            QVector3D(-0.21F, HP::SHOULDER_Y - 0.03F * (1.0F - 0.5F * t),
+                      0.17F + 0.20F * t);
+      } else if (attack_phase < 0.72F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.52F) / 0.20F);
+        pose.hand_r = strike_pos * (1.0F - t) + recover_pos * t;
+        pose.handL = QVector3D(-0.20F, HP::SHOULDER_Y - 0.015F * (1.0F - t),
+                               lerp(0.37F, 0.20F, t));
+      } else {
+
+        float const t = smoothstep(0.72F, 1.0F, attack_phase);
+        pose.hand_r = recover_pos * (1.0F - t) + rest_pos * t;
+        pose.handL = QVector3D(-0.20F - 0.02F * (1.0F - t),
+                               HP::SHOULDER_Y + arm_height_jitter * (1.0F - t),
+                               lerp(0.20F, 0.15F, t));
+      }
+    } else {
+
+      pose.hand_r =
+          QVector3D(0.30F + arm_asymmetry,
+                    HP::SHOULDER_Y - 0.02F + arm_height_jitter, 0.35F);
+      pose.handL = QVector3D(-0.22F - 0.5F * arm_asymmetry,
+                             HP::SHOULDER_Y + 0.5F * arm_height_jitter, 0.18F);
+    }
+  }
+
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose,
+                      const HumanoidAnimationContext &anim_ctx,
+                      ISubmitter &out) const override {
+    const AnimationInputs &anim = anim_ctx.inputs;
+    uint32_t const seed = reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU;
+    auto const &style = resolve_style(ctx);
+    QVector3D const team_tint = resolveTeamTint(ctx);
+
+    KnightExtras extras;
+    auto it = m_extrasCache.find(seed);
+    if (it != m_extrasCache.end()) {
+      extras = it->second;
+    } else {
+      extras = computeKnightExtras(seed, v);
+      apply_extras_overrides(style, team_tint, v, extras);
+      m_extrasCache[seed] = extras;
+
+      if (m_extrasCache.size() > MAX_EXTRAS_CACHE_SIZE) {
+        m_extrasCache.clear();
+      }
+    }
+    apply_extras_overrides(style, team_tint, v, extras);
+
+    bool const is_attacking = anim.is_attacking && anim.isMelee;
+    float attack_phase = 0.0F;
+    if (is_attacking) {
+      attack_phase = std::fmod(anim.time * KNIGHT_INV_ATTACK_CYCLE_TIME, 1.0F);
+    }
+
+    drawSword(ctx, pose, v, extras, is_attacking, attack_phase, out);
+    drawShield(ctx, pose, v, extras, out);
+
+    if (!is_attacking && extras.hasScabbard) {
+      drawScabbard(ctx, pose, v, extras, out);
+    }
+  }
+
+  void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose, ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    QVector3D const steel_color =
+        v.palette.metal * QVector3D(0.95F, 0.96F, 1.0F);
+
+    float helm_r = pose.headR * 1.15F;
+    QVector3D const helm_bot(0, pose.headPos.y() - pose.headR * 0.20F, 0);
+    QVector3D const helm_top(0, pose.headPos.y() + pose.headR * 1.40F, 0);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
+             steel_color, nullptr, 1.0F);
+
+    QVector3D const cap_top(0, pose.headPos.y() + pose.headR * 1.48F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
+             steel_color * 1.05F, nullptr, 1.0F);
+
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 1.25F, 0), helm_r * 1.02F,
+         0.015F, steel_color * 1.08F);
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.50F, 0), helm_r * 1.02F,
+         0.015F, steel_color * 1.08F);
+    ring(QVector3D(0, pose.headPos.y() - pose.headR * 0.05F, 0), helm_r * 1.02F,
+         0.015F, steel_color * 1.08F);
+
+    float const visor_y = pose.headPos.y() + pose.headR * 0.15F;
+    float const visor_z = helm_r * 0.72F;
+
+    QVector3D const visor_hl(-helm_r * 0.35F, visor_y, visor_z);
+    QVector3D const visor_hr(helm_r * 0.35F, visor_y, visor_z);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, visor_hl, visor_hr, 0.012F),
+             QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
+
+    QVector3D const visor_vt(0, visor_y + helm_r * 0.25F, visor_z);
+    QVector3D const visor_vb(0, visor_y - helm_r * 0.25F, visor_z);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, visor_vb, visor_vt, 0.012F),
+             QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
+
+    auto draw_breathing_hole = [&](float x, float y) {
+      QVector3D const pos(x, pose.headPos.y() + y, helm_r * 0.70F);
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.010F);
+      out.mesh(getUnitSphere(), m, QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
+    };
+
+    for (int i = 0; i < 4; ++i) {
+      draw_breathing_hole(helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+    }
+
+    for (int i = 0; i < 4; ++i) {
+      draw_breathing_hole(-helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+    }
+
+    QVector3D const cross_center(0, pose.headPos.y() + pose.headR * 0.60F,
+                                 helm_r * 0.75F);
+    QVector3D const brass_color = v.palette.metal * QVector3D(1.3F, 1.1F, 0.7F);
+
+    QVector3D const cross_h1 = cross_center + QVector3D(-0.04F, 0, 0);
+    QVector3D const cross_h2 = cross_center + QVector3D(0.04F, 0, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, cross_h1, cross_h2, 0.008F),
+             brass_color, nullptr, 1.0F);
+
+    QVector3D const cross_v1 = cross_center + QVector3D(0, -0.04F, 0);
+    QVector3D const cross_v2 = cross_center + QVector3D(0, 0.04F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, cross_v1, cross_v2, 0.008F),
+             brass_color, nullptr, 1.0F);
+  }
+
+  void draw_armorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, float y_top_cover,
+                         float torso_r, float, float upper_arm_r,
+                         const QVector3D &right_axis,
+                         ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    QVector3D steel_color = v.palette.metal * QVector3D(0.95F, 0.96F, 1.0F);
+    QVector3D const dark_steel = steel_color * 0.85F;
+    QVector3D brass_color = v.palette.metal * QVector3D(1.3F, 1.1F, 0.7F);
+
+    QVector3D const bp_top(0, y_top_cover + 0.02F, 0);
+    QVector3D const bp_mid(0, (y_top_cover + HP::WAIST_Y) * 0.5F + 0.04F, 0);
+    QVector3D const bp_bot(0, HP::WAIST_Y + 0.06F, 0);
+    float const r_chest = torso_r * 1.18F;
+    float const r_waist = torso_r * 1.14F;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, bp_top, bp_mid, r_chest), steel_color,
+             nullptr, 1.0F);
+
+    QVector3D const bp_mid_low(0, (bp_mid.y() + bp_bot.y()) * 0.5F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, bp_mid, bp_mid_low, r_chest * 0.98F),
+             steel_color * 0.99F, nullptr, 1.0F);
+
+    out.mesh(getUnitCone(), coneFromTo(ctx.model, bp_bot, bp_mid_low, r_waist),
+             steel_color * 0.98F, nullptr, 1.0F);
+
+    auto draw_rivet = [&](const QVector3D &pos) {
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.012F);
+      out.mesh(getUnitSphere(), m, brass_color, nullptr, 1.0F);
+    };
+
+    for (int i = 0; i < 8; ++i) {
+      float const angle = (i / 8.0F) * 2.0F * std::numbers::pi_v<float>;
+      float const x = r_chest * std::sin(angle) * 0.95F;
+      float const z = r_chest * std::cos(angle) * 0.95F;
+      draw_rivet(QVector3D(x, bp_mid.y() + 0.08F, z));
+    }
+
+    auto draw_pauldron = [&](const QVector3D &shoulder,
+                             const QVector3D &outward) {
+      for (int i = 0; i < 4; ++i) {
+        float const seg_y = shoulder.y() + 0.04F - i * 0.045F;
+        float const seg_r = upper_arm_r * (2.5F - i * 0.12F);
+        QVector3D seg_pos = shoulder + outward * (0.02F + i * 0.008F);
+        seg_pos.setY(seg_y);
+
+        out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_pos, seg_r),
+                 i == 0 ? steel_color * 1.05F
+                        : steel_color * (1.0F - i * 0.03F),
+                 nullptr, 1.0F);
+
+        if (i < 3) {
+          draw_rivet(seg_pos + QVector3D(0, 0.015F, 0.03F));
+        }
+      }
+    };
+
+    draw_pauldron(pose.shoulderL, -right_axis);
+    draw_pauldron(pose.shoulderR, right_axis);
+
+    auto draw_arm_plate = [&](const QVector3D &shoulder,
+                              const QVector3D &elbow) {
+      QVector3D dir = (elbow - shoulder);
+      float const len = dir.length();
+      if (len < 1e-5F) {
+        return;
+      }
+      dir /= len;
+
+      for (int i = 0; i < 3; ++i) {
+        float const t0 = 0.10F + i * 0.25F;
+        float const t1 = t0 + 0.22F;
+        QVector3D const a = shoulder + dir * (t0 * len);
+        QVector3D const b = shoulder + dir * (t1 * len);
+        float const r = upper_arm_r * (1.32F - i * 0.04F);
+
+        out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+                 steel_color * (0.98F - i * 0.02F), nullptr, 1.0F);
+
+        if (i < 2) {
+          draw_rivet(b);
+        }
+      }
+    };
+
+    draw_arm_plate(pose.shoulderL, pose.elbowL);
+    draw_arm_plate(pose.shoulderR, pose.elbowR);
+
+    for (int i = 0; i < 4; ++i) {
+      float const y0 = HP::WAIST_Y + 0.04F - i * 0.038F;
+      float const y1 = y0 - 0.032F;
+      float const r0 = r_waist * (1.06F + i * 0.025F);
+      out.mesh(
+          getUnitCone(),
+          coneFromTo(ctx.model, QVector3D(0, y0, 0), QVector3D(0, y1, 0), r0),
+          steel_color * (0.96F - i * 0.02F), nullptr, 1.0F);
+
+      if (i < 3) {
+        draw_rivet(QVector3D(r0 * 0.90F, y0 - 0.016F, 0));
+      }
+    }
+
+    QVector3D const gorget_top(0, y_top_cover + 0.025F, 0);
+    QVector3D const gorget_bot(0, y_top_cover - 0.012F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, gorget_bot, gorget_top,
+                             HP::NECK_RADIUS * 2.6F),
+             steel_color * 1.08F, nullptr, 1.0F);
+
+    ring(gorget_top, HP::NECK_RADIUS * 2.62F, 0.010F, brass_color);
+  }
+
+  void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
+                               const HumanoidPose &pose, float y_top_cover,
+                               float y_neck, const QVector3D &right_axis,
+                               ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    QVector3D brass_color = v.palette.metal * QVector3D(1.3F, 1.1F, 0.7F);
+    QVector3D const chainmail_color =
+        v.palette.metal * QVector3D(0.85F, 0.88F, 0.92F);
+    QVector3D mantling_color = v.palette.cloth;
+
+    for (int i = 0; i < 5; ++i) {
+      float const y = y_neck - i * 0.022F;
+      float const r = HP::NECK_RADIUS * (1.85F + i * 0.08F);
+      QVector3D const ring_pos(0, y, 0);
+      QVector3D const a = ring_pos + QVector3D(0, 0.010F, 0);
+      QVector3D const b = ring_pos - QVector3D(0, 0.010F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+               chainmail_color * (1.0F - i * 0.04F), nullptr, 1.0F);
+    }
+
+    QVector3D const helm_top(0, HP::HEAD_TOP_Y - HP::HEAD_RADIUS * 0.15F, 0);
+    QMatrix4x4 crest_base = ctx.model;
+    crest_base.translate(helm_top);
+    crest_base.scale(0.025F, 0.015F, 0.025F);
+    out.mesh(getUnitSphere(), crest_base, brass_color * 1.2F, nullptr, 1.0F);
+
+    auto draw_stud = [&](const QVector3D &pos) {
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.008F);
+      out.mesh(getUnitSphere(), m, brass_color * 1.3F, nullptr, 1.0F);
+    };
+
+    draw_stud(helm_top + QVector3D(0.020F, 0, 0.020F));
+    draw_stud(helm_top + QVector3D(-0.020F, 0, 0.020F));
+    draw_stud(helm_top + QVector3D(0.020F, 0, -0.020F));
+    draw_stud(helm_top + QVector3D(-0.020F, 0, -0.020F));
+
+    auto draw_mantling = [&](const QVector3D &startPos,
+                             const QVector3D &direction) {
+      QVector3D current_pos = startPos;
+      for (int i = 0; i < 4; ++i) {
+        float const seg_len = 0.035F - i * 0.005F;
+        float const seg_r = 0.020F - i * 0.003F;
+        QVector3D next_pos = current_pos + direction * seg_len;
+        next_pos.setY(next_pos.y() - 0.025F);
+
+        out.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, current_pos, next_pos, seg_r),
+                 mantling_color * (1.1F - i * 0.06F), nullptr, 1.0F);
+
+        current_pos = next_pos;
+      }
+    };
+
+    QVector3D const mantling_start(0, HP::CHIN_Y + HP::HEAD_RADIUS * 0.25F, 0);
+    draw_mantling(mantling_start + right_axis * HP::HEAD_RADIUS * 0.95F,
+                  right_axis * 0.5F + QVector3D(0, -0.1F, -0.3F));
+    draw_mantling(mantling_start - right_axis * HP::HEAD_RADIUS * 0.95F,
+                  -right_axis * 0.5F + QVector3D(0, -0.1F, -0.3F));
+
+    auto draw_pauldron_rivet = [&](const QVector3D &shoulder,
+                                   const QVector3D &outward) {
+      for (int i = 0; i < 3; ++i) {
+        float const seg_y = shoulder.y() + 0.025F - i * 0.045F;
+        QVector3D rivet_pos = shoulder + outward * (0.04F + i * 0.008F);
+        rivet_pos.setY(seg_y);
+
+        draw_stud(rivet_pos);
+      }
+    };
+
+    draw_pauldron_rivet(pose.shoulderL, -right_axis);
+    draw_pauldron_rivet(pose.shoulderR, right_axis);
+
+    QVector3D const gorget_top(0, y_top_cover + 0.045F, 0);
+    for (int i = 0; i < 6; ++i) {
+      float const angle = (i / 6.0F) * 2.0F * std::numbers::pi_v<float>;
+      float const x = HP::NECK_RADIUS * 2.58F * std::sin(angle);
+      float const z = HP::NECK_RADIUS * 2.58F * std::cos(angle);
+      draw_stud(gorget_top + QVector3D(x, 0, z));
+    }
+
+    QVector3D const belt_center(0, HP::WAIST_Y + 0.03F,
+                                HP::TORSO_BOT_R * 1.15F);
+    QMatrix4x4 buckle = ctx.model;
+    buckle.translate(belt_center);
+    buckle.scale(0.035F, 0.025F, 0.012F);
+    out.mesh(getUnitSphere(), buckle, brass_color * 1.25F, nullptr, 1.0F);
+
+    QVector3D const buckle_h1 = belt_center + QVector3D(-0.025F, 0, 0.005F);
+    QVector3D const buckle_h2 = belt_center + QVector3D(0.025F, 0, 0.005F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, buckle_h1, buckle_h2, 0.006F),
+             brass_color * 1.4F, nullptr, 1.0F);
+
+    QVector3D const buckle_v1 = belt_center + QVector3D(0, -0.018F, 0.005F);
+    QVector3D const buckle_v2 = belt_center + QVector3D(0, 0.018F, 0.005F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, buckle_v1, buckle_v2, 0.006F),
+             brass_color * 1.4F, nullptr, 1.0F);
+  }
+
+private:
+  static auto computeKnightExtras(uint32_t seed,
+                                  const HumanoidVariant &v) -> KnightExtras {
+    KnightExtras e;
+
+    e.metalColor = QVector3D(0.72F, 0.73F, 0.78F);
+
+    float const shield_hue = hash_01(seed ^ 0x12345U);
+    if (shield_hue < 0.45F) {
+      e.shieldColor = v.palette.cloth * 1.10F;
+    } else if (shield_hue < 0.90F) {
+      e.shieldColor = v.palette.leather * 1.25F;
+    } else {
+
+      e.shieldColor = e.metalColor * 0.95F;
+    }
+
+    e.swordLength = 0.80F + (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.16F;
+    e.swordWidth = 0.060F + (hash_01(seed ^ 0x7777U) - 0.5F) * 0.010F;
+    e.shieldRadius = 0.16F + (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
+
+    e.guard_half_width = 0.120F + (hash_01(seed ^ 0x3456U) - 0.5F) * 0.020F;
+    e.handleRadius = 0.016F + (hash_01(seed ^ 0x88AAU) - 0.5F) * 0.003F;
+    e.pommelRadius = 0.045F + (hash_01(seed ^ 0x19C3U) - 0.5F) * 0.006F;
+
+    e.bladeRicasso =
+        clampf(0.14F + (hash_01(seed ^ 0xBEEFU) - 0.5F) * 0.04F, 0.10F, 0.20F);
+    e.bladeTaperBias = clamp01(0.6F + (hash_01(seed ^ 0xFACEU) - 0.5F) * 0.2F);
+
+    e.shieldCrossDecal = (hash_01(seed ^ 0xA11CU) > 0.55F);
+    e.hasScabbard = (hash_01(seed ^ 0x5CABU) > 0.15F);
+    e.shieldTrimColor = e.metalColor * 0.95F;
+    e.shieldAspect = 1.0F;
+    return e;
+  }
+
+  static void drawSword(const DrawContext &ctx, const HumanoidPose &pose,
+                        const HumanoidVariant &v, const KnightExtras &extras,
+                        bool is_attacking, float attack_phase,
+                        ISubmitter &out) {
+    QVector3D const grip_pos = pose.hand_r;
+
+    constexpr float k_sword_yaw_deg = 25.0F;
+    QMatrix4x4 yaw_m;
+    yaw_m.rotate(k_sword_yaw_deg, 0.0F, 1.0F, 0.0F);
+
+    QVector3D upish = yaw_m.map(QVector3D(0.05F, 1.0F, 0.15F));
+    QVector3D midish = yaw_m.map(QVector3D(0.08F, 0.20F, 1.0F));
+    QVector3D downish = yaw_m.map(QVector3D(0.10F, -1.0F, 0.25F));
+    if (upish.lengthSquared() > 1e-6F) {
+      upish.normalize();
+    }
+    if (midish.lengthSquared() > 1e-6F) {
+      midish.normalize();
+    }
+    if (downish.lengthSquared() > 1e-6F) {
+      downish.normalize();
+    }
+
+    QVector3D sword_dir = upish;
+
+    if (is_attacking) {
+      if (attack_phase < 0.18F) {
+        float const t = easeInOutCubic(attack_phase / 0.18F);
+        sword_dir = nlerp(upish, upish, t);
+      } else if (attack_phase < 0.32F) {
+        float const t = easeInOutCubic((attack_phase - 0.18F) / 0.14F);
+        sword_dir = nlerp(upish, midish, t * 0.35F);
+      } else if (attack_phase < 0.52F) {
+        float t = (attack_phase - 0.32F) / 0.20F;
+        t = t * t * t;
+        if (t < 0.5F) {
+          float const u = t / 0.5F;
+          sword_dir = nlerp(upish, midish, u);
+        } else {
+          float const u = (t - 0.5F) / 0.5F;
+          sword_dir = nlerp(midish, downish, u);
+        }
+      } else if (attack_phase < 0.72F) {
+        float const t = easeInOutCubic((attack_phase - 0.52F) / 0.20F);
+        sword_dir = nlerp(downish, midish, t);
+      } else {
+        float const t = smoothstep(0.72F, 1.0F, attack_phase);
+        sword_dir = nlerp(midish, upish, t);
+      }
+    }
+
+    QVector3D const handle_end = grip_pos - sword_dir * 0.10F;
+    QVector3D const blade_base = grip_pos;
+    QVector3D const blade_tip = grip_pos + sword_dir * extras.swordLength;
+
+    out.mesh(
+        getUnitCylinder(),
+        cylinderBetween(ctx.model, handle_end, blade_base, extras.handleRadius),
+        v.palette.leather, nullptr, 1.0F);
+
+    QVector3D const guard_center = blade_base;
+    float const gw = extras.guard_half_width;
+
+    QVector3D guard_right =
+        QVector3D::crossProduct(QVector3D(0, 1, 0), sword_dir);
+    if (guard_right.lengthSquared() < 1e-6F) {
+      guard_right = QVector3D::crossProduct(QVector3D(1, 0, 0), sword_dir);
+    }
+    guard_right.normalize();
+
+    QVector3D const guard_l = guard_center - guard_right * gw;
+    QVector3D const guard_r = guard_center + guard_right * gw;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, guard_l, guard_r, 0.014F),
+             extras.metalColor, nullptr, 1.0F);
+
+    QMatrix4x4 gl = ctx.model;
+    gl.translate(guard_l);
+    gl.scale(0.018F);
+    out.mesh(getUnitSphere(), gl, extras.metalColor, nullptr, 1.0F);
+    QMatrix4x4 gr = ctx.model;
+    gr.translate(guard_r);
+    gr.scale(0.018F);
+    out.mesh(getUnitSphere(), gr, extras.metalColor, nullptr, 1.0F);
+
+    float const l = extras.swordLength;
+    float const base_w = extras.swordWidth;
+    float blade_thickness = base_w * 0.15F;
+
+    float const ricasso_len = clampf(extras.bladeRicasso, 0.10F, l * 0.30F);
+    QVector3D const ricasso_end = blade_base + sword_dir * ricasso_len;
+
+    float const mid_w = base_w * 0.95F;
+    float const tip_w = base_w * 0.28F;
+    float const tip_start_dist = lerp(ricasso_len, l, 0.70F);
+    QVector3D const tip_start = blade_base + sword_dir * tip_start_dist;
+
+    auto draw_flat_section = [&](const QVector3D &start, const QVector3D &end,
+                                 float width, const QVector3D &color) {
+      QVector3D right = QVector3D::crossProduct(sword_dir, QVector3D(0, 1, 0));
+      if (right.lengthSquared() < 0.001F) {
+        right = QVector3D::crossProduct(sword_dir, QVector3D(1, 0, 0));
+      }
+      right.normalize();
+
+      float const offset = width * 0.33F;
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, start, end, blade_thickness), color,
+               nullptr, 1.0F);
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, start + right * offset,
+                               end + right * offset, blade_thickness * 0.8F),
+               color * 0.92F, nullptr, 1.0F);
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, start - right * offset,
+                               end - right * offset, blade_thickness * 0.8F),
+               color * 0.92F, nullptr, 1.0F);
+    };
+
+    draw_flat_section(blade_base, ricasso_end, base_w, extras.metalColor);
+
+    draw_flat_section(ricasso_end, tip_start, mid_w, extras.metalColor);
+
+    int const tip_segments = 3;
+    for (int i = 0; i < tip_segments; ++i) {
+      float const t0 = (float)i / tip_segments;
+      float const t1 = (float)(i + 1) / tip_segments;
+      QVector3D const seg_start =
+          tip_start + sword_dir * ((blade_tip - tip_start).length() * t0);
+      QVector3D const seg_end =
+          tip_start + sword_dir * ((blade_tip - tip_start).length() * t1);
+      float const w = lerp(mid_w, tip_w, t1);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, seg_start, seg_end, blade_thickness),
+               extras.metalColor * (1.0F - i * 0.03F), nullptr, 1.0F);
+    }
+
+    QVector3D const fuller_start =
+        blade_base + sword_dir * (ricasso_len + 0.02F);
+    QVector3D const fuller_end =
+        blade_base + sword_dir * (tip_start_dist - 0.06F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, fuller_start, fuller_end,
+                             blade_thickness * 0.6F),
+             extras.metalColor * 0.65F, nullptr, 1.0F);
+
+    QVector3D const pommel = handle_end - sword_dir * 0.02F;
+    QMatrix4x4 pommel_mat = ctx.model;
+    pommel_mat.translate(pommel);
+    pommel_mat.scale(extras.pommelRadius);
+    out.mesh(getUnitSphere(), pommel_mat, extras.metalColor, nullptr, 1.0F);
+
+    if (is_attacking && attack_phase >= 0.32F && attack_phase < 0.56F) {
+      float const t = (attack_phase - 0.32F) / 0.24F;
+      float const alpha = clamp01(0.35F * (1.0F - t));
+      QVector3D const trail_start = blade_base - sword_dir * 0.05F;
+      QVector3D const trail_end = blade_base - sword_dir * (0.28F + 0.15F * t);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, trail_end, trail_start, base_w * 0.9F),
+               extras.metalColor * 0.9F, nullptr, alpha);
+    }
+  }
+
+  static void drawShield(const DrawContext &ctx, const HumanoidPose &pose,
+                         const HumanoidVariant &v, const KnightExtras &extras,
+                         ISubmitter &out) {
+
+    constexpr float k_scale_factor = 2.5F;
+    constexpr float k_shield_yaw_degrees = -70.0F;
+
+    QMatrix4x4 rot;
+    rot.rotate(k_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+
+    const QVector3D n = rot.map(QVector3D(0.0F, 0.0F, 1.0F));
+    const QVector3D axis_x = rot.map(QVector3D(1.0F, 0.0F, 0.0F));
+    const QVector3D axis_y = rot.map(QVector3D(0.0F, 1.0F, 0.0F));
+
+    float const base_extent = extras.shieldRadius * k_scale_factor;
+    float const shield_width = base_extent;
+    float const shield_height = base_extent * extras.shieldAspect;
+    float const min_extent = std::min(shield_width, shield_height);
+
+    QVector3D shield_center = pose.handL + axis_x * (-shield_width * 0.35F) +
+                              axis_y * (-0.05F) + n * (0.06F);
+
+    const float plate_half = 0.0015F;
+    const float plate_full = plate_half * 2.0F;
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center + n * plate_half);
+      m.rotate(k_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+      m.scale(shield_width, shield_height, plate_full);
+      out.mesh(getUnitCylinder(), m, extras.shieldColor, nullptr, 1.0F);
+    }
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center - n * plate_half);
+      m.rotate(k_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+      m.scale(shield_width * 0.985F, shield_height * 0.985F, plate_full);
+      out.mesh(getUnitCylinder(), m, v.palette.leather * 0.8F, nullptr, 1.0F);
+    }
+
+    auto draw_ring_rotated = [&](float width, float height, float thickness,
+                                 const QVector3D &color) {
+      constexpr int k_segments = 18;
+      for (int i = 0; i < k_segments; ++i) {
+        float const a0 =
+            (float)i / k_segments * 2.0F * std::numbers::pi_v<float>;
+        float const a1 =
+            (float)(i + 1) / k_segments * 2.0F * std::numbers::pi_v<float>;
+
+        QVector3D const v0(width * std::cos(a0), height * std::sin(a0), 0.0F);
+        QVector3D const v1(width * std::cos(a1), height * std::sin(a1), 0.0F);
+
+        QVector3D const p0 = shield_center + rot.map(v0);
+        QVector3D const p1 = shield_center + rot.map(v1);
+
+        out.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, p0, p1, thickness), color, nullptr,
+                 1.0F);
+      }
+    };
+
+    draw_ring_rotated(shield_width, shield_height, min_extent * 0.010F,
+                      extras.shieldTrimColor * 0.95F);
+    draw_ring_rotated(shield_width * 0.72F, shield_height * 0.72F,
+                      min_extent * 0.006F, v.palette.leather * 0.90F);
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center + n * (0.02F * k_scale_factor));
+      m.scale(0.045F * k_scale_factor);
+      out.mesh(getUnitSphere(), m, extras.metalColor, nullptr, 1.0F);
+    }
+
+    {
+      QVector3D const grip_a = shield_center - axis_x * 0.035F - n * 0.030F;
+      QVector3D const grip_b = shield_center + axis_x * 0.035F - n * 0.030F;
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, grip_a, grip_b, 0.010F),
+               v.palette.leather, nullptr, 1.0F);
+    }
+
+    if (extras.shieldCrossDecal) {
+      QVector3D const center_front =
+          shield_center + n * (plate_full * 0.5F + 0.0015F);
+      float const bar_radius = min_extent * 0.10F;
+
+      QVector3D const top = center_front + axis_y * (shield_height * 0.90F);
+      QVector3D const bot = center_front - axis_y * (shield_height * 0.90F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, top, bot, bar_radius),
+               extras.shieldTrimColor, nullptr, 1.0F);
+
+      QVector3D const left = center_front - axis_x * (shield_width * 0.90F);
+      QVector3D const right = center_front + axis_x * (shield_width * 0.90F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, left, right, bar_radius),
+               extras.shieldTrimColor, nullptr, 1.0F);
+    }
+  }
+
+  static void drawScabbard(const DrawContext &ctx, const HumanoidPose &,
+                           const HumanoidVariant &v, const KnightExtras &extras,
+                           ISubmitter &out) {
+    using HP = HumanProportions;
+
+    QVector3D const hip(0.10F, HP::WAIST_Y - 0.04F, -0.02F);
+    QVector3D const tip = hip + QVector3D(-0.05F, -0.22F, -0.12F);
+    float const sheath_r = extras.swordWidth * 0.85F;
+
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, hip, tip, sheath_r),
+             v.palette.leather * 0.9F, nullptr, 1.0F);
+
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, tip, tip + QVector3D(-0.02F, -0.02F, -0.02F),
+                        sheath_r),
+             extras.metalColor, nullptr, 1.0F);
+
+    QVector3D const strap_a = hip + QVector3D(0.00F, 0.03F, 0.00F);
+    QVector3D const belt = QVector3D(0.12F, HP::WAIST_Y + 0.01F, 0.02F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, strap_a, belt, 0.006F),
+             v.palette.leather, nullptr, 1.0F);
+  }
+
+  auto
+  resolve_style(const DrawContext &ctx) const -> const KnightStyleConfig & {
+    ensure_knight_styles_registered();
+    auto &styles = knight_style_registry();
+    std::string nation_id;
+    if (ctx.entity != nullptr) {
+      if (auto *unit =
+              ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+        nation_id = unit->nation_id;
+      }
+    }
+    if (!nation_id.empty()) {
+      auto it = styles.find(nation_id);
+      if (it != styles.end()) {
+        return it->second;
+      }
+    }
+    auto it_default = styles.find(std::string(k_knight_default_style_key));
+    if (it_default != styles.end()) {
+      return it_default->second;
+    }
+    static const KnightStyleConfig k_empty{};
+    return k_empty;
+  }
+
+public:
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    const KnightStyleConfig &style = resolve_style(ctx);
+    if (!style.shader_id.empty()) {
+      return QString::fromStdString(style.shader_id);
+    }
+    return QStringLiteral("knight");
+  }
+
+private:
+  void apply_palette_overrides(const KnightStyleConfig &style,
+                               const QVector3D &team_tint,
+                               HumanoidVariant &variant) const {
+    auto apply_color = [&](const std::optional<QVector3D> &override_color,
+                           QVector3D &target) {
+      target = mix_palette_color(target, override_color, team_tint,
+                                 k_knight_team_mix_weight,
+                                 k_knight_style_mix_weight);
+    };
+
+    apply_color(style.cloth_color, variant.palette.cloth);
+    apply_color(style.leather_color, variant.palette.leather);
+    apply_color(style.leather_dark_color, variant.palette.leatherDark);
+    apply_color(style.metal_color, variant.palette.metal);
+  }
+
+  void apply_extras_overrides(const KnightStyleConfig &style,
+                              const QVector3D &team_tint,
+                              const HumanoidVariant &variant,
+                              KnightExtras &extras) const {
+    extras.metalColor = saturate_color(variant.palette.metal);
+    extras.shieldColor = saturate_color(extras.shieldColor);
+    extras.shieldTrimColor = saturate_color(extras.shieldTrimColor);
+
+    auto apply_shield_color =
+        [&](const std::optional<QVector3D> &override_color, QVector3D &target) {
+          target = mix_palette_color(target, override_color, team_tint,
+                                     k_knight_team_mix_weight,
+                                     k_knight_style_mix_weight);
+        };
+
+    apply_shield_color(style.shield_color, extras.shieldColor);
+    apply_shield_color(style.shield_trim_color, extras.shieldTrimColor);
+
+    if (style.shield_radius_scale) {
+      extras.shieldRadius =
+          std::max(0.10F, extras.shieldRadius * *style.shield_radius_scale);
+    }
+    if (style.shield_aspect_ratio) {
+      extras.shieldAspect = std::max(0.40F, *style.shield_aspect_ratio);
+    }
+    if (style.has_scabbard) {
+      extras.hasScabbard = *style.has_scabbard;
+    }
+    if (style.shield_cross_decal) {
+      extras.shieldCrossDecal = *style.shield_cross_decal;
+    }
+  }
+};
+
+void registerKnightRenderer(Render::GL::EntityRendererRegistry &registry) {
+  ensure_knight_styles_registered();
+  static KnightRenderer const renderer;
+  registry.registerRenderer(
+      "troops/kingdom/swordsman", [](const DrawContext &ctx, ISubmitter &out) {
+        static KnightRenderer const static_renderer;
+        Shader *knight_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          knight_shader = ctx.backend->shader(shader_key);
+          if (knight_shader == nullptr) {
+            knight_shader = ctx.backend->shader(QStringLiteral("knight"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) && (knight_shader != nullptr)) {
+          scene_renderer->setCurrentShader(knight_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+  registry.registerRenderer(
+      "troops/kingdom/knight", [](const DrawContext &ctx, ISubmitter &out) {
+        static KnightRenderer const static_renderer;
+        Shader *knight_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          knight_shader = ctx.backend->shader(QStringLiteral("knight"));
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) && (knight_shader != nullptr)) {
+          scene_renderer->setCurrentShader(knight_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Kingdom

+ 15 - 0
render/entity/nations/kingdom/knight_renderer.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include "../../registry.h"
+#include <string>
+
+namespace Render::GL::Kingdom {
+
+struct KnightStyleConfig;
+
+void register_knight_style(const std::string &nation_id,
+                           const KnightStyleConfig &style);
+
+void registerKnightRenderer(EntityRendererRegistry &registry);
+
+} // namespace Render::GL::Kingdom

+ 33 - 0
render/entity/nations/kingdom/knight_style.cpp

@@ -0,0 +1,33 @@
+#include "knight_style.h"
+#include "knight_renderer.h"
+
+#include <QVector3D>
+
+namespace {
+constexpr QVector3D k_kingdom_cloth{0.24F, 0.28F, 0.34F};
+constexpr QVector3D k_kingdom_leather{0.30F, 0.20F, 0.12F};
+constexpr QVector3D k_kingdom_leather_dark{0.18F, 0.14F, 0.10F};
+constexpr QVector3D k_kingdom_metal{0.72F, 0.74F, 0.78F};
+constexpr QVector3D k_kingdom_shield{0.20F, 0.22F, 0.30F};
+constexpr QVector3D k_kingdom_shield_trim{0.78F, 0.76F, 0.62F};
+} // namespace
+
+namespace Render::GL::Kingdom {
+
+void register_kingdom_knight_style() {
+  KnightStyleConfig style;
+  style.cloth_color = k_kingdom_cloth;
+  style.leather_color = k_kingdom_leather;
+  style.leather_dark_color = k_kingdom_leather_dark;
+  style.metal_color = k_kingdom_metal;
+  style.shield_color = k_kingdom_shield;
+  style.shield_trim_color = k_kingdom_shield_trim;
+  style.shield_radius_scale = 1.0F;
+  style.shield_aspect_ratio = 1.0F;
+  style.has_scabbard = true;
+  style.shader_id = "knight_kingdom_of_iron";
+
+  register_knight_style("kingdom_of_iron", style);
+}
+
+} // namespace Render::GL::Kingdom

+ 27 - 0
render/entity/nations/kingdom/knight_style.h

@@ -0,0 +1,27 @@
+#pragma once
+
+#include <QVector3D>
+#include <optional>
+#include <string>
+
+namespace Render::GL::Kingdom {
+
+struct KnightStyleConfig {
+  std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> leather_color;
+  std::optional<QVector3D> leather_dark_color;
+  std::optional<QVector3D> metal_color;
+  std::optional<QVector3D> shield_color;
+  std::optional<QVector3D> shield_trim_color;
+
+  std::optional<float> shield_radius_scale;
+  std::optional<float> shield_aspect_ratio;
+  std::optional<bool> shield_cross_decal;
+  std::optional<bool> has_scabbard;
+
+  std::string shader_id;
+};
+
+void register_kingdom_knight_style();
+
+} // namespace Render::GL::Kingdom

+ 807 - 0
render/entity/nations/kingdom/mounted_knight_renderer.cpp

@@ -0,0 +1,807 @@
+#include "mounted_knight_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../../game/core/entity.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/shader.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid_math.h"
+#include "../../../humanoid_specs.h"
+#include "../../../palette.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../horse_renderer.h"
+#include "../../registry.h"
+#include "../../renderer_constants.h"
+#include <numbers>
+#include <qmatrix4x4.h>
+#include <qstringliteral.h>
+#include <qvectornd.h>
+#include <unordered_map>
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+
+namespace Render::GL::Kingdom {
+
+using Render::Geom::clamp01;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::easeInOutCubic;
+using Render::Geom::smoothstep;
+using Render::Geom::sphereAt;
+
+struct MountedKnightExtras {
+  QVector3D metalColor;
+  HorseProfile horseProfile;
+  float swordLength = 0.85F;
+  float swordWidth = 0.045F;
+  bool hasSword = true;
+  bool hasCavalryShield = false;
+};
+
+class MountedKnightRenderer : public HumanoidRendererBase {
+public:
+  auto getProportionScaling() const -> QVector3D override {
+    return {1.40F, 1.05F, 1.10F};
+  }
+
+private:
+  mutable std::unordered_map<uint32_t, MountedKnightExtras> m_extrasCache;
+  HorseRenderer m_horseRenderer;
+
+public:
+  void getVariant(const DrawContext &ctx, uint32_t seed,
+                  HumanoidVariant &v) const override {
+    QVector3D const team_tint = resolveTeamTint(ctx);
+    v.palette = makeHumanoidPalette(team_tint, seed);
+  }
+
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    std::string nation;
+    if (ctx.entity != nullptr) {
+      if (auto *unit =
+              ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+        nation = unit->nation_id;
+      }
+    }
+    if (!nation.empty()) {
+      return QString::fromStdString(std::string("mounted_knight_") + nation);
+    }
+    return QStringLiteral("mounted_knight");
+  }
+
+  void customizePose(const DrawContext &ctx,
+                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                     HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    const AnimationInputs &anim = anim_ctx.inputs;
+
+    const float arm_height_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.03F;
+    const float arm_asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
+
+    uint32_t horse_seed = seed;
+    if (ctx.entity != nullptr) {
+      horse_seed = static_cast<uint32_t>(
+          reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU);
+    }
+
+    const HorseDimensions dims = makeHorseDimensions(horse_seed);
+    HorseProfile mount_profile{};
+    mount_profile.dims = dims;
+    const HorseMountFrame mount = compute_mount_frame(mount_profile);
+
+    const float saddle_height = mount.seat_position.y();
+    const float offset_y = saddle_height - pose.pelvisPos.y();
+
+    pose.pelvisPos.setY(pose.pelvisPos.y() + offset_y);
+    pose.headPos.setY(pose.headPos.y() + offset_y);
+    pose.neck_base.setY(pose.neck_base.y() + offset_y);
+    pose.shoulderL.setY(pose.shoulderL.y() + offset_y);
+    pose.shoulderR.setY(pose.shoulderR.y() + offset_y);
+
+    float const speed_norm = anim_ctx.locomotion_normalized_speed();
+    float const speed_lean = std::clamp(
+        anim_ctx.locomotion_speed() * 0.10F + speed_norm * 0.05F, 0.0F, 0.22F);
+    const float lean_forward = dims.seatForwardOffset * 0.08F + speed_lean;
+    pose.shoulderL.setZ(pose.shoulderL.z() + lean_forward);
+    pose.shoulderR.setZ(pose.shoulderR.z() + lean_forward);
+
+    pose.footYOffset = 0.0F;
+    pose.footL = mount.stirrup_bottom_left;
+    pose.foot_r = mount.stirrup_bottom_right;
+
+    const float knee_y =
+        mount.stirrup_bottom_left.y() +
+        (saddle_height - mount.stirrup_bottom_left.y()) * 0.62F;
+    const float knee_z = mount.stirrup_bottom_left.z() * 0.60F + 0.06F;
+
+    QVector3D knee_left = mount.stirrup_attach_left;
+    knee_left.setY(knee_y);
+    knee_left.setZ(knee_z);
+    pose.knee_l = knee_left;
+
+    QVector3D knee_right = mount.stirrup_attach_right;
+    knee_right.setY(knee_y);
+    knee_right.setZ(knee_z);
+    pose.knee_r = knee_right;
+
+    float const shoulder_height = pose.shoulderL.y();
+    float const rein_extension = std::clamp(
+        speed_norm * 0.14F + anim_ctx.locomotion_speed() * 0.015F, 0.0F, 0.12F);
+    float const rein_drop = std::clamp(
+        speed_norm * 0.06F + anim_ctx.locomotion_speed() * 0.008F, 0.0F, 0.04F);
+
+    QVector3D forward = anim_ctx.heading_forward();
+    QVector3D right = anim_ctx.heading_right();
+    QVector3D up = anim_ctx.heading_up();
+    float const rein_spread =
+        std::abs(mount.rein_attach_right.x() - mount.rein_attach_left.x()) *
+        0.5F;
+
+    QVector3D rest_hand_r = mount.rein_attach_right;
+    rest_hand_r += forward * (0.08F + rein_extension);
+    rest_hand_r -= right * (0.10F - arm_asymmetry * 0.05F);
+    rest_hand_r += up * (0.05F + arm_height_jitter * 0.6F - rein_drop);
+
+    QVector3D rest_hand_l = mount.rein_attach_left;
+    rest_hand_l += forward * (0.05F + rein_extension * 0.6F);
+    rest_hand_l += right * (0.08F + arm_asymmetry * 0.04F);
+    rest_hand_l += up * (0.04F - arm_height_jitter * 0.5F - rein_drop * 0.6F);
+
+    float const rein_forward = rest_hand_r.z();
+
+    pose.elbowL =
+        QVector3D(pose.shoulderL.x() * 0.4F + rest_hand_l.x() * 0.6F,
+                  (pose.shoulderL.y() + rest_hand_l.y()) * 0.5F - 0.08F,
+                  (pose.shoulderL.z() + rest_hand_l.z()) * 0.5F);
+    pose.elbowR =
+        QVector3D(pose.shoulderR.x() * 0.4F + rest_hand_r.x() * 0.6F,
+                  (pose.shoulderR.y() + rest_hand_r.y()) * 0.5F - 0.08F,
+                  (pose.shoulderR.z() + rest_hand_r.z()) * 0.5F);
+
+    if (anim.is_attacking && anim.isMelee) {
+      float const attack_phase =
+          std::fmod(anim.time * MOUNTED_KNIGHT_INV_ATTACK_CYCLE_TIME, 1.0F);
+
+      QVector3D const rest_pos = rest_hand_r;
+      QVector3D const windup_pos =
+          QVector3D(rest_hand_r.x() + 0.32F, shoulder_height + 0.15F,
+                    rein_forward - 0.35F);
+      QVector3D const raised_pos = QVector3D(
+          rein_spread + 0.38F, shoulder_height + 0.28F, rein_forward - 0.25F);
+      QVector3D const slash_pos = QVector3D(
+          -rein_spread * 0.65F, shoulder_height - 0.08F, rein_forward + 0.85F);
+      QVector3D const follow_through = QVector3D(
+          -rein_spread * 0.85F, shoulder_height - 0.15F, rein_forward + 0.60F);
+      QVector3D const recover_pos = QVector3D(
+          rein_spread * 0.45F, shoulder_height - 0.05F, rein_forward + 0.25F);
+
+      if (attack_phase < 0.18F) {
+
+        float const t = easeInOutCubic(attack_phase / 0.18F);
+        pose.hand_r = rest_pos * (1.0F - t) + windup_pos * t;
+      } else if (attack_phase < 0.30F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.18F) / 0.12F);
+        pose.hand_r = windup_pos * (1.0F - t) + raised_pos * t;
+      } else if (attack_phase < 0.48F) {
+
+        float t = (attack_phase - 0.30F) / 0.18F;
+        t = t * t * t;
+        pose.hand_r = raised_pos * (1.0F - t) + slash_pos * t;
+      } else if (attack_phase < 0.62F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.48F) / 0.14F);
+        pose.hand_r = slash_pos * (1.0F - t) + follow_through * t;
+      } else if (attack_phase < 0.80F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.62F) / 0.18F);
+        pose.hand_r = follow_through * (1.0F - t) + recover_pos * t;
+      } else {
+
+        float const t = smoothstep(0.80F, 1.0F, attack_phase);
+        pose.hand_r = recover_pos * (1.0F - t) + rest_pos * t;
+      }
+
+      float const rein_tension = clamp01((attack_phase - 0.10F) * 2.2F);
+      pose.handL = rest_hand_l + QVector3D(0.0F, -0.015F * rein_tension,
+                                           0.10F * rein_tension);
+
+      pose.elbowR =
+          QVector3D(pose.shoulderR.x() * 0.3F + pose.hand_r.x() * 0.7F,
+                    (pose.shoulderR.y() + pose.hand_r.y()) * 0.5F - 0.12F,
+                    (pose.shoulderR.z() + pose.hand_r.z()) * 0.5F);
+      pose.elbowL =
+          QVector3D(pose.shoulderL.x() * 0.4F + pose.handL.x() * 0.6F,
+                    (pose.shoulderL.y() + pose.handL.y()) * 0.5F - 0.08F,
+                    (pose.shoulderL.z() + pose.handL.z()) * 0.5F);
+    } else {
+      pose.hand_r = rest_hand_r;
+      pose.handL = rest_hand_l;
+    }
+  }
+
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose,
+                      const HumanoidAnimationContext &anim_ctx,
+                      ISubmitter &out) const override {
+    const AnimationInputs &anim = anim_ctx.inputs;
+    uint32_t horse_seed = 0U;
+    if (ctx.entity != nullptr) {
+      horse_seed = static_cast<uint32_t>(
+          reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU);
+    }
+
+    MountedKnightExtras extras;
+    auto it = m_extrasCache.find(horse_seed);
+    if (it != m_extrasCache.end()) {
+      extras = it->second;
+    } else {
+      extras = computeMountedKnightExtras(horse_seed, v);
+      m_extrasCache[horse_seed] = extras;
+
+      if (m_extrasCache.size() > MAX_EXTRAS_CACHE_SIZE) {
+        m_extrasCache.clear();
+      }
+    }
+
+    m_horseRenderer.render(ctx, anim, anim_ctx, extras.horseProfile, out);
+
+    bool const is_attacking = anim.is_attacking && anim.isMelee;
+    float attack_phase = 0.0F;
+    if (is_attacking) {
+      attack_phase =
+          std::fmod(anim.time * MOUNTED_KNIGHT_INV_ATTACK_CYCLE_TIME, 1.0F);
+    }
+
+    if (extras.hasSword) {
+      drawSword(ctx, pose, v, extras, is_attacking, attack_phase, out);
+    }
+
+    if (extras.hasCavalryShield) {
+      drawCavalryShield(ctx, pose, v, extras, out);
+    }
+  }
+
+  void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose, ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    const QVector3D steel_color = v.palette.metal * STEEL_TINT;
+
+    float helm_r = pose.headR * 1.15F;
+    QVector3D const helm_bot(0, pose.headPos.y() - pose.headR * 0.20F, 0);
+    QVector3D const helm_top(0, pose.headPos.y() + pose.headR * 1.40F, 0);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_bot, helm_top, helm_r),
+             steel_color, nullptr, 1.0F);
+
+    QVector3D const cap_top(0, pose.headPos.y() + pose.headR * 1.48F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.98F),
+             steel_color * 1.05F, nullptr, 1.0F);
+
+    const QVector3D ring_color = steel_color * 1.08F;
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 1.25F, 0), helm_r * 1.02F,
+         0.015F, ring_color);
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.50F, 0), helm_r * 1.02F,
+         0.015F, ring_color);
+    ring(QVector3D(0, pose.headPos.y() - pose.headR * 0.05F, 0), helm_r * 1.02F,
+         0.015F, ring_color);
+
+    const float visor_y = pose.headPos.y() + pose.headR * 0.15F;
+    const float visor_z = helm_r * 0.72F;
+    static const QVector3D visor_color(0.1F, 0.1F, 0.1F);
+
+    QVector3D const visor_hl(-helm_r * 0.35F, visor_y, visor_z);
+    QVector3D const visor_hr(helm_r * 0.35F, visor_y, visor_z);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, visor_hl, visor_hr, 0.012F),
+             visor_color, nullptr, 1.0F);
+
+    QVector3D const visor_vt(0, visor_y + helm_r * 0.25F, visor_z);
+    QVector3D const visor_vb(0, visor_y - helm_r * 0.25F, visor_z);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, visor_vb, visor_vt, 0.012F),
+             visor_color, nullptr, 1.0F);
+
+    auto draw_breathing_hole = [&](float x, float y) {
+      QVector3D const pos(x, pose.headPos.y() + y, helm_r * 0.70F);
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.010F);
+      out.mesh(getUnitSphere(), m, QVector3D(0.1F, 0.1F, 0.1F), nullptr, 1.0F);
+    };
+
+    for (int i = 0; i < 4; ++i) {
+      draw_breathing_hole(helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+    }
+
+    for (int i = 0; i < 4; ++i) {
+      draw_breathing_hole(-helm_r * 0.50F, pose.headR * (0.05F - i * 0.10F));
+    }
+
+    const QVector3D plume_base(0, pose.headPos.y() + pose.headR * 1.50F, 0);
+    const QVector3D brass_color = v.palette.metal * BRASS_TINT;
+
+    QMatrix4x4 plume = ctx.model;
+    plume.translate(plume_base);
+    plume.scale(0.030F, 0.015F, 0.030F);
+    out.mesh(getUnitSphere(), plume, brass_color * 1.2F, nullptr, 1.0F);
+
+    for (int i = 0; i < 5; ++i) {
+      float const offset = i * 0.025F;
+      QVector3D const feather_start =
+          plume_base + QVector3D(0, 0.005F, -0.020F + offset * 0.5F);
+      QVector3D const feather_end =
+          feather_start +
+          QVector3D(0, 0.15F - i * 0.015F, -0.08F + offset * 0.3F);
+
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, feather_start, feather_end, 0.008F),
+               v.palette.cloth * (1.1F - i * 0.05F), nullptr, 1.0F);
+    }
+  }
+
+  void draw_armorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, float y_top_cover,
+                         float torso_r, float, float upper_arm_r,
+                         const QVector3D &right_axis,
+                         ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    const QVector3D steel_color = v.palette.metal * STEEL_TINT;
+    const QVector3D dark_steel = steel_color * 0.85F;
+    const QVector3D brass_color = v.palette.metal * BRASS_TINT;
+
+    QVector3D const bp_top(0, y_top_cover + 0.02F, 0);
+    QVector3D const bp_mid(0, (y_top_cover + pose.pelvisPos.y()) * 0.5F + 0.04F,
+                           0);
+    QVector3D const bp_bot(0, pose.pelvisPos.y() + 0.06F, 0);
+    float const r_chest = torso_r * 1.18F;
+    float const r_waist = torso_r * 1.14F;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, bp_top, bp_mid, r_chest), steel_color,
+             nullptr, 1.0F);
+
+    QVector3D const bp_mid_low(0, (bp_mid.y() + bp_bot.y()) * 0.5F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, bp_mid, bp_mid_low, r_chest * 0.98F),
+             steel_color * 0.99F, nullptr, 1.0F);
+
+    out.mesh(getUnitCone(), coneFromTo(ctx.model, bp_bot, bp_mid_low, r_waist),
+             steel_color * 0.98F, nullptr, 1.0F);
+
+    auto draw_rivet = [&](const QVector3D &pos) {
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.012F);
+      out.mesh(getUnitSphere(), m, brass_color, nullptr, 1.0F);
+    };
+
+    for (int i = 0; i < 8; ++i) {
+      float const angle = (i / 8.0F) * 2.0F * std::numbers::pi_v<float>;
+      float const x = r_chest * std::sin(angle) * 0.95F;
+      float const z = r_chest * std::cos(angle) * 0.95F;
+      draw_rivet(QVector3D(x, bp_mid.y() + 0.08F, z));
+    }
+
+    auto draw_pauldron = [&](const QVector3D &shoulder,
+                             const QVector3D &outward) {
+      for (int i = 0; i < 4; ++i) {
+        float const seg_y = shoulder.y() + 0.04F - i * 0.045F;
+        float const seg_r = upper_arm_r * (2.5F - i * 0.12F);
+        QVector3D seg_pos = shoulder + outward * (0.02F + i * 0.008F);
+        seg_pos.setY(seg_y);
+
+        out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_pos, seg_r),
+                 i == 0 ? steel_color * 1.05F
+                        : steel_color * (1.0F - i * 0.03F),
+                 nullptr, 1.0F);
+
+        if (i < 3) {
+          draw_rivet(seg_pos + QVector3D(0, 0.015F, 0.03F));
+        }
+      }
+    };
+
+    draw_pauldron(pose.shoulderL, -right_axis);
+    draw_pauldron(pose.shoulderR, right_axis);
+
+    auto draw_arm_plate = [&](const QVector3D &shoulder,
+                              const QVector3D &elbow) {
+      QVector3D dir = (elbow - shoulder);
+      float const len = dir.length();
+      if (len < 1e-5F) {
+        return;
+      }
+      dir /= len;
+
+      for (int i = 0; i < 3; ++i) {
+        float const t0 = 0.10F + i * 0.25F;
+        float const t1 = t0 + 0.22F;
+        QVector3D const a = shoulder + dir * (t0 * len);
+        QVector3D const b = shoulder + dir * (t1 * len);
+        float const r = upper_arm_r * (1.32F - i * 0.04F);
+
+        out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+                 steel_color * (0.98F - i * 0.02F), nullptr, 1.0F);
+
+        if (i < 2) {
+          draw_rivet(b);
+        }
+      }
+    };
+
+    draw_arm_plate(pose.shoulderL, pose.elbowL);
+    draw_arm_plate(pose.shoulderR, pose.elbowR);
+
+    QVector3D const gorget_top(0, y_top_cover + 0.025F, 0);
+    QVector3D const gorget_bot(0, y_top_cover - 0.012F, 0);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, gorget_bot, gorget_top,
+                             HP::NECK_RADIUS * 2.6F),
+             steel_color * 1.08F, nullptr, 1.0F);
+
+    ring(gorget_top, HP::NECK_RADIUS * 2.62F, 0.010F, brass_color);
+  }
+
+  void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
+                               const HumanoidPose &, float, float y_neck,
+                               const QVector3D &,
+                               ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    const QVector3D brass_color = v.palette.metal * BRASS_TINT;
+    const QVector3D chainmail_color = v.palette.metal * CHAINMAIL_TINT;
+    const QVector3D mantling_color = v.palette.cloth;
+
+    for (int i = 0; i < 5; ++i) {
+      float const y = y_neck - i * 0.022F;
+      float const r = HP::NECK_RADIUS * (1.85F + i * 0.08F);
+      QVector3D const ring_pos(0, y, 0);
+      QVector3D const a = ring_pos + QVector3D(0, 0.010F, 0);
+      QVector3D const b = ring_pos - QVector3D(0, 0.010F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+               chainmail_color * (1.0F - i * 0.04F), nullptr, 1.0F);
+    }
+
+    auto draw_stud = [&](const QVector3D &pos) {
+      QMatrix4x4 m = ctx.model;
+      m.translate(pos);
+      m.scale(0.008F);
+      out.mesh(getUnitSphere(), m, brass_color * 1.3F, nullptr, 1.0F);
+    };
+
+    QVector3D const belt_center(0, HP::WAIST_Y + 0.03F,
+                                HP::TORSO_BOT_R * 1.15F);
+    QMatrix4x4 buckle = ctx.model;
+    buckle.translate(belt_center);
+    buckle.scale(0.035F, 0.025F, 0.012F);
+    out.mesh(getUnitSphere(), buckle, brass_color * 1.25F, nullptr, 1.0F);
+
+    QVector3D const buckle_h1 = belt_center + QVector3D(-0.025F, 0, 0.005F);
+    QVector3D const buckle_h2 = belt_center + QVector3D(0.025F, 0, 0.005F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, buckle_h1, buckle_h2, 0.006F),
+             brass_color * 1.4F, nullptr, 1.0F);
+
+    QVector3D const buckle_v1 = belt_center + QVector3D(0, -0.018F, 0.005F);
+    QVector3D const buckle_v2 = belt_center + QVector3D(0, 0.018F, 0.005F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, buckle_v1, buckle_v2, 0.006F),
+             brass_color * 1.4F, nullptr, 1.0F);
+  }
+
+private:
+  static auto
+  computeMountedKnightExtras(uint32_t seed,
+                             const HumanoidVariant &v) -> MountedKnightExtras {
+    MountedKnightExtras e;
+
+    e.metalColor = QVector3D(0.72F, 0.73F, 0.78F);
+
+    e.horseProfile = makeHorseProfile(seed, v.palette.leather, v.palette.cloth);
+
+    e.swordLength = 0.82F + (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.12F;
+    e.swordWidth = 0.042F + (hash_01(seed ^ 0x7777U) - 0.5F) * 0.008F;
+
+    e.hasSword = (hash_01(seed ^ 0xFACEU) > 0.15F);
+    e.hasCavalryShield = (hash_01(seed ^ 0xCAFEU) > 0.60F);
+
+    return e;
+  }
+
+  static void drawSword(const DrawContext &ctx, const HumanoidPose &pose,
+                        const HumanoidVariant &v,
+                        const MountedKnightExtras &extras, bool is_attacking,
+                        float attack_phase, ISubmitter &out) {
+
+    const QVector3D grip_pos = pose.hand_r;
+
+    QVector3D sword_dir(0.0F, 0.15F, 1.0F);
+    sword_dir.normalize();
+
+    QVector3D const world_up(0.0F, 1.0F, 0.0F);
+    QVector3D right_axis = QVector3D::crossProduct(world_up, sword_dir);
+    if (right_axis.lengthSquared() < 1e-6F) {
+      right_axis = QVector3D(1.0F, 0.0F, 0.0F);
+    }
+    right_axis.normalize();
+    QVector3D up_axis = QVector3D::crossProduct(sword_dir, right_axis);
+    up_axis.normalize();
+
+    const QVector3D steel = extras.metalColor;
+    const QVector3D steel_hi = steel * 1.18F;
+    const QVector3D steel_lo = steel * 0.92F;
+    const QVector3D leather = v.palette.leather;
+    const QVector3D pommel_col =
+        v.palette.metal * QVector3D(1.25F, 1.10F, 0.75F);
+
+    const float pommel_offset = 0.10F;
+    const float grip_len = 0.16F;
+    const float grip_rad = 0.017F;
+    const float guard_half = 0.11F;
+    const float guard_rad = 0.012F;
+    const float guard_curve = 0.03F;
+
+    const QVector3D pommel_pos = grip_pos - sword_dir * pommel_offset;
+    out.mesh(getUnitSphere(), sphereAt(ctx.model, pommel_pos, 0.028F),
+             pommel_col, nullptr, 1.0F);
+
+    {
+      QVector3D const neck_a = pommel_pos + sword_dir * 0.010F;
+      QVector3D const neck_b = grip_pos - sword_dir * 0.005F;
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, neck_a, neck_b, 0.0125F), steel_lo,
+               nullptr, 1.0F);
+
+      QVector3D const peen = pommel_pos - sword_dir * 0.012F;
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, peen, pommel_pos, 0.010F),
+               steel, nullptr, 1.0F);
+    }
+
+    const QVector3D grip_a = grip_pos - sword_dir * 0.005F;
+    const QVector3D grip_b = grip_pos + sword_dir * (grip_len - 0.005F);
+    const int wrap_rings = 5;
+    for (int i = 0; i < wrap_rings; ++i) {
+      float const t0 = (float)i / wrap_rings;
+      float const t1 = (float)(i + 1) / wrap_rings;
+      QVector3D const a = grip_a + sword_dir * (grip_len * t0);
+      QVector3D const b = grip_a + sword_dir * (grip_len * t1);
+
+      float const r_mid =
+          grip_rad *
+          (0.96F + 0.08F * std::sin((t0 + t1) * std::numbers::pi_v<float>));
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r_mid),
+               leather * 0.98F, nullptr, 1.0F);
+    }
+
+    const QVector3D guard_center = grip_b + sword_dir * 0.010F;
+    {
+      const int segs = 4;
+      QVector3D prev =
+          guard_center - right_axis * guard_half + (-up_axis * guard_curve);
+      for (int s = 1; s <= segs; ++s) {
+        float const u = -1.0F + 2.0F * (float)s / segs;
+        QVector3D const p = guard_center + right_axis * (guard_half * u) +
+                            (-up_axis * guard_curve * (1.0F - u * u));
+        out.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, prev, p, guard_rad), steel_hi,
+                 nullptr, 1.0F);
+        prev = p;
+      }
+
+      QVector3D const lend =
+          guard_center - right_axis * guard_half + (-up_axis * guard_curve);
+      QVector3D const rend =
+          guard_center + right_axis * guard_half + (-up_axis * guard_curve);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, lend - right_axis * 0.030F, lend,
+                          guard_rad * 1.12F),
+               steel_hi, nullptr, 1.0F);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, rend + right_axis * 0.030F, rend,
+                          guard_rad * 1.12F),
+               steel_hi, nullptr, 1.0F);
+
+      out.mesh(getUnitSphere(),
+               sphereAt(ctx.model, guard_center, guard_rad * 0.9F), steel,
+               nullptr, 1.0F);
+    }
+
+    const float blade_len = std::max(0.0F, extras.swordLength - 0.14F);
+    const QVector3D blade_root = guard_center + sword_dir * 0.020F;
+    const QVector3D blade_tip = blade_root + sword_dir * blade_len;
+
+    const QVector3D ricasso_end = blade_root + sword_dir * (blade_len * 0.08F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, blade_root, ricasso_end,
+                             extras.swordWidth * 0.32F),
+             steel_hi, nullptr, 1.0F);
+
+    const QVector3D fuller_a = blade_root + sword_dir * (blade_len * 0.10F);
+    const QVector3D fuller_b = blade_root + sword_dir * (blade_len * 0.80F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, fuller_a, fuller_b,
+                             extras.swordWidth * 0.10F),
+             steel_lo, nullptr, 1.0F);
+
+    const float base_r = extras.swordWidth * 0.26F;
+    const float mid_r = extras.swordWidth * 0.16F;
+    const float pre_tip_r = extras.swordWidth * 0.09F;
+
+    QVector3D const s0 = ricasso_end;
+    QVector3D const s1 = blade_root + sword_dir * (blade_len * 0.55F);
+    QVector3D const s2 = blade_root + sword_dir * (blade_len * 0.88F);
+
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, s0, s1, base_r),
+             steel_hi, nullptr, 1.0F);
+    out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, s1, s2, mid_r),
+             steel_hi, nullptr, 1.0F);
+
+    {
+      float const edge_r = extras.swordWidth * 0.03F;
+      QVector3D const e_a = blade_root + sword_dir * (blade_len * 0.10F);
+      QVector3D const e_b = blade_tip - sword_dir * (blade_len * 0.06F);
+      QVector3D const left_edge_a = e_a + right_axis * (base_r * 0.95F);
+      QVector3D const left_edge_b = e_b + right_axis * (pre_tip_r * 0.95F);
+      QVector3D const right_edge_a = e_a - right_axis * (base_r * 0.95F);
+      QVector3D const right_edge_b = e_b - right_axis * (pre_tip_r * 0.95F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, left_edge_a, left_edge_b, edge_r),
+               steel * 1.08F, nullptr, 1.0F);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, right_edge_a, right_edge_b, edge_r),
+               steel * 1.08F, nullptr, 1.0F);
+    }
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, s2, blade_tip - sword_dir * 0.020F,
+                             pre_tip_r),
+             steel_hi, nullptr, 1.0F);
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, blade_tip, blade_tip - sword_dir * 0.060F,
+                        pre_tip_r * 0.95F),
+             steel_hi * 1.04F, nullptr, 1.0F);
+
+    {
+      QVector3D const shoulder_l0 = blade_root + right_axis * (base_r * 1.05F);
+      QVector3D const shoulder_l1 = shoulder_l0 - right_axis * (base_r * 0.45F);
+      QVector3D const shoulder_r0 = blade_root - right_axis * (base_r * 1.05F);
+      QVector3D const shoulder_r1 = shoulder_r0 + right_axis * (base_r * 0.45F);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, shoulder_l1, shoulder_l0, base_r * 0.22F),
+               steel, nullptr, 1.0F);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, shoulder_r1, shoulder_r0, base_r * 0.22F),
+               steel, nullptr, 1.0F);
+    }
+
+    if (is_attacking && attack_phase >= 0.28F && attack_phase < 0.58F) {
+      float const t = (attack_phase - 0.28F) / 0.30F;
+      float const alpha = clamp01(0.40F * (1.0F - t * t));
+      QVector3D const sweep = (-right_axis * 0.18F - sword_dir * 0.10F) * t;
+
+      QVector3D const trail_tip = blade_tip + sweep;
+      QVector3D const trail_root = blade_root + sweep * 0.6F;
+
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, trail_root, trail_tip, base_r * 1.10F),
+               steel * 0.90F, nullptr, alpha);
+      out.mesh(getUnitCone(),
+               coneFromTo(ctx.model, trail_root + up_axis * 0.01F, trail_tip,
+                          base_r * 0.75F),
+               steel * 0.80F, nullptr, alpha * 0.7F);
+    }
+  }
+
+  static void drawCavalryShield(const DrawContext &ctx,
+                                const HumanoidPose &pose,
+                                const HumanoidVariant &v,
+                                const MountedKnightExtras &extras,
+                                ISubmitter &out) {
+    const float scale_factor = 2.0F;
+    const float r = 0.15F * scale_factor;
+
+    constexpr float k_mounted_shield_yaw_degrees = -70.0F;
+    QMatrix4x4 rot;
+    rot.rotate(k_mounted_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+
+    const QVector3D n = rot.map(QVector3D(0.0F, 0.0F, 1.0F));
+    const QVector3D axis_x = rot.map(QVector3D(1.0F, 0.0F, 0.0F));
+    const QVector3D axis_y = rot.map(QVector3D(0.0F, 1.0F, 0.0F));
+
+    QVector3D const shield_center =
+        pose.handL + axis_x * (-r * 0.30F) + axis_y * (-0.05F) + n * (0.05F);
+
+    const float plate_half = 0.0012F;
+    const float plate_full = plate_half * 2.0F;
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center + n * plate_half);
+      m.rotate(k_mounted_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+      m.scale(r, r, plate_full);
+      out.mesh(getUnitCylinder(), m, v.palette.cloth * 1.15F, nullptr, 1.0F);
+    }
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center - n * plate_half);
+      m.rotate(k_mounted_shield_yaw_degrees, 0.0F, 1.0F, 0.0F);
+      m.scale(r * 0.985F, r * 0.985F, plate_full);
+      out.mesh(getUnitCylinder(), m, v.palette.leather * 0.8F, nullptr, 1.0F);
+    }
+
+    {
+      QMatrix4x4 m = ctx.model;
+      m.translate(shield_center + n * (0.015F * scale_factor));
+      m.scale(0.035F * scale_factor);
+      out.mesh(getUnitSphere(), m, extras.metalColor, nullptr, 1.0F);
+    }
+
+    {
+      QVector3D const grip_a = shield_center - axis_x * 0.025F - n * 0.025F;
+      QVector3D const grip_b = shield_center + axis_x * 0.025F - n * 0.025F;
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, grip_a, grip_b, 0.008F),
+               v.palette.leather, nullptr, 1.0F);
+    }
+  }
+};
+
+void registerMountedKnightRenderer(
+    Render::GL::EntityRendererRegistry &registry) {
+  static MountedKnightRenderer const renderer;
+  registry.registerRenderer(
+      "troops/kingdom/mounted_knight",
+      [](const DrawContext &ctx, ISubmitter &out) {
+        static MountedKnightRenderer const static_renderer;
+        Shader *mounted_knight_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          mounted_knight_shader = ctx.backend->shader(shader_key);
+          if (mounted_knight_shader == nullptr) {
+            mounted_knight_shader =
+                ctx.backend->shader(QStringLiteral("mounted_knight"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) && (mounted_knight_shader != nullptr)) {
+          scene_renderer->setCurrentShader(mounted_knight_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Kingdom

+ 9 - 0
render/entity/nations/kingdom/mounted_knight_renderer.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include "../../registry.h"
+
+namespace Render::GL::Kingdom {
+
+void registerMountedKnightRenderer(EntityRendererRegistry &registry);
+
+}

+ 578 - 0
render/entity/nations/kingdom/spearman_renderer.cpp

@@ -0,0 +1,578 @@
+#include "spearman_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/shader.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
+#include "../../../humanoid_math.h"
+#include "../../../humanoid_specs.h"
+#include "../../../palette.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../registry.h"
+#include "../../renderer_constants.h"
+#include "spearman_style.h"
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+#include <cstdint>
+#include <optional>
+#include <qstringliteral.h>
+#include <qvectornd.h>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+namespace Render::GL::Kingdom {
+
+namespace {
+
+constexpr std::string_view k_spearman_default_style_key = "default";
+constexpr float k_spearman_team_mix_weight = 0.6F;
+constexpr float k_spearman_style_mix_weight = 0.4F;
+
+auto spearman_style_registry()
+    -> std::unordered_map<std::string, SpearmanStyleConfig> & {
+  static std::unordered_map<std::string, SpearmanStyleConfig> styles;
+  return styles;
+}
+
+void ensure_spearman_styles_registered() {
+  static const bool registered = []() {
+    register_kingdom_spearman_style();
+    return true;
+  }();
+  (void)registered;
+}
+
+} // namespace
+
+void register_spearman_style(const std::string &nation_id,
+                             const SpearmanStyleConfig &style) {
+  spearman_style_registry()[nation_id] = style;
+}
+
+using Render::Geom::clamp01;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::easeInOutCubic;
+using Render::Geom::lerp;
+using Render::Geom::smoothstep;
+using Render::Geom::sphereAt;
+using Render::GL::Humanoid::mix_palette_color;
+using Render::GL::Humanoid::saturate_color;
+
+struct SpearmanExtras {
+  QVector3D spearShaftColor;
+  QVector3D spearheadColor;
+  float spearLength = 1.20F;
+  float spearShaftRadius = 0.020F;
+  float spearheadLength = 0.18F;
+};
+
+class SpearmanRenderer : public HumanoidRendererBase {
+public:
+  auto getProportionScaling() const -> QVector3D override {
+    return {1.10F, 1.02F, 1.05F};
+  }
+
+private:
+  mutable std::unordered_map<uint32_t, SpearmanExtras> m_extrasCache;
+
+public:
+  void getVariant(const DrawContext &ctx, uint32_t seed,
+                  HumanoidVariant &v) const override {
+    QVector3D const team_tint = resolveTeamTint(ctx);
+    v.palette = makeHumanoidPalette(team_tint, seed);
+    auto const &style = resolve_style(ctx);
+    apply_palette_overrides(style, team_tint, v);
+  }
+
+  void customizePose(const DrawContext &,
+                     const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                     HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    const AnimationInputs &anim = anim_ctx.inputs;
+
+    float const arm_height_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.03F;
+    float const arm_asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
+
+    if (anim.isInHoldMode || anim.isExitingHold) {
+      float const t = anim.isInHoldMode ? 1.0F : (1.0F - anim.holdExitProgress);
+
+      float const kneel_depth = 0.35F * t;
+      float const pelvis_y = HP::WAIST_Y - kneel_depth;
+      pose.pelvisPos.setY(pelvis_y);
+
+      float const stance_narrow = 0.10F;
+
+      float const left_knee_y = HP::GROUND_Y + 0.06F * t;
+      float const left_knee_z = -0.08F * t;
+      pose.knee_l = QVector3D(-stance_narrow, left_knee_y, left_knee_z);
+      pose.footL = QVector3D(-stance_narrow - 0.02F, HP::GROUND_Y,
+                             left_knee_z - HP::LOWER_LEG_LEN * 0.90F * t);
+
+      float const right_knee_y =
+          HP::WAIST_Y * 0.45F * (1.0F - t) + HP::WAIST_Y * 0.30F * t;
+      pose.knee_r = QVector3D(stance_narrow + 0.05F, right_knee_y, 0.15F * t);
+      pose.foot_r = QVector3D(stance_narrow + 0.08F, HP::GROUND_Y, 0.25F * t);
+
+      float const upper_body_drop = kneel_depth;
+      pose.shoulderL.setY(HP::SHOULDER_Y - upper_body_drop);
+      pose.shoulderR.setY(HP::SHOULDER_Y - upper_body_drop);
+      pose.neck_base.setY(HP::NECK_BASE_Y - upper_body_drop);
+
+      float const lowered_chin_y = HP::CHIN_Y - upper_body_drop;
+
+      pose.headPos.setY(lowered_chin_y + pose.headR);
+
+      float const forward_lean = 0.08F * t;
+      pose.shoulderL.setZ(pose.shoulderL.z() + forward_lean);
+      pose.shoulderR.setZ(pose.shoulderR.z() + forward_lean);
+      pose.neck_base.setZ(pose.neck_base.z() + forward_lean * 0.8F);
+      pose.headPos.setZ(pose.headPos.z() + forward_lean * 0.7F);
+
+      float const lowered_shoulder_y = HP::SHOULDER_Y - upper_body_drop;
+
+      pose.hand_r =
+          QVector3D(0.18F * (1.0F - t) + 0.22F * t,
+                    lowered_shoulder_y * (1.0F - t) + (pelvis_y + 0.05F) * t,
+                    0.15F * (1.0F - t) + 0.20F * t);
+
+      pose.handL = QVector3D(0.0F,
+                             lowered_shoulder_y * (1.0F - t) +
+                                 (lowered_shoulder_y - 0.10F) * t,
+                             0.30F * (1.0F - t) + 0.55F * t);
+
+      QVector3D const shoulder_to_hand_r = pose.hand_r - pose.shoulderR;
+      float const arm_length_r = shoulder_to_hand_r.length();
+      QVector3D const arm_dir_r = shoulder_to_hand_r.normalized();
+      pose.elbowR = pose.shoulderR + arm_dir_r * (arm_length_r * 0.5F) +
+                    QVector3D(0.08F, -0.15F, -0.05F);
+
+      QVector3D const shoulder_to_hand_l = pose.handL - pose.shoulderL;
+      float const arm_length_l = shoulder_to_hand_l.length();
+      QVector3D const arm_dir_l = shoulder_to_hand_l.normalized();
+      pose.elbowL = pose.shoulderL + arm_dir_l * (arm_length_l * 0.5F) +
+                    QVector3D(-0.08F, -0.12F, 0.05F);
+
+    } else if (anim.is_attacking && anim.isMelee && !anim.isInHoldMode) {
+      float const attack_phase =
+          std::fmod(anim.time * SPEARMAN_INV_ATTACK_CYCLE_TIME, 1.0F);
+
+      QVector3D const guard_pos(0.28F, HP::SHOULDER_Y + 0.05F, 0.25F);
+      QVector3D const prepare_pos(0.35F, HP::SHOULDER_Y + 0.08F, 0.05F);
+      QVector3D const thrust_pos(0.32F, HP::SHOULDER_Y + 0.10F, 0.90F);
+      QVector3D const recover_pos(0.28F, HP::SHOULDER_Y + 0.06F, 0.40F);
+
+      if (attack_phase < 0.20F) {
+
+        float const t = easeInOutCubic(attack_phase / 0.20F);
+        pose.hand_r = guard_pos * (1.0F - t) + prepare_pos * t;
+
+        pose.handL = QVector3D(-0.10F, HP::SHOULDER_Y - 0.05F,
+                               0.20F * (1.0F - t) + 0.08F * t);
+      } else if (attack_phase < 0.30F) {
+
+        pose.hand_r = prepare_pos;
+        pose.handL = QVector3D(-0.10F, HP::SHOULDER_Y - 0.05F, 0.08F);
+      } else if (attack_phase < 0.50F) {
+
+        float t = (attack_phase - 0.30F) / 0.20F;
+        t = t * t * t;
+        pose.hand_r = prepare_pos * (1.0F - t) + thrust_pos * t;
+
+        pose.handL =
+            QVector3D(-0.10F + 0.05F * t, HP::SHOULDER_Y - 0.05F + 0.03F * t,
+                      0.08F + 0.45F * t);
+      } else if (attack_phase < 0.70F) {
+
+        float const t = easeInOutCubic((attack_phase - 0.50F) / 0.20F);
+        pose.hand_r = thrust_pos * (1.0F - t) + recover_pos * t;
+        pose.handL = QVector3D(-0.05F * (1.0F - t) - 0.10F * t,
+                               HP::SHOULDER_Y - 0.02F * (1.0F - t) - 0.06F * t,
+                               lerp(0.53F, 0.35F, t));
+      } else {
+
+        float const t = smoothstep(0.70F, 1.0F, attack_phase);
+        pose.hand_r = recover_pos * (1.0F - t) + guard_pos * t;
+        pose.handL = QVector3D(-0.10F - 0.02F * (1.0F - t),
+                               HP::SHOULDER_Y - 0.06F + 0.01F * t +
+                                   arm_height_jitter * (1.0F - t),
+                               lerp(0.35F, 0.25F, t));
+      }
+    } else {
+      pose.hand_r =
+          QVector3D(0.28F + arm_asymmetry,
+                    HP::SHOULDER_Y - 0.02F + arm_height_jitter, 0.30F);
+
+      pose.handL =
+          QVector3D(-0.08F - 0.5F * arm_asymmetry,
+                    HP::SHOULDER_Y - 0.08F + 0.5F * arm_height_jitter, 0.45F);
+
+      QVector3D const shoulder_to_hand = pose.hand_r - pose.shoulderR;
+      float const arm_length = shoulder_to_hand.length();
+      QVector3D const arm_dir = shoulder_to_hand.normalized();
+
+      pose.elbowR = pose.shoulderR + arm_dir * (arm_length * 0.5F) +
+                    QVector3D(0.06F, -0.12F, -0.04F);
+    }
+  }
+
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose,
+                      const HumanoidAnimationContext &anim_ctx,
+                      ISubmitter &out) const override {
+    const AnimationInputs &anim = anim_ctx.inputs;
+    uint32_t const seed = reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU;
+    auto const &style = resolve_style(ctx);
+    QVector3D const team_tint = resolveTeamTint(ctx);
+
+    SpearmanExtras extras;
+    auto it = m_extrasCache.find(seed);
+    if (it != m_extrasCache.end()) {
+      extras = it->second;
+    } else {
+      extras = computeSpearmanExtras(seed, v);
+      apply_extras_overrides(style, team_tint, v, extras);
+      m_extrasCache[seed] = extras;
+
+      if (m_extrasCache.size() > MAX_EXTRAS_CACHE_SIZE) {
+        m_extrasCache.clear();
+      }
+    }
+    apply_extras_overrides(style, team_tint, v, extras);
+
+    bool const is_attacking = anim.is_attacking && anim.isMelee;
+    float attack_phase = 0.0F;
+    if (is_attacking) {
+      attack_phase =
+          std::fmod(anim.time * SPEARMAN_INV_ATTACK_CYCLE_TIME, 1.0F);
+    }
+
+    drawSpear(ctx, pose, v, extras, anim, is_attacking, attack_phase, out);
+  }
+
+  void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose, ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    const QVector3D iron_color = v.palette.metal * IRON_TINT;
+
+    const float helm_r = pose.headR * 1.12F;
+
+    QVector3D const helm_bot(pose.headPos.x(),
+                             pose.headPos.y() - pose.headR * 0.15F,
+                             pose.headPos.z());
+    QVector3D const helm_top(pose.headPos.x(),
+                             pose.headPos.y() + pose.headR * 1.25F,
+                             pose.headPos.z());
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_bot, helm_top, helm_r), iron_color,
+             nullptr, 1.0F);
+
+    QVector3D const cap_top(pose.headPos.x(),
+                            pose.headPos.y() + pose.headR * 1.32F,
+                            pose.headPos.z());
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, helm_top, cap_top, helm_r * 0.96F),
+             iron_color * 1.04F, nullptr, 1.0F);
+
+    auto ring = [&](const QVector3D &center, float r, float h,
+                    const QVector3D &col) {
+      QVector3D const a = center + QVector3D(0, h * 0.5F, 0);
+      QVector3D const b = center - QVector3D(0, h * 0.5F, 0);
+      out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
+               nullptr, 1.0F);
+    };
+
+    ring(QVector3D(pose.headPos.x(), pose.headPos.y() + pose.headR * 0.95F,
+                   pose.headPos.z()),
+         helm_r * 1.01F, 0.012F, iron_color * 1.06F);
+    ring(QVector3D(pose.headPos.x(), pose.headPos.y() - pose.headR * 0.02F,
+                   pose.headPos.z()),
+         helm_r * 1.01F, 0.012F, iron_color * 1.06F);
+
+    float const visor_y = pose.headPos.y() + pose.headR * 0.10F;
+    float const visor_z = pose.headPos.z() + helm_r * 0.68F;
+
+    for (int i = 0; i < 3; ++i) {
+      float const y = visor_y + pose.headR * (0.18F - i * 0.12F);
+      QVector3D const visor_l(pose.headPos.x() - helm_r * 0.30F, y, visor_z);
+      QVector3D const visor_r(pose.headPos.x() + helm_r * 0.30F, y, visor_z);
+      out.mesh(getUnitCylinder(),
+               cylinderBetween(ctx.model, visor_l, visor_r, 0.010F), DARK_METAL,
+               nullptr, 1.0F);
+    }
+  }
+
+  void draw_armorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, float y_top_cover,
+                         float torso_r, float, float upper_arm_r,
+                         const QVector3D &right_axis,
+                         ISubmitter &out) const override {
+    using HP = HumanProportions;
+
+    const QVector3D iron_color = v.palette.metal * IRON_TINT;
+    const QVector3D leather_color = v.palette.leather * 0.95F;
+
+    QVector3D const chest_top(0, y_top_cover + 0.02F, 0);
+    QVector3D const chest_bot(0, HP::WAIST_Y + 0.08F, 0);
+    float const r_chest = torso_r * 1.14F;
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, chest_top, chest_bot, r_chest),
+             iron_color, nullptr, 1.0F);
+
+    auto draw_pauldron = [&](const QVector3D &shoulder,
+                             const QVector3D &outward) {
+      for (int i = 0; i < 3; ++i) {
+        float const seg_y = shoulder.y() + 0.03F - i * 0.040F;
+        float const seg_r = upper_arm_r * (2.2F - i * 0.10F);
+        QVector3D seg_pos = shoulder + outward * (0.015F + i * 0.006F);
+        seg_pos.setY(seg_y);
+
+        out.mesh(getUnitSphere(), sphereAt(ctx.model, seg_pos, seg_r),
+                 i == 0 ? iron_color * 1.04F : iron_color * (1.0F - i * 0.02F),
+                 nullptr, 1.0F);
+      }
+    };
+
+    draw_pauldron(pose.shoulderL, -right_axis);
+    draw_pauldron(pose.shoulderR, right_axis);
+
+    auto draw_arm_plate = [&](const QVector3D &shoulder,
+                              const QVector3D &elbow) {
+      QVector3D dir = (elbow - shoulder);
+      float const len = dir.length();
+      if (len < 1e-5F) {
+        return;
+      }
+      dir /= len;
+
+      for (int i = 0; i < 2; ++i) {
+        float const t0 = 0.12F + i * 0.28F;
+        float const t1 = t0 + 0.24F;
+        QVector3D const a = shoulder + dir * (t0 * len);
+        QVector3D const b = shoulder + dir * (t1 * len);
+        float const r = upper_arm_r * (1.26F - i * 0.03F);
+
+        out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r),
+                 iron_color * (0.96F - i * 0.02F), nullptr, 1.0F);
+      }
+    };
+
+    draw_arm_plate(pose.shoulderL, pose.elbowL);
+    draw_arm_plate(pose.shoulderR, pose.elbowR);
+
+    for (int i = 0; i < 3; ++i) {
+      float const y = HP::WAIST_Y + 0.06F - i * 0.035F;
+      float const r = torso_r * (1.12F + i * 0.020F);
+      QVector3D const strip_top(0, y, 0);
+      QVector3D const strip_bot(0, y - 0.030F, 0);
+
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, strip_top, strip_bot, r),
+               leather_color * (0.98F - i * 0.02F), nullptr, 1.0F);
+    }
+  }
+
+  void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
+                               const HumanoidPose &pose, float y_top_cover,
+                               float y_neck, const QVector3D &right_axis,
+                               ISubmitter &out) const override {}
+
+private:
+  static auto computeSpearmanExtras(uint32_t seed, const HumanoidVariant &v)
+      -> SpearmanExtras {
+    SpearmanExtras e;
+
+    e.spearShaftColor = v.palette.leather * QVector3D(0.85F, 0.75F, 0.65F);
+    e.spearheadColor = QVector3D(0.75F, 0.76F, 0.80F);
+
+    e.spearLength = 1.15F + (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.10F;
+    e.spearShaftRadius = 0.018F + (hash_01(seed ^ 0x7777U) - 0.5F) * 0.003F;
+    e.spearheadLength = 0.16F + (hash_01(seed ^ 0xBEEFU) - 0.5F) * 0.04F;
+
+    return e;
+  }
+
+  static void drawSpear(const DrawContext &ctx, const HumanoidPose &pose,
+                        const HumanoidVariant &v, const SpearmanExtras &extras,
+                        const AnimationInputs &anim, bool is_attacking,
+                        float attack_phase, ISubmitter &out) {
+    QVector3D const grip_pos = pose.hand_r;
+
+    QVector3D spear_dir = QVector3D(0.05F, 0.55F, 0.85F);
+    if (spear_dir.lengthSquared() > 1e-6F) {
+      spear_dir.normalize();
+    }
+
+    if (anim.isInHoldMode || anim.isExitingHold) {
+      float const t = anim.isInHoldMode ? 1.0F : (1.0F - anim.holdExitProgress);
+
+      QVector3D braced_dir = QVector3D(0.05F, 0.40F, 0.91F);
+      if (braced_dir.lengthSquared() > 1e-6F) {
+        braced_dir.normalize();
+      }
+
+      spear_dir = spear_dir * (1.0F - t) + braced_dir * t;
+      if (spear_dir.lengthSquared() > 1e-6F) {
+        spear_dir.normalize();
+      }
+    } else if (is_attacking) {
+      if (attack_phase >= 0.30F && attack_phase < 0.50F) {
+        float const t = (attack_phase - 0.30F) / 0.20F;
+
+        QVector3D attack_dir = QVector3D(0.03F, -0.15F, 1.0F);
+        if (attack_dir.lengthSquared() > 1e-6F) {
+          attack_dir.normalize();
+        }
+
+        spear_dir = spear_dir * (1.0F - t) + attack_dir * t;
+        if (spear_dir.lengthSquared() > 1e-6F) {
+          spear_dir.normalize();
+        }
+      }
+    }
+
+    QVector3D const shaft_base = grip_pos - spear_dir * 0.28F;
+    QVector3D shaft_mid = grip_pos + spear_dir * (extras.spearLength * 0.5F);
+    QVector3D const shaft_tip = grip_pos + spear_dir * extras.spearLength;
+
+    shaft_mid.setY(shaft_mid.y() + 0.02F);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, shaft_base, shaft_mid,
+                             extras.spearShaftRadius),
+             extras.spearShaftColor, nullptr, 1.0F);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, shaft_mid, shaft_tip,
+                             extras.spearShaftRadius * 0.95F),
+             extras.spearShaftColor * 0.98F, nullptr, 1.0F);
+
+    QVector3D const spearhead_base = shaft_tip;
+    QVector3D const spearhead_tip =
+        shaft_tip + spear_dir * extras.spearheadLength;
+
+    out.mesh(getUnitCone(),
+             coneFromTo(ctx.model, spearhead_base, spearhead_tip,
+                        extras.spearShaftRadius * 1.8F),
+             extras.spearheadColor, nullptr, 1.0F);
+
+    QVector3D const grip_end = grip_pos + spear_dir * 0.10F;
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, grip_pos, grip_end,
+                             extras.spearShaftRadius * 1.5F),
+             v.palette.leather * 0.92F, nullptr, 1.0F);
+  }
+
+  auto
+  resolve_style(const DrawContext &ctx) const -> const SpearmanStyleConfig & {
+    ensure_spearman_styles_registered();
+    auto &styles = spearman_style_registry();
+    std::string nation_id;
+    if (ctx.entity != nullptr) {
+      if (auto *unit =
+              ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+        nation_id = unit->nation_id;
+      }
+    }
+    if (!nation_id.empty()) {
+      auto it = styles.find(nation_id);
+      if (it != styles.end()) {
+        return it->second;
+      }
+    }
+    auto it_default = styles.find(std::string(k_spearman_default_style_key));
+    if (it_default != styles.end()) {
+      return it_default->second;
+    }
+    static const SpearmanStyleConfig k_empty{};
+    return k_empty;
+  }
+
+public:
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    const SpearmanStyleConfig &style = resolve_style(ctx);
+    if (!style.shader_id.empty()) {
+      return QString::fromStdString(style.shader_id);
+    }
+    return QStringLiteral("spearman");
+  }
+
+private:
+  void apply_palette_overrides(const SpearmanStyleConfig &style,
+                               const QVector3D &team_tint,
+                               HumanoidVariant &variant) const {
+    auto apply_color = [&](const std::optional<QVector3D> &override_color,
+                           QVector3D &target) {
+      target = mix_palette_color(target, override_color, team_tint,
+                                 k_spearman_team_mix_weight,
+                                 k_spearman_style_mix_weight);
+    };
+
+    apply_color(style.cloth_color, variant.palette.cloth);
+    apply_color(style.leather_color, variant.palette.leather);
+    apply_color(style.leather_dark_color, variant.palette.leatherDark);
+    apply_color(style.metal_color, variant.palette.metal);
+  }
+
+  void apply_extras_overrides(const SpearmanStyleConfig &style,
+                              const QVector3D &team_tint,
+                              [[maybe_unused]] const HumanoidVariant &variant,
+                              SpearmanExtras &extras) const {
+    extras.spearShaftColor = saturate_color(extras.spearShaftColor);
+    extras.spearheadColor = saturate_color(extras.spearheadColor);
+
+    auto apply_color = [&](const std::optional<QVector3D> &override_color,
+                           QVector3D &target) {
+      target = mix_palette_color(target, override_color, team_tint,
+                                 k_spearman_team_mix_weight,
+                                 k_spearman_style_mix_weight);
+    };
+
+    apply_color(style.spear_shaft_color, extras.spearShaftColor);
+    apply_color(style.spearhead_color, extras.spearheadColor);
+
+    if (style.spear_length_scale) {
+      extras.spearLength =
+          std::max(0.80F, extras.spearLength * *style.spear_length_scale);
+    }
+  }
+};
+
+void registerSpearmanRenderer(Render::GL::EntityRendererRegistry &registry) {
+  ensure_spearman_styles_registered();
+  static SpearmanRenderer const renderer;
+  registry.registerRenderer(
+      "troops/kingdom/spearman", [](const DrawContext &ctx, ISubmitter &out) {
+        static SpearmanRenderer const static_renderer;
+        Shader *spearman_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          spearman_shader = ctx.backend->shader(shader_key);
+          if (spearman_shader == nullptr) {
+            spearman_shader = ctx.backend->shader(QStringLiteral("spearman"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) && (spearman_shader != nullptr)) {
+          scene_renderer->setCurrentShader(spearman_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Kingdom

+ 16 - 0
render/entity/nations/kingdom/spearman_renderer.h

@@ -0,0 +1,16 @@
+#pragma once
+
+#include "../../registry.h"
+#include "spearman_style.h"
+#include <string>
+
+namespace Render::GL::Kingdom {
+
+struct SpearmanStyleConfig;
+
+void register_spearman_style(const std::string &nation_id,
+                             const SpearmanStyleConfig &style);
+
+void registerSpearmanRenderer(EntityRendererRegistry &registry);
+
+} // namespace Render::GL::Kingdom

+ 30 - 0
render/entity/nations/kingdom/spearman_style.cpp

@@ -0,0 +1,30 @@
+#include "spearman_style.h"
+#include "spearman_renderer.h"
+#include <QVector3D>
+
+namespace {
+constexpr QVector3D k_kingdom_cloth{0.40F, 0.44F, 0.52F};
+constexpr QVector3D k_kingdom_leather{0.29F, 0.20F, 0.12F};
+constexpr QVector3D k_kingdom_leather_dark{0.18F, 0.16F, 0.14F};
+constexpr QVector3D k_kingdom_metal{0.68F, 0.69F, 0.72F};
+constexpr QVector3D k_kingdom_shaft{0.36F, 0.28F, 0.16F};
+constexpr QVector3D k_kingdom_head{0.80F, 0.82F, 0.88F};
+} // namespace
+
+namespace Render::GL::Kingdom {
+
+void register_kingdom_spearman_style() {
+  SpearmanStyleConfig style;
+  style.cloth_color = k_kingdom_cloth;
+  style.leather_color = k_kingdom_leather;
+  style.leather_dark_color = k_kingdom_leather_dark;
+  style.metal_color = k_kingdom_metal;
+  style.spear_shaft_color = k_kingdom_shaft;
+  style.spearhead_color = k_kingdom_head;
+  style.shader_id = "spearman_kingdom_of_iron";
+
+  register_spearman_style("default", style);
+  register_spearman_style("kingdom_of_iron", style);
+}
+
+} // namespace Render::GL::Kingdom

部分文件因文件數量過多而無法顯示