diff --git a/data-global/lib/core/echo_warden.lua b/data-global/lib/core/echo_warden.lua
new file mode 100644
index 000000000..2bc150f35
--- /dev/null
+++ b/data-global/lib/core/echo_warden.lua
@@ -0,0 +1,261 @@
+-- ============================================================================
+-- Echo Raids / Echo Wardens - shared library (pure Lua)
+-- IMPORTANT: lives in data/libs/functions/ (NOT data/global/lib/, which does
+-- not exist in this fork). Registered from data/libs/functions/load.lua so it
+-- loads before every revscriptsys data/scripts file at boot.
+--
+-- Verified APIs only:
+-- Monster:setForgeStack/getForgeStack (C++; applyStacks buffs HP, icon "forge")
+-- Creature:setMaxHealth/setHealth/getMaxHealth/setIcon (C++)
+-- Monster:setStorageValue/getStorageValue (Lua fallback in monster.lua;
+-- getStorageValue returns -1 when unset, so compare with == 1)
+-- MonsterType:BestiaryStars/raceId/name/isRewardBoss/bossRace
+-- Player:addMinorCharmEchoes/sendLeaderMonsterKilledBanner/kv():scoped() (boolean round-trips)
+-- Game.createMonster(name,pos,extended,force,master,spawnEffect) (returns nil on fail)
+-- Game.getSpectators(pos, multifloor, onlyPlayer, minX,maxX,minY,maxY)
+-- addEvent, Tile:hasFlag/getGround, Position:sendMagicEffect
+-- ============================================================================
+
+EchoWarden = EchoWarden or {}
+
+-- ----------------------------- TUNABLES -------------------------------------
+EchoWarden.PORTAL_ITEM_ID = 54133
+EchoWarden.PORTAL_ATTR_KIND = "echoKind" -- item:setCustomAttribute key
+EchoWarden.PORTAL_TTL_MS = 120000 -- portal self-removes after 2 min (counted from when it appears)
+EchoWarden.PORTAL_DELAY_MS = 30000 -- portal appears 30s AFTER the kill (not instantly)
+
+EchoWarden.SPAWN_CHANCE_NUM = 100 -- 100 / 200000 = 0.05%
+EchoWarden.SPAWN_CHANCE_DEN = 200000
+
+EchoWarden.WARDEN_ADDS_MIN, EchoWarden.WARDEN_ADDS_MAX = 7, 12 -- cantidad de minions por raid
+
+EchoWarden.WARDEN_HP_MULT = 4.0
+EchoWarden.WARDEN_ATK_MULT = 1.5 -- only used if optional C++ applied
+EchoWarden.USE_CPP_ATK = true -- set true only if applyEchoWarden compiled
+
+EchoWarden.AURA_RANGE = 5
+EchoWarden.AURA_MS = 2000
+
+EchoWarden.SCATTER_RADIUS = 3 -- (unused now: spawns land on the portal tile)
+EchoWarden.SPAWN_EFFECT = CONST_ME_NONE -- monsters are born silently (no teleport flash)
+EchoWarden.SPAWN_STEP_MS = 400 -- delay between each monster (born one after another)
+
+EchoWarden.STORAGE_IS_WARDEN = 54133 -- 1 = this monster is THE echo warden
+EchoWarden.STORAGE_IS_SPAWNED = 54134 -- 1 = spawned by an echo raid (never re-triggers)
+EchoWarden.KV_SCOPE = "echo_warden" -- player:kv():scoped(...) for first-kill
+
+-- first-kill reward (MINOR CHARM ECHOES) by bestiary difficulty (stars). 0-star fall back to [0].
+EchoWarden.ECHOES_BY_STARS = { [0] = 1, [1] = 10, [2] = 15, [3] = 20, [4] = 25, [5] = 30 }
+
+-- runtime map: wardenCreatureId -> baseKindName (aura loop + reward lookup)
+EchoWarden.activeWardens = EchoWarden.activeWardens or {}
+EchoWarden.activeMinions = EchoWarden.activeMinions or {}
+EchoWarden.GLOW_REFRESH_MS = 400
+EchoWarden._glowLoop = false
+
+function EchoWarden.pickTile(center, radius)
+ radius = radius or EchoWarden.SCATTER_RADIUS
+ for _ = 1, 12 do
+ local dx = math.random(-radius, radius)
+ local dy = math.random(-radius, radius)
+ local p = Position(center.x + dx, center.y + dy, center.z)
+ local tile = Tile(p)
+ if tile and tile:getGround() and not tile:hasFlag(TILESTATE_BLOCKSOLID) and not tile:hasFlag(TILESTATE_PROTECTIONZONE) and not tile:hasFlag(TILESTATE_FLOORCHANGE) and not tile:hasFlag(TILESTATE_TELEPORT) then
+ return p
+ end
+ end
+ return center
+end
+
+function EchoWarden.markSpawned(m)
+ if m and m.setStorageValue then
+ m:setStorageValue(EchoWarden.STORAGE_IS_SPAWNED, 1)
+ end
+end
+
+EchoWarden.STORAGE_IS_MINION = 54135
+EchoWarden.MINION_HP_MULT = 1.5
+function EchoWarden.makeMinion(m)
+ if not m then
+ return
+ end
+ if m:getStorageValue(EchoWarden.STORAGE_IS_MINION) ~= 1 then
+ m:setStorageValue(EchoWarden.STORAGE_IS_MINION, 1)
+ m:setMaxHealth(math.floor(m:getMaxHealth() * EchoWarden.MINION_HP_MULT))
+ m:setHealth(m:getMaxHealth())
+ EchoWarden.markSpawned(m) -- nunca re-dispara un portal
+ end
+ EchoWarden.activeMinions[m:getId()] = true
+ EchoWarden.sendCreatureGlow(m, EchoWarden.STATE_MINION) -- glow nativo de MINION (state 1)
+ EchoWarden.ensureGlowLoop() -- re-envío continuo (persiste al moverse)
+end
+
+EchoWarden.STATE_LEADER = 0
+EchoWarden.STATE_MINION = 1
+EchoWarden.GLOW_RANGE = 11 -- tiles a la redonda para mandar el glow a los spectators
+
+---@param creature Creature
+---@param state number 0=leader, 1=minion
+function EchoWarden.sendCreatureGlow(creature, state)
+ if not creature then
+ return
+ end
+ local cid = creature:getId()
+ local pos = creature:getPosition()
+ if not pos then
+ return
+ end
+ local r = EchoWarden.GLOW_RANGE
+ for _, spec in ipairs(Game.getSpectators(pos, false, true, r, r, r, r)) do
+ local msg = NetworkMessage()
+ msg:addByte(0x8B)
+ msg:addU32(cid)
+ msg:addByte(0x0f)
+ msg:addByte(1)
+ msg:addByte(state) -- 0=leader, 1=minion
+ msg:sendToPlayer(spec)
+ end
+end
+
+function EchoWarden.glowRefresh()
+ local any = false
+ for id in pairs(EchoWarden.activeWardens) do
+ local w = Monster(id)
+ if w and not w:isRemoved() and w:getHealth() > 0 then
+ EchoWarden.sendCreatureGlow(w, EchoWarden.STATE_LEADER)
+ any = true
+ else
+ EchoWarden.activeWardens[id] = nil
+ end
+ end
+ for id in pairs(EchoWarden.activeMinions) do
+ local m = Monster(id)
+ if m and not m:isRemoved() and m:getHealth() > 0 then
+ EchoWarden.sendCreatureGlow(m, EchoWarden.STATE_MINION)
+ any = true
+ else
+ EchoWarden.activeMinions[id] = nil
+ end
+ end
+ if any then
+ addEvent(EchoWarden.glowRefresh, EchoWarden.GLOW_REFRESH_MS)
+ else
+ EchoWarden._glowLoop = false
+ end
+end
+
+function EchoWarden.ensureGlowLoop()
+ if not EchoWarden._glowLoop then
+ EchoWarden._glowLoop = true
+ EchoWarden.glowRefresh()
+ end
+end
+
+function EchoWarden.makeWarden(w, kindName)
+ if not w then
+ return
+ end
+ if EchoWarden.USE_CPP_ATK and w.applyEchoWarden then
+ w:applyEchoWarden(EchoWarden.WARDEN_HP_MULT, EchoWarden.WARDEN_ATK_MULT)
+ w:removeIcon("warden")
+ else
+ w:setMaxHealth(math.floor(w:getMaxHealth() * EchoWarden.WARDEN_HP_MULT))
+ w:setHealth(w:getMaxHealth())
+ end
+ w:setStorageValue(EchoWarden.STORAGE_IS_WARDEN, 1)
+ w:setStorageValue(EchoWarden.STORAGE_IS_SPAWNED, 1)
+ EchoWarden.activeWardens[w:getId()] = kindName
+ EchoWarden.sendCreatureGlow(w, EchoWarden.STATE_LEADER)
+ EchoWarden.ensureGlowLoop()
+end
+
+function EchoWarden.aura(wardenId, kindName)
+ local w = Monster(wardenId)
+ if not w or w:isRemoved() or w:getHealth() <= 0 then
+ EchoWarden.activeWardens[wardenId] = nil
+ return
+ end
+ local p = w:getPosition()
+ local r = EchoWarden.AURA_RANGE
+ for _, c in ipairs(Game.getSpectators(p, false, false, r, r, r, r)) do
+ local m = c:getMonster()
+ if m and m:getId() ~= wardenId and m:getName() == kindName and m:getStorageValue(EchoWarden.STORAGE_IS_WARDEN) ~= 1 then
+ EchoWarden.makeMinion(m)
+ end
+ end
+ addEvent(EchoWarden.aura, EchoWarden.AURA_MS, wardenId, kindName)
+end
+
+function EchoWarden.trickleSpawn(kindName, cx, cy, cz, remaining, stepMs)
+ if remaining <= 0 then
+ return
+ end
+ local m = Game.createMonster(kindName, Position(cx, cy, cz), false, true, nil, EchoWarden.SPAWN_EFFECT)
+ if m then
+ EchoWarden.makeMinion(m)
+ end
+ addEvent(EchoWarden.trickleSpawn, stepMs, kindName, cx, cy, cz, remaining - 1, stepMs)
+end
+
+function EchoWarden.spawnPortal(px, py, pz, kindName)
+ local pos = Position(px, py, pz)
+ local tile = Tile(pos)
+ if not tile or not tile:getGround() then
+ return
+ end
+ local portal = Game.createItem(EchoWarden.PORTAL_ITEM_ID, 1, pos)
+ if not portal then
+ return
+ end
+ portal:setCustomAttribute(EchoWarden.PORTAL_ATTR_KIND, kindName)
+ pos:sendMagicEffect(CONST_ME_TELEPORT)
+ -- failsafe cleanup if nobody steps on it
+ addEvent(function(qx, qy, qz)
+ local t = Tile(Position(qx, qy, qz))
+ if t then
+ local it = t:getItemById(EchoWarden.PORTAL_ITEM_ID)
+ if it then
+ it:remove()
+ end
+ end
+ end, EchoWarden.PORTAL_TTL_MS, px, py, pz)
+end
+
+function EchoWarden.runRaid(kindName, center)
+ if not kindName or kindName == "" then
+ return
+ end
+ local cx, cy, cz = center.x, center.y, center.z
+ local step = EchoWarden.SPAWN_STEP_MS
+
+ local w = Game.createMonster(kindName, Position(cx, cy, cz), false, true, nil, EchoWarden.SPAWN_EFFECT)
+ if w then
+ EchoWarden.makeWarden(w, kindName)
+ addEvent(EchoWarden.aura, EchoWarden.AURA_MS, w:getId(), kindName)
+ end
+
+ local adds = math.random(EchoWarden.WARDEN_ADDS_MIN, EchoWarden.WARDEN_ADDS_MAX)
+ EchoWarden.trickleSpawn(kindName, cx, cy, cz, adds, step)
+end
+
+function EchoWarden.grantReward(player, baseKind)
+ if not player or not baseKind or baseKind == "" then
+ return false
+ end
+ local kv = player:kv():scoped(EchoWarden.KV_SCOPE)
+ if kv:get(baseKind) then
+ return false
+ end
+
+ local mt = MonsterType(baseKind)
+ local stars = (mt and mt:BestiaryStars()) or 0
+ local amount = EchoWarden.ECHOES_BY_STARS[stars] or EchoWarden.ECHOES_BY_STARS[0]
+ player:addMinorCharmEchoes(amount)
+ kv:set(baseKind, true)
+
+ local raceId = (mt and mt:raceId()) or 0
+ if raceId > 0 then
+ player:sendLeaderMonsterKilledBanner(raceId, amount)
+ end
+ return true
+end
diff --git a/data-global/lib/core/load.lua b/data-global/lib/core/load.lua
index bf9b37a52..2988fa676 100644
--- a/data-global/lib/core/load.lua
+++ b/data-global/lib/core/load.lua
@@ -1,3 +1,4 @@
dofile(DATA_DIRECTORY .. "/lib/core/storages.lua")
dofile(DATA_DIRECTORY .. "/lib/core/constants.lua")
dofile(DATA_DIRECTORY .. "/lib/core/quests.lua")
+dofile(DATA_DIRECTORY .. "/lib/core/echo_warden.lua")
diff --git a/data-global/scripts/custom/echo_warden/echo_warden_portal.lua b/data-global/scripts/custom/echo_warden/echo_warden_portal.lua
new file mode 100644
index 000000000..60cc68700
--- /dev/null
+++ b/data-global/scripts/custom/echo_warden/echo_warden_portal.lua
@@ -0,0 +1,31 @@
+-- ============================================================================
+-- Echo Warden Portal - onStepIn for item 54133
+-- Reads stored echoKind, removes the portal, runs the 3-outcome echo raid.
+-- Only players trigger it.
+-- ============================================================================
+
+local echoPortal = MoveEvent()
+
+function echoPortal.onStepIn(creature, item, position, fromPosition)
+ local player = creature and creature:getPlayer()
+ if not player then
+ return true
+ end
+
+ local kind = item:getCustomAttribute(EchoWarden.PORTAL_ATTR_KIND)
+ if type(kind) ~= "string" or kind == "" then
+ item:remove()
+ return true
+ end
+
+ local center = Position(position.x, position.y, position.z)
+ item:remove()
+ center:sendMagicEffect(CONST_ME_AGONY)
+
+ EchoWarden.runRaid(kind, center)
+ return true
+end
+
+echoPortal:type("stepin")
+echoPortal:id(54133)
+echoPortal:register()
diff --git a/data-global/scripts/custom/echo_warden/echo_warden_trigger.lua b/data-global/scripts/custom/echo_warden/echo_warden_trigger.lua
new file mode 100644
index 000000000..b4ea154d3
--- /dev/null
+++ b/data-global/scripts/custom/echo_warden/echo_warden_trigger.lua
@@ -0,0 +1,117 @@
+-- ============================================================================
+-- Echo Warden Trigger - onDeath for every common/uncommon monster
+-- (a) 0.05% chance to spawn echo warden portal (item 54133) at corpse tile,
+-- appearing PORTAL_DELAY_MS (30s) AFTER the kill (not instantly).
+-- (b) on tagged Echo Warden death -> first-kill charm reward per damager
+-- Echo-raid-spawned creatures (STORAGE_IS_SPAWNED == 1) never re-trigger.
+-- Registered to all 1-2 star monsters via GlobalEvent onStartup.
+-- ============================================================================
+
+local echoWardenTrigger = CreatureEvent("EchoWardenTrigger")
+
+-- summon-aware list of players who damaged the creature
+local function damagersOf(creature)
+ local players, seen = {}, {}
+ local dmap = creature:getDamageMap()
+ if not dmap then
+ return players
+ end
+ for cid, _ in pairs(dmap) do
+ if cid and cid > 0 then
+ local d = Creature(cid)
+ if d then
+ local pl
+ if d:isPlayer() then
+ pl = d:getPlayer()
+ else
+ local master = d:getMaster()
+ if master and master:isPlayer() then
+ pl = master:getPlayer()
+ end
+ end
+ if pl and not seen[pl:getId()] then
+ seen[pl:getId()] = true
+ players[#players + 1] = pl
+ end
+ end
+ end
+ end
+ return players
+end
+
+function echoWardenTrigger.onDeath(creature, corpse, killer, mostDamageKiller, lastHitUnjustified, mostDamageUnjustified)
+ if not creature or not creature:isMonster() then
+ return true
+ end
+
+ -- (b) tagged Echo Warden -> first-kill reward per damager, then stop.
+ if creature:getStorageValue(EchoWarden.STORAGE_IS_WARDEN) == 1 then
+ local baseKind = EchoWarden.activeWardens[creature:getId()] or creature:getName()
+ EchoWarden.activeWardens[creature:getId()] = nil
+ for _, player in ipairs(damagersOf(creature)) do
+ EchoWarden.grantReward(player, baseKind)
+ end
+ return true
+ end
+
+ -- echo-raid-spawned creatures (warden + minions) never re-trigger.
+ if creature:getStorageValue(EchoWarden.STORAGE_IS_SPAWNED) == 1 then
+ return true
+ end
+
+ local mType = creature:getType()
+ if not mType then
+ return true
+ end
+
+ -- Guards: never spawn from summon / reward boss / bosstiary boss / forge / out-of-range stars.
+ if creature:getMaster() ~= nil or mType:isRewardBoss() then
+ return true
+ end
+ local br = mType.bossRace and mType:bossRace()
+ if br and br ~= "" then
+ return true
+ end
+ if creature.getForgeStack and creature:getForgeStack() > 0 then
+ return true
+ end
+ local stars = mType:BestiaryStars() or 0
+ if stars < 1 or stars > 2 then -- SOLO common/uncommon (1-2 estrellas) disparan Echo Raids (como Tibia RL)
+ return true
+ end
+
+ -- (a) portal spawn roll
+ if math.random(1, EchoWarden.SPAWN_CHANCE_DEN) > EchoWarden.SPAWN_CHANCE_NUM then
+ return true
+ end
+
+ local kindName = creature:getName()
+ local pos = creature:getPosition()
+ addEvent(EchoWarden.spawnPortal, EchoWarden.PORTAL_DELAY_MS, pos.x, pos.y, pos.z, kindName)
+
+ return true
+end
+
+echoWardenTrigger:register()
+
+-- Register the death event to ALL common/uncommon monsters at boot.
+local echoWardenStartup = GlobalEvent("EchoWardenTriggerStartup")
+
+function echoWardenStartup.onStartup()
+ local count = 0
+ for stars = 1, 2 do -- SOLO common/uncommon (1-2 estrellas), como Tibia RL
+ local monsters = Game.getMonstersByBestiaryStars(stars)
+ if monsters then
+ for _, mType in ipairs(monsters) do
+ if mType:name() then
+ mType:registerEvent("EchoWardenTrigger")
+ count = count + 1
+ end
+ end
+ end
+ end
+ logger.info("[EchoWarden] Registered death trigger to {} monsters (common/uncommon 1-2)", count)
+ return true
+end
+
+echoWardenStartup:register()
diff --git a/data-global/scripts/custom/echo_warden/ondroploot_echo_warden.lua b/data-global/scripts/custom/echo_warden/ondroploot_echo_warden.lua
new file mode 100644
index 000000000..2886adf3d
--- /dev/null
+++ b/data-global/scripts/custom/echo_warden/ondroploot_echo_warden.lua
@@ -0,0 +1,32 @@
+-- ============================================================================
+-- Echo Warden loot: ALWAYS 1 random basic scroll (53751-53774) + suffix.
+-- The scroll is added unconditionally; the "(Echo Warden kill)" suffix is
+-- best-effort cosmetic (creature.cpp only prints it when the most-damage
+-- killer is a player receiving the loot message).
+-- corpse is a Container userdata (events.cpp:1239), so corpse:addItem works;
+-- there is NO Container:addLoot in this fork -- do not use it.
+-- ============================================================================
+
+local BASIC_SCROLL_MIN, BASIC_SCROLL_MAX = 53751, 53774
+
+local callback = EventCallback("MonsterOnDropLootEchoWarden")
+
+function callback.monsterOnDropLoot(monster, corpse)
+ if not monster or not corpse then
+ return
+ end
+ if monster:getStorageValue(EchoWarden.STORAGE_IS_WARDEN) ~= 1 then
+ return
+ end
+
+ -- exactly one random basic imbuement scroll
+ local scrollId = math.random(BASIC_SCROLL_MIN, BASIC_SCROLL_MAX)
+ corpse:addItem(scrollId, 1)
+
+ -- loot message suffix: C++ wraps as "Loot of X: items (Echo Warden kill)"
+ local existing = corpse:getAttribute(ITEM_ATTRIBUTE_LOOTMESSAGE_SUFFIX) or ""
+ local suffix = string.len(existing) > 0 and (existing .. " Echo Warden kill") or "Echo Warden kill"
+ corpse:setAttribute(ITEM_ATTRIBUTE_LOOTMESSAGE_SUFFIX, suffix)
+end
+
+callback:register()
diff --git a/data/XML/imbuements.xml b/data/XML/imbuements.xml
index dc712cbd6..b1e8563a7 100644
--- a/data/XML/imbuements.xml
+++ b/data/XML/imbuements.xml
@@ -23,7 +23,7 @@
-
+
@@ -41,7 +41,7 @@
-
+
@@ -59,7 +59,7 @@
-
+
@@ -77,7 +77,7 @@
-
+
@@ -95,7 +95,7 @@
-
+
@@ -113,7 +113,7 @@
-
+
@@ -131,7 +131,7 @@
-
+
@@ -149,7 +149,7 @@
-
+
@@ -167,7 +167,7 @@
-
+
@@ -185,7 +185,7 @@
-
+
@@ -203,7 +203,7 @@
-
+
@@ -221,7 +221,7 @@
-
+
@@ -239,7 +239,7 @@
-
+
@@ -257,7 +257,7 @@
-
+
@@ -275,7 +275,7 @@
-
+
@@ -293,7 +293,7 @@
-
+
@@ -311,7 +311,7 @@
-
+
@@ -329,7 +329,7 @@
-
+
@@ -347,7 +347,7 @@
-
+
@@ -365,7 +365,7 @@
-
+
@@ -383,7 +383,7 @@
-
+
@@ -401,7 +401,7 @@
-
+
@@ -419,7 +419,7 @@
-
+
@@ -437,7 +437,7 @@
-
+
diff --git a/data/XML/mounts.xml b/data/XML/mounts.xml
index af7293507..f948a255c 100644
--- a/data/XML/mounts.xml
+++ b/data/XML/mounts.xml
@@ -276,4 +276,6 @@
+
+
\ No newline at end of file
diff --git a/data/items/appearances.dat b/data/items/appearances.dat
index 07db48e8e..ad2b48757 100644
Binary files a/data/items/appearances.dat and b/data/items/appearances.dat differ
diff --git a/data/items/items.xml b/data/items/items.xml
index d44c6db32..236c20484 100644
--- a/data/items/items.xml
+++ b/data/items/items.xml
@@ -87477,4 +87477,753 @@
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/data/items/proficiencies.json b/data/items/proficiencies.json
index 49ec7edc6..6725f22b6 100644
--- a/data/items/proficiencies.json
+++ b/data/items/proficiencies.json
@@ -27086,5 +27086,2667 @@
"Name": "Crypt 1H Rod",
"ProficiencyId": 463,
"Version": 5
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "ElementId": 64,
+ "Type": 31,
+ "Value": 0.05
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 64,
+ "Type": 9,
+ "Value": 0.015
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 64,
+ "Type": 31,
+ "Value": 0.1
+ },
+ {
+ "Type": 24,
+ "Value": 1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 64,
+ "Type": 9,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 2
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 64,
+ "Type": 13,
+ "Value": 0.1
+ },
+ {
+ "Type": 15,
+ "Value": 2.0
+ }
+ ]
+ }
+ ],
+ "Name": "Throw - Snowball with Shards",
+ "ProficiencyId": 474,
+ "Version": 1
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 6,
+ "Type": 3,
+ "Value": 2
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "ElementId": 16,
+ "MissileId": 71,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.05
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.025
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.05
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 1H Axe",
+ "ProficiencyId": 475,
+ "Version": 4
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 6,
+ "Type": 3,
+ "Value": 2
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "ElementId": 16,
+ "MissileId": 71,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.05
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.025
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.05
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 1H Club",
+ "ProficiencyId": 476,
+ "Version": 4
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.025
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "SkillId": 1,
+ "Type": 3,
+ "Value": 1
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 43,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "ElementId": 64,
+ "MissileId": 73,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 318,
+ "Type": 5,
+ "Value": 0.05
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.025
+ },
+ {
+ "SkillId": 1,
+ "Type": 3,
+ "Value": 2
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 43,
+ "Type": 5,
+ "Value": 0.015
+ },
+ {
+ "AugmentType": 3,
+ "SpellId": 5,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 318,
+ "Type": 5,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 64,
+ "Type": 31,
+ "Value": 0.04
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "ElementId": 16,
+ "Type": 31,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 1H Rod",
+ "ProficiencyId": 477,
+ "Version": 4
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 6,
+ "Type": 3,
+ "Value": 2
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "ElementId": 16,
+ "MissileId": 73,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.05
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.025
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.05
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 1H Sword",
+ "ProficiencyId": 478,
+ "Version": 6
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 12,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "SkillId": 1,
+ "Type": 3,
+ "Value": 1
+ },
+ {
+ "Type": 17,
+ "Value": 0.03
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 240,
+ "Type": 5,
+ "Value": 0.04
+ },
+ {
+ "ElementId": 256,
+ "MissileId": 75,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 310,
+ "Type": 5,
+ "Value": 0.05
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.025
+ },
+ {
+ "SkillId": 1,
+ "Type": 3,
+ "Value": 2
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 240,
+ "Type": 5,
+ "Value": 0.01
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 21,
+ "Type": 5,
+ "Value": 0.025
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 310,
+ "Type": 5,
+ "Value": 0.015
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 8,
+ "Type": 31,
+ "Value": 0.04
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "ElementId": 256,
+ "Type": 31,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 1H Wand",
+ "ProficiencyId": 479,
+ "Version": 4
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 10,
+ "Type": 3,
+ "Value": 1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "ElementId": 8,
+ "MissileId": 71,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "Type": 11,
+ "Value": 0.015
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "SkillId": 10,
+ "Type": 25,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.05
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 2H Axe",
+ "ProficiencyId": 480,
+ "Version": 4
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 12,
+ "Value": 0.08
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "SkillId": 7,
+ "Type": 3,
+ "Value": 1
+ },
+ {
+ "Type": 17,
+ "Value": 0.03
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 303,
+ "Type": 5,
+ "Value": 0.04
+ },
+ {
+ "ElementId": 128,
+ "MissileId": 74,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 302,
+ "Type": 5,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 30,
+ "Value": 0.06
+ },
+ {
+ "Type": 0,
+ "Value": 2
+ },
+ {
+ "Type": 16,
+ "Value": 0.02
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 303,
+ "Type": 5,
+ "Value": 0.01
+ },
+ {
+ "SkillId": 7,
+ "Type": 26,
+ "Value": 0.12
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 302,
+ "Type": 5,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ },
+ {
+ "Type": 12,
+ "Value": 0.1
+ },
+ {
+ "ElementId": 128,
+ "Type": 31,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.05
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 2H Bow",
+ "ProficiencyId": 481,
+ "Version": 7
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 9,
+ "Type": 3,
+ "Value": 1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "ElementId": 64,
+ "MissileId": 73,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "Type": 11,
+ "Value": 0.015
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "SkillId": 9,
+ "Type": 25,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.05
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 2H Club",
+ "ProficiencyId": 482,
+ "Version": 3
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 23,
+ "Value": 0.02
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 7,
+ "Value": 0.03
+ },
+ {
+ "SkillId": 7,
+ "Type": 3,
+ "Value": 1
+ },
+ {
+ "Type": 17,
+ "Value": 0.03
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 1,
+ "Type": 13,
+ "Value": 0.12
+ },
+ {
+ "ElementId": 128,
+ "MissileId": 74,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "ElementId": 128,
+ "Type": 13,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 30,
+ "Value": 0.06
+ },
+ {
+ "Type": 0,
+ "Value": 2
+ },
+ {
+ "Type": 16,
+ "Value": 0.02
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 1,
+ "Type": 9,
+ "Value": 0.02
+ },
+ {
+ "SkillId": 7,
+ "Type": 26,
+ "Value": 0.12
+ },
+ {
+ "ElementId": 128,
+ "Type": 9,
+ "Value": 0.015
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "ElementId": 128,
+ "Type": 31,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.05
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 2H Crossbow",
+ "ProficiencyId": 483,
+ "Version": 5
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 12,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "SkillId": 1,
+ "Type": 3,
+ "Value": 2
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 14,
+ "SpellId": 301,
+ "Type": 5,
+ "Value": 0.1
+ },
+ {
+ "ElementId": 32,
+ "MissileId": 72,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 301,
+ "Type": 5,
+ "Value": 0.075
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 15,
+ "SpellId": 301,
+ "Type": 5,
+ "Value": 0.075
+ },
+ {
+ "Type": 12,
+ "Value": 0.08
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 301,
+ "Type": 5,
+ "Value": 0.035
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 17,
+ "Value": 0.06
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.05
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 2H Fist",
+ "ProficiencyId": 484,
+ "Version": 5
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 8,
+ "Type": 3,
+ "Value": 1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "ElementId": 8,
+ "MissileId": 71,
+ "Multiplier": 2.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "Type": 11,
+ "Value": 0.015
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "SkillId": 8,
+ "Type": 25,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.05
+ }
+ ]
+ }
+ ],
+ "Name": "Moonsilver 2H Sword",
+ "ProficiencyId": 485,
+ "Version": 4
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 6,
+ "Type": 3,
+ "Value": 2
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.09
+ },
+ {
+ "ElementId": 16,
+ "MissileId": 71,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.075
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.025
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 1H Axe",
+ "ProficiencyId": 486,
+ "Version": 5
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 6,
+ "Type": 3,
+ "Value": 2
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.09
+ },
+ {
+ "ElementId": 16,
+ "MissileId": 71,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.075
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.025
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 1H Club",
+ "ProficiencyId": 487,
+ "Version": 5
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.025
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "SkillId": 1,
+ "Type": 3,
+ "Value": 1
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 43,
+ "Type": 5,
+ "Value": 0.09
+ },
+ {
+ "ElementId": 64,
+ "MissileId": 80,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 318,
+ "Type": 5,
+ "Value": 0.075
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.025
+ },
+ {
+ "SkillId": 1,
+ "Type": 3,
+ "Value": 2
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 43,
+ "Type": 5,
+ "Value": 0.015
+ },
+ {
+ "AugmentType": 3,
+ "SpellId": 5,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 318,
+ "Type": 5,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 64,
+ "Type": 31,
+ "Value": 0.04
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "ElementId": 16,
+ "Type": 31,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.1125
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 1H Rod",
+ "ProficiencyId": 488,
+ "Version": 5
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 6,
+ "Type": 3,
+ "Value": 2
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.09
+ },
+ {
+ "ElementId": 16,
+ "MissileId": 71,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.075
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 316,
+ "Type": 5,
+ "Value": 0.025
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 1H Sword",
+ "ProficiencyId": 489,
+ "Version": 6
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 12,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "SkillId": 1,
+ "Type": 3,
+ "Value": 1
+ },
+ {
+ "Type": 17,
+ "Value": 0.03
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 240,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "ElementId": 256,
+ "MissileId": 82,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 310,
+ "Type": 5,
+ "Value": 0.075
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.025
+ },
+ {
+ "SkillId": 1,
+ "Type": 3,
+ "Value": 2
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 240,
+ "Type": 5,
+ "Value": 0.01
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 21,
+ "Type": 5,
+ "Value": 0.025
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 310,
+ "Type": 5,
+ "Value": 0.015
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 8,
+ "Type": 31,
+ "Value": 0.04
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "ElementId": 256,
+ "Type": 31,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.1125
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 1H Wand",
+ "ProficiencyId": 490,
+ "Version": 5
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 10,
+ "Type": 3,
+ "Value": 1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.09
+ },
+ {
+ "ElementId": 8,
+ "MissileId": 79,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "Type": 11,
+ "Value": 0.0275
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "SkillId": 10,
+ "Type": 25,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 2H Axe",
+ "ProficiencyId": 491,
+ "Version": 5
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 12,
+ "Value": 0.08
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "SkillId": 7,
+ "Type": 3,
+ "Value": 1
+ },
+ {
+ "Type": 17,
+ "Value": 0.03
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 303,
+ "Type": 5,
+ "Value": 0.06
+ },
+ {
+ "ElementId": 128,
+ "MissileId": 81,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 302,
+ "Type": 5,
+ "Value": 0.06
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 30,
+ "Value": 0.06
+ },
+ {
+ "Type": 0,
+ "Value": 2
+ },
+ {
+ "Type": 16,
+ "Value": 0.02
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 303,
+ "Type": 5,
+ "Value": 0.01
+ },
+ {
+ "SkillId": 7,
+ "Type": 26,
+ "Value": 0.12
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 302,
+ "Type": 5,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ },
+ {
+ "Type": 12,
+ "Value": 0.1
+ },
+ {
+ "ElementId": 128,
+ "Type": 31,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 2H Bow",
+ "ProficiencyId": 492,
+ "Version": 8
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 9,
+ "Type": 3,
+ "Value": 1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.09
+ },
+ {
+ "ElementId": 64,
+ "MissileId": 73,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "Type": 11,
+ "Value": 0.0275
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "SkillId": 9,
+ "Type": 25,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 2H Club",
+ "ProficiencyId": 493,
+ "Version": 4
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 23,
+ "Value": 0.02
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 7,
+ "Value": 0.03
+ },
+ {
+ "SkillId": 7,
+ "Type": 3,
+ "Value": 1
+ },
+ {
+ "Type": 17,
+ "Value": 0.03
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 1,
+ "Type": 13,
+ "Value": 0.18
+ },
+ {
+ "ElementId": 128,
+ "MissileId": 81,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "ElementId": 128,
+ "Type": 13,
+ "Value": 0.15
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 30,
+ "Value": 0.06
+ },
+ {
+ "Type": 0,
+ "Value": 2
+ },
+ {
+ "Type": 16,
+ "Value": 0.02
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 1,
+ "Type": 9,
+ "Value": 0.02
+ },
+ {
+ "SkillId": 7,
+ "Type": 26,
+ "Value": 0.12
+ },
+ {
+ "ElementId": 128,
+ "Type": 9,
+ "Value": 0.015
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "ElementId": 128,
+ "Type": 31,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 2H Crossbow",
+ "ProficiencyId": 494,
+ "Version": 6
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 12,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "SkillId": 1,
+ "Type": 3,
+ "Value": 2
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 14,
+ "SpellId": 301,
+ "Type": 5,
+ "Value": 0.15
+ },
+ {
+ "ElementId": 32,
+ "MissileId": 79,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "AugmentType": 2,
+ "SpellId": 301,
+ "Type": 5,
+ "Value": 0.1125
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 15,
+ "SpellId": 301,
+ "Type": 5,
+ "Value": 0.075
+ },
+ {
+ "Type": 12,
+ "Value": 0.08
+ },
+ {
+ "AugmentType": 17,
+ "SpellId": 301,
+ "Type": 5,
+ "Value": 0.035
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 17,
+ "Value": 0.06
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 2H Fist",
+ "ProficiencyId": 495,
+ "Version": 6
+ },
+ {
+ "Levels": [
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.04
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "BestiaryId": 10,
+ "Type": 6,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.05
+ },
+ {
+ "SkillId": 8,
+ "Type": 3,
+ "Value": 1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 2,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.09
+ },
+ {
+ "ElementId": 8,
+ "MissileId": 77,
+ "Multiplier": 3.0,
+ "Probability": 0.01,
+ "Type": 32,
+ "Value": 0
+ },
+ {
+ "Type": 11,
+ "Value": 0.0275
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 17,
+ "Value": 0.03
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "Type": 8,
+ "Value": 0.01
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "AugmentType": 17,
+ "SpellId": 106,
+ "Type": 5,
+ "Value": 0.03
+ },
+ {
+ "Type": 12,
+ "Value": 0.12
+ },
+ {
+ "SkillId": 8,
+ "Type": 25,
+ "Value": 0.1
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 16,
+ "Value": 0.02
+ },
+ {
+ "Type": 0,
+ "Value": 1
+ },
+ {
+ "ElementId": 1,
+ "Type": 31,
+ "Value": 0.09
+ }
+ ]
+ },
+ {
+ "Perks": [
+ {
+ "Type": 29,
+ "Value": 0.075
+ }
+ ]
+ }
+ ],
+ "Name": "Stellar Moonsilver 2H Sword",
+ "ProficiencyId": 496,
+ "Version": 4
}
-]
\ No newline at end of file
+]
diff --git a/data/scripts/actions/items/imbuement_scrolls.lua b/data/scripts/actions/items/imbuement_scrolls.lua
index 597b4218a..942f7c60c 100644
--- a/data/scripts/actions/items/imbuement_scrolls.lua
+++ b/data/scripts/actions/items/imbuement_scrolls.lua
@@ -9,5 +9,78 @@ function imbuementScrolls.onUse(player, item, fromPosition, target, toPosition,
return true
end
-imbuementScrolls:id(51444, 51445, 51446, 51447, 51448, 51449, 51450, 51451, 51452, 51453, 51454, 51455, 51456, 51457, 51458, 51459, 51460, 51461, 51462, 51463, 51464, 51465, 51466, 51467, 51724, 51725, 51726, 51727, 51728, 51729, 51730, 51731, 51732, 51733, 51734, 51735, 51736, 51737, 51738, 51739, 51740, 51741, 51742, 51743, 51744, 51745, 51746, 51747)
+imbuementScrolls:id(
+ 51444,
+ 51445,
+ 51446,
+ 51447,
+ 51448,
+ 51449,
+ 51450,
+ 51451,
+ 51452,
+ 51453,
+ 51454,
+ 51455,
+ 51456,
+ 51457,
+ 51458,
+ 51459,
+ 51460,
+ 51461,
+ 51462,
+ 51463,
+ 51464,
+ 51465,
+ 51466,
+ 51467,
+ 51724,
+ 51725,
+ 51726,
+ 51727,
+ 51728,
+ 51729,
+ 51730,
+ 51731,
+ 51732,
+ 51733,
+ 51734,
+ 51735,
+ 51736,
+ 51737,
+ 51738,
+ 51739,
+ 51740,
+ 51741,
+ 51742,
+ 51743,
+ 51744,
+ 51745,
+ 51746,
+ 51747,
+ 53751,
+ 53752,
+ 53753,
+ 53754,
+ 53755,
+ 53756,
+ 53757,
+ 53758,
+ 53759,
+ 53760,
+ 53761,
+ 53762,
+ 53763,
+ 53764,
+ 53765,
+ 53766,
+ 53767,
+ 53768,
+ 53769,
+ 53770,
+ 53771,
+ 53772,
+ 53773,
+ 53774
+)
imbuementScrolls:register()
diff --git a/data/scripts/talkactions/god/boss_difficulty_test.lua b/data/scripts/talkactions/god/boss_difficulty_test.lua
new file mode 100644
index 000000000..53625617d
--- /dev/null
+++ b/data/scripts/talkactions/god/boss_difficulty_test.lua
@@ -0,0 +1,40 @@
+-- Boss Difficulty Selection window — S2C opcode 0x2F.
+-- Field -> controller (RE'd open handler 0x1405ed8d3):
+-- f1->+0x40 = lowestDifficulty | f2(u8)->+0x44 = SPINNER GATE (f2=0 -> spinner editable!) |
+-- f3->+0x48 = raceId | f4->+0x4c = selectedDifficulty (value shown in spinner) |
+-- f5->+0x50 = highestDifficultyOfGroup | f6->+0x54 = personalHighestDifficulty | f7->+0x58 = badLuck/1000
+--
+-- LIVE-confirmed: f2=0 makes the "Select Difficulty" spinner appear with arrows; f4 = the shown value.
+-- The server NO LONGER auto-closes on the client's 0xC2 (that was making the window vanish); it now
+-- just logs the 0xC2 body so we can wire Cancel/Start properly next.
+--
+-- Usage: /bossdiff [selected=f4] [lowest=f1] [group=f5] [personal=f6]
+-- default: /bossdiff 100 -> boss 100, spinner ON, selected 1, range lowest 1 .. group/personal 25
+
+local talkaction = TalkAction("/bossdiff")
+
+function talkaction.onSay(player, words, param)
+ local a = {}
+ for tok in string.gmatch(param or "", "%S+") do
+ a[#a + 1] = tonumber(tok)
+ end
+
+ local raceId = a[1] or 100
+ local selected = a[2] or 0 -- f4 = selectedDifficulty (spinner start value)
+ local lowest = a[3] or 0 -- f1 = lowestDifficulty (spinner min = 0)
+ local group = a[4] or 25 -- f5 = highestDifficultyOfGroup (max = 25)
+ local personal = a[5] or 25 -- f6 = personalHighestDifficulty (max = 25)
+ local spinnerGate = 0 -- f2 = 0 -> spinner editable (difficulty 0..25)
+
+ -- numbers = { f1=lowest, f2=gate, f3=raceId, f4=selected, f5=group, f6=personal, f7=badluck }
+ local numbers = { lowest, spinnerGate, raceId, selected, group, personal, 2000 }
+ local banners = { "Yvara", "Gryllan", "Frost Walker", "Drift Reaper", "Eradrel" }
+
+ player:sendBossDifficultySelection(0, numbers, banners, {}, {})
+ player:sendTextMessage(MESSAGE_EVENT_ADVANCE, string.format("[bossdiff] boss=%d selected=%d lowest=%d group=%d personal=%d", raceId, selected, lowest, group, personal))
+ return true
+end
+
+talkaction:separator(" ")
+talkaction:groupType("god")
+talkaction:register()
diff --git a/src/core.hpp b/src/core.hpp
index 41c20bd7d..e37b8773b 100644
--- a/src/core.hpp
+++ b/src/core.hpp
@@ -18,8 +18,8 @@
#pragma once
static constexpr auto SOFTWARE_NAME = "Crystal Server";
-static constexpr auto SOFTWARE_VERSION = "4.1.8";
-static constexpr auto GAME_UPDATE = "Winter Update 2025";
+static constexpr auto SOFTWARE_VERSION = "4.1.9";
+static constexpr auto GAME_UPDATE = "Summer Update 2026";
static constexpr auto AUTHENTICATOR_DIGITS = 6U;
static constexpr auto AUTHENTICATOR_PERIOD = 30U;
diff --git a/src/creatures/monsters/monster.cpp b/src/creatures/monsters/monster.cpp
index 71791246a..ce72000e2 100644
--- a/src/creatures/monsters/monster.cpp
+++ b/src/creatures/monsters/monster.cpp
@@ -2668,9 +2668,39 @@ float Monster::getAttackMultiplier() const {
if (auto stacks = getForgeStack(); stacks > 0) {
multiplier *= (1.35 + (stacks - 1) * 0.1);
}
+ if (echoWarden) {
+ multiplier *= echoAtkMult;
+ }
return multiplier;
}
+bool Monster::applyEchoWarden(float hpMult, float atkMult) {
+ if (echoWarden) {
+ return false;
+ }
+ if (!mType) {
+ return false;
+ }
+ // Same protections as the forge/soulpit guards: never on summon/boss/fiendish/soulpit.
+ // (C: also excluded `dark`, but this fork has no dark-monster layer.)
+ if (isSummon() || mType->isBoss() || forgeStack > 0 || soulPit) {
+ return false;
+ }
+
+ echoWarden = true;
+ echoAtkMult = atkMult;
+
+ healthMax = static_cast(std::ceil(static_cast(healthMax) * hpMult));
+ health = healthMax;
+ runAwayHealth = static_cast(std::ceil(static_cast(runAwayHealth) * hpMult));
+
+ // Intense glow, distinct "warden" key (NOT "forge"); NO rename, classification stays NORMAL.
+ setIcon("warden", CreatureIcon(CreatureIconModifications_t::Fiendish, 0));
+ g_game().updateCreatureIcon(static_self_cast());
+ g_game().sendUpdateCreature(static_self_cast());
+ return true;
+}
+
float Monster::getDefenseMultiplier() const {
float multiplier = mType->getDefenseMultiplier();
return multiplier;
diff --git a/src/creatures/monsters/monster.hpp b/src/creatures/monsters/monster.hpp
index d84b53968..dbdb45f1b 100644
--- a/src/creatures/monsters/monster.hpp
+++ b/src/creatures/monsters/monster.hpp
@@ -231,6 +231,8 @@ class Monster final : public Creature {
float getDefenseMultiplier() const;
+ bool applyEchoWarden(float hpMult, float atkMult);
+
bool isDead() const override;
void setDead(bool isDead);
@@ -316,6 +318,9 @@ class Monster final : public Creature {
bool soulPit = false;
+ bool echoWarden = false;
+ float echoAtkMult = 1.0f;
+
bool m_isDead = false;
bool m_isImmune = false;
diff --git a/src/creatures/players/player.cpp b/src/creatures/players/player.cpp
index 04fbea05b..7db947532 100644
--- a/src/creatures/players/player.cpp
+++ b/src/creatures/players/player.cpp
@@ -8502,6 +8502,12 @@ void Player::sendScreenshotAndBannerWeeklyTaskSpecificFinished(uint16_t raceId)
}
}
+void Player::sendScreenshotAndBannerLeaderMonsterKilled(uint16_t raceId, uint32_t charmPoints) const {
+ if (client) {
+ client->sendScreenshotAndBannerLeaderMonsterKilled(raceId, charmPoints);
+ }
+}
+
void Player::checkSpellUnlocksOnAdvance(uint32_t oldLevel, uint32_t newLevel, uint32_t oldMagLevel, uint32_t newMagLevel) const {
if (!client) {
return;
@@ -12742,6 +12748,121 @@ EquippedWeaponProficiencyBonuses &Player::getEquippedWeaponProficiency() {
return equippedWeaponProficiency;
}
+void Player::tryProcWeaponProficiencyHomingMissile(const std::shared_ptr &target) {
+ if (!target || target->isRemoved() || target->getHealth() <= 0) {
+ return;
+ }
+
+ // Equipped weapon (ignore ammo so distance weapons resolve to the bow itself).
+ const auto &weapon = getWeapon(true);
+ if (!weapon) {
+ return;
+ }
+ const uint16_t itemId = weapon->getID();
+
+ // Player's unlocked/active perk slots for this weapon.
+ auto profIt = weaponProficiencies.find(itemId);
+ if (profIt == weaponProficiencies.end()) {
+ return;
+ }
+ const WeaponProficiencyData &playerProficiencyData = profIt->second;
+
+ // The weapon's proficiency tree (the perk catalogue). Nullptr-guarded (also logs internally).
+ const WeaponProficiencyStruct* proficiencyData = g_proficiencies().getProficiencyByItemId(itemId);
+ if (!proficiencyData) {
+ return;
+ }
+
+ for (const auto &lvl : proficiencyData->proficiencyDataLevel) {
+ for (const auto &perk : lvl.proficiencyDataPerks) {
+ if (perk.perkType != PROFICIENCY_PERK_ON_HIT_HOMING_MISSILE) {
+ continue;
+ }
+
+ // Active only if the player has unlocked this exact slot (level + position).
+ const bool isActive = std::any_of(
+ playerProficiencyData.activePerks.begin(), playerProficiencyData.activePerks.end(),
+ [&](const WeaponProficiencyPerk &p) {
+ return p.proficiencyLevel == lvl.proficiencyLevel && p.perkPosition == perk.positionSlot;
+ }
+ );
+ if (!isActive) {
+ continue;
+ }
+
+ if (perk.probability <= 0.0f || perk.multiplier <= 0.0f) {
+ continue;
+ }
+
+ // Roll Probability (e.g. 0.01 = 1%).
+ if (!boolean_random(static_cast(perk.probability))) {
+ continue;
+ }
+
+ // Map ElementId (stored in perk.damageType) -> CombatType + the homing-missile target hit effect
+ // observed in the 15.25 client. These raw effect ids (176-181) are sent verbatim over the wire;
+ // the official client renders them as the element-specific homing-missile impact.
+ CombatType_t combatType = COMBAT_NONE;
+ uint16_t targetEffect = CONST_ME_NONE;
+ switch (perk.damageType) {
+ case PROFICIENCY_DAMAGETYPE_PHYSICAL:
+ combatType = COMBAT_PHYSICALDAMAGE;
+ targetEffect = CONST_ME_DRAWBLOOD;
+ break;
+ case PROFICIENCY_DAMAGETYPE_FIRE:
+ combatType = COMBAT_FIREDAMAGE;
+ targetEffect = 177;
+ break;
+ case PROFICIENCY_DAMAGETYPE_EARTH:
+ combatType = COMBAT_EARTHDAMAGE;
+ targetEffect = 178;
+ break;
+ case PROFICIENCY_DAMAGETYPE_ENERGY:
+ combatType = COMBAT_ENERGYDAMAGE;
+ targetEffect = 179;
+ break;
+ case PROFICIENCY_DAMAGETYPE_ICE:
+ combatType = COMBAT_ICEDAMAGE;
+ targetEffect = 176;
+ break;
+ case PROFICIENCY_DAMAGETYPE_HOLY:
+ combatType = COMBAT_HOLYDAMAGE;
+ targetEffect = 181;
+ break;
+ case PROFICIENCY_DAMAGETYPE_DEATH:
+ combatType = COMBAT_DEATHDAMAGE;
+ targetEffect = 180;
+ break;
+ default:
+ continue; // healing / unsupported element -> skip
+ }
+
+ // Damage = Multiplier * level (data-driven; Multiplier lives in proficiencies.json).
+ const int32_t dmg = static_cast(perk.multiplier * static_cast(getLevel()));
+ if (dmg <= 0) {
+ continue;
+ }
+
+ // (b) shoot/distance effect caster -> target. missileId is the raw 15.25 shoot id from the json
+ // (e.g. 74 normal / 81 Stellar for holy); sent verbatim, the official client renders it.
+ g_game().addDistanceEffect(getPosition(), target->getPosition(), perk.missileId, static_self_cast());
+
+ // (c) magic hit effect on the target tile.
+ g_game().addMagicEffect(target->getPosition(), targetEffect);
+
+ // (a) element-typed damage (negative value = damage).
+ CombatDamage damage;
+ damage.origin = ORIGIN_SPELL;
+ damage.primary.type = combatType;
+ damage.primary.value = -dmg;
+ g_game().combatChangeHealth(static_self_cast(), target, damage);
+
+ // One proc per hit: stop after the first active homing-missile perk fires.
+ return;
+ }
+ }
+}
+
void Player::addWeaponProficiencyExperience(const std::shared_ptr &mType, const ForgeClassifications_t classification, const bool bossSoulpit) {
uint32_t addProficiencyExperience = 0;
const auto weaponProficiencyRate = std::max(0.0f, g_configManager().getFloat(RATE_WEAPON_PROFICIENCY));
@@ -12865,6 +12986,287 @@ void Player::sendWeaponProficiencyInfo(const uint16_t itemId) const {
}
}
+void Player::sendBossDifficultySelection(uint8_t selectedDifficulty, const std::vector &numbers, const std::vector &banners, const std::vector &redMods, const std::vector &greenMods) const {
+ if (client) {
+ client->sendBossDifficultySelection(selectedDifficulty, numbers, banners, redMods, greenMods);
+ }
+}
+
+void Player::sendWeaponProficiencyReshapeOffers(const uint16_t itemId) const {
+ if (client) {
+ client->sendWeaponProficiencyReshapeOffers(itemId);
+ }
+}
+
+uint8_t Player::getWeaponProficiencyVocationRegion(const uint16_t itemId) const {
+ // 15.25 (sommerrelease26): the client shaping catalogue is laid out in 50-wide vocation regions
+ // (0 = knight 1-50, 1 = paladin 51-100, 2 = sorcerer 101-150, 3 = druid 151-200, 4 = monk 201-250).
+ // Derive the region from the WEAPON so a paladin bow always offers paladin augments. Wands (sorcerer)
+ // and rods (druid) share WEAPON_WAND, so disambiguate by the holder's CIP vocation.
+ const ItemType &itemType = Item::items[itemId];
+ switch (itemType.weaponType) {
+ case WEAPON_SWORD:
+ case WEAPON_CLUB:
+ case WEAPON_AXE:
+ return 0; // knight
+ case WEAPON_DISTANCE:
+ case WEAPON_AMMO:
+ case WEAPON_MISSILE:
+ return 1; // paladin
+ case WEAPON_FIST:
+ return 4; // monk
+ case WEAPON_WAND: {
+ return getPlayerVocationEnum() == Vocation_t::VOCATION_DRUID_CIP ? 3 : 2;
+ }
+ default: {
+ const uint16_t cip = getPlayerVocationEnum();
+ return cip > Vocation_t::VOCATION_NONE ? static_cast(cip - 1) : 0;
+ }
+ }
+}
+
+WeaponProficiencyPerkType_t Player::rollWeaponProficiencyPerk(const uint16_t itemId) const {
+ // 15.25 (sommerrelease26): roll one valid shapeable perk from the union of the weapon's per-vocation spell
+ // augments (region*50 + UNIVERSAL offsets) and the GENERAL vocation-agnostic pool. Both contain only valid
+ // catalogue indices, so the client never renders the "Attack Damage" fallback.
+ const uint8_t region = getWeaponProficiencyVocationRegion(itemId);
+ constexpr int32_t augmentPoolSize = static_cast(sizeof(WEAPON_PROFICIENCY_UNIVERSAL_SHAPEABLE_PERKS) / sizeof(WEAPON_PROFICIENCY_UNIVERSAL_SHAPEABLE_PERKS[0]));
+ constexpr int32_t generalPoolSize = static_cast(sizeof(WEAPON_PROFICIENCY_GENERAL_SHAPEABLE_PERKS) / sizeof(WEAPON_PROFICIENCY_GENERAL_SHAPEABLE_PERKS[0]));
+ const int32_t pick = uniform_random(0, augmentPoolSize + generalPoolSize - 1);
+ if (pick < augmentPoolSize) {
+ return static_cast(region * 50 + static_cast(WEAPON_PROFICIENCY_UNIVERSAL_SHAPEABLE_PERKS[pick]));
+ }
+ return WEAPON_PROFICIENCY_GENERAL_SHAPEABLE_PERKS[pick - augmentPoolSize];
+}
+
+void Player::modifyWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition) {
+ static constexpr uint64_t MODIFY_DUST_COST = 250;
+ auto it = weaponProficiencies.find(itemId);
+ if (it == weaponProficiencies.end()) {
+ sendTextMessage(MESSAGE_FAILURE, "You have no proficiency progress on this weapon.");
+ return;
+ }
+ const auto &tile = getTile();
+ if (!tile || !tile->hasFlag(TILESTATE_PROTECTIONZONE)) {
+ sendTextMessage(MESSAGE_FAILURE, "You can only modify proficiency slots inside a protection zone.");
+ return;
+ }
+ if (getForgeDusts() < MODIFY_DUST_COST) {
+ sendTextMessage(MESSAGE_FAILURE, "You do not have enough dust to modify this slot.");
+ return;
+ }
+ removeForgeDusts(MODIFY_DUST_COST);
+ auto &proficiency = it->second;
+ const auto perkType = rollWeaponProficiencyPerk(itemId);
+ const uint8_t value = 1;
+ // Store 1-based; the wire request is 0-based.
+ const uint8_t storedLevel = static_cast(proficiencyLevel + 1);
+ const uint8_t storedPosition = static_cast(perkPosition + 1);
+ bool replaced = false;
+ for (auto &slot : proficiency.modifiedSlots) {
+ if (slot.proficiencyLevel == storedLevel && slot.perkPosition == storedPosition) {
+ slot.perkType = perkType;
+ slot.value = value;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ proficiency.modifiedSlots.push_back({ storedLevel, storedPosition, perkType, value });
+ }
+ applyEquippedWeaponProficiency(itemId);
+ sendWeaponProficiencyInfo(itemId);
+}
+
+void Player::refineWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition) {
+ static constexpr uint64_t REFINE_DUST_COST = 200;
+ static constexpr uint8_t MAX_RANK = 10;
+ auto it = weaponProficiencies.find(itemId);
+ if (it == weaponProficiencies.end()) {
+ sendTextMessage(MESSAGE_FAILURE, "You have no proficiency progress on this weapon.");
+ return;
+ }
+ const auto &tile = getTile();
+ if (!tile || !tile->hasFlag(TILESTATE_PROTECTIONZONE)) {
+ sendTextMessage(MESSAGE_FAILURE, "You can only refine proficiency slots inside a protection zone.");
+ return;
+ }
+ const uint8_t storedLevel = static_cast(proficiencyLevel + 1);
+ const uint8_t storedPosition = static_cast(perkPosition + 1);
+ auto &proficiency = it->second;
+ WeaponProficiencyModifiedSlot* slot = nullptr;
+ for (auto &candidate : proficiency.modifiedSlots) {
+ if (candidate.proficiencyLevel == storedLevel && candidate.perkPosition == storedPosition) {
+ slot = &candidate;
+ break;
+ }
+ }
+ if (!slot) {
+ sendTextMessage(MESSAGE_FAILURE, "That slot has not been modified yet.");
+ return;
+ }
+ if (slot->value >= MAX_RANK) {
+ sendTextMessage(MESSAGE_FAILURE, "This perk is already at its maximum rank.");
+ return;
+ }
+ if (getForgeDusts() < REFINE_DUST_COST) {
+ sendTextMessage(MESSAGE_FAILURE, fmt::format("You need {} dust to refine this perk (you currently have {}).", REFINE_DUST_COST, getForgeDusts()));
+ return;
+ }
+ removeForgeDusts(REFINE_DUST_COST);
+ ++slot->value;
+ applyEquippedWeaponProficiency(itemId);
+ sendWeaponProficiencyInfo(itemId);
+}
+
+void Player::maximiseWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition) {
+ // TODO(15.25): official client charges 1 special tradeable item; not in data yet, so just set to max.
+ static constexpr uint8_t MAX_RANK = 10;
+ auto it = weaponProficiencies.find(itemId);
+ if (it == weaponProficiencies.end()) {
+ sendTextMessage(MESSAGE_FAILURE, "You have no proficiency progress on this weapon.");
+ return;
+ }
+ const auto &tile = getTile();
+ if (!tile || !tile->hasFlag(TILESTATE_PROTECTIONZONE)) {
+ sendTextMessage(MESSAGE_FAILURE, "You can only shape proficiency slots inside a protection zone.");
+ return;
+ }
+ const uint8_t storedLevel = static_cast(proficiencyLevel + 1);
+ const uint8_t storedPosition = static_cast(perkPosition + 1);
+ auto &proficiency = it->second;
+ WeaponProficiencyModifiedSlot* slot = nullptr;
+ for (auto &candidate : proficiency.modifiedSlots) {
+ if (candidate.proficiencyLevel == storedLevel && candidate.perkPosition == storedPosition) {
+ slot = &candidate;
+ break;
+ }
+ }
+ if (!slot) {
+ sendTextMessage(MESSAGE_FAILURE, "That slot has not been modified yet.");
+ return;
+ }
+ if (slot->value >= MAX_RANK) {
+ sendTextMessage(MESSAGE_FAILURE, "This perk is already at its maximum rank.");
+ return;
+ }
+ slot->value = MAX_RANK;
+ applyEquippedWeaponProficiency(itemId);
+ sendWeaponProficiencyInfo(itemId);
+}
+
+void Player::clearWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition) {
+ auto it = weaponProficiencies.find(itemId);
+ if (it == weaponProficiencies.end()) {
+ sendTextMessage(MESSAGE_FAILURE, "You have no proficiency progress on this weapon.");
+ return;
+ }
+ const auto &tile = getTile();
+ if (!tile || !tile->hasFlag(TILESTATE_PROTECTIONZONE)) {
+ sendTextMessage(MESSAGE_FAILURE, "You can only shape proficiency slots inside a protection zone.");
+ return;
+ }
+ const uint8_t storedLevel = static_cast(proficiencyLevel + 1);
+ const uint8_t storedPosition = static_cast(perkPosition + 1);
+ auto &proficiency = it->second;
+ const auto removed = std::erase_if(proficiency.modifiedSlots, [storedLevel, storedPosition](const WeaponProficiencyModifiedSlot &s) {
+ return s.proficiencyLevel == storedLevel && s.perkPosition == storedPosition;
+ });
+ if (removed == 0) {
+ sendTextMessage(MESSAGE_FAILURE, "That slot has not been modified.");
+ return;
+ }
+ applyEquippedWeaponProficiency(itemId);
+ sendWeaponProficiencyInfo(itemId);
+}
+
+void Player::reshapeWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition) {
+ static constexpr uint8_t RESHAPE_OFFER_COUNT = 3;
+ static constexpr uint64_t RESHAPE_DUST_COST = 250;
+ auto it = weaponProficiencies.find(itemId);
+ if (it == weaponProficiencies.end()) {
+ sendTextMessage(MESSAGE_FAILURE, "You have no proficiency progress on this weapon.");
+ return;
+ }
+ const auto &tile = getTile();
+ if (!tile || !tile->hasFlag(TILESTATE_PROTECTIONZONE)) {
+ sendTextMessage(MESSAGE_FAILURE, "You can only shape proficiency slots inside a protection zone.");
+ return;
+ }
+ const uint8_t storedLevel = static_cast(proficiencyLevel + 1);
+ const uint8_t storedPosition = static_cast(perkPosition + 1);
+ auto &proficiency = it->second;
+ WeaponProficiencyModifiedSlot* slot = nullptr;
+ for (auto &candidate : proficiency.modifiedSlots) {
+ if (candidate.proficiencyLevel == storedLevel && candidate.perkPosition == storedPosition) {
+ slot = &candidate;
+ break;
+ }
+ }
+ if (!slot) {
+ sendTextMessage(MESSAGE_FAILURE, "That slot has not been modified yet.");
+ return;
+ }
+ if (getForgeDusts() < RESHAPE_DUST_COST) {
+ sendTextMessage(MESSAGE_FAILURE, fmt::format("You need {} dust to reshape this perk (you currently have {}).", RESHAPE_DUST_COST, getForgeDusts()));
+ return;
+ }
+ removeForgeDusts(RESHAPE_DUST_COST);
+ (void)slot;
+ std::vector offers;
+ offers.reserve(RESHAPE_OFFER_COUNT);
+ for (int32_t guard = 0; offers.size() < RESHAPE_OFFER_COUNT && guard < 200; ++guard) {
+ const auto candidate = rollWeaponProficiencyPerk(itemId);
+ bool duplicate = false;
+ for (const auto existing : offers) {
+ if (existing == candidate) {
+ duplicate = true;
+ break;
+ }
+ }
+ if (!duplicate) {
+ offers.push_back(candidate);
+ }
+ }
+ proficiency.pendingReshapeLevel = storedLevel;
+ proficiency.pendingReshapePosition = storedPosition;
+ proficiency.pendingReshapeOffers = offers;
+ sendWeaponProficiencyReshapeOffers(itemId);
+}
+
+void Player::pickReshapeWeaponProficiencyOffer(const uint16_t itemId, const uint8_t offerIndex) {
+ // 15.25: the client sends the 0-based index of the clicked offer (left=0/middle=1/right=2).
+ auto it = weaponProficiencies.find(itemId);
+ if (it == weaponProficiencies.end()) {
+ return;
+ }
+ auto &proficiency = it->second;
+ if (proficiency.pendingReshapeOffers.empty()) {
+ return;
+ }
+ if (static_cast(offerIndex) >= proficiency.pendingReshapeOffers.size()) {
+ return;
+ }
+ const auto chosen = proficiency.pendingReshapeOffers[offerIndex];
+ WeaponProficiencyModifiedSlot* slot = nullptr;
+ for (auto &candidate : proficiency.modifiedSlots) {
+ if (candidate.proficiencyLevel == proficiency.pendingReshapeLevel && candidate.perkPosition == proficiency.pendingReshapePosition) {
+ slot = &candidate;
+ break;
+ }
+ }
+ if (!slot) {
+ proficiency.pendingReshapeOffers.clear();
+ return;
+ }
+ slot->perkType = chosen;
+ proficiency.pendingReshapeOffers.clear();
+ proficiency.pendingReshapeLevel = 0;
+ proficiency.pendingReshapePosition = 0;
+ applyEquippedWeaponProficiency(itemId);
+ sendWeaponProficiencyInfo(itemId);
+}
+
void Player::resetAllWeaponProficiencyPerks(const uint16_t itemId) {
auto it = weaponProficiencies.find(itemId);
if (it == weaponProficiencies.end()) {
@@ -13148,6 +13550,72 @@ void Player::applyEquippedWeaponProficiency(const uint16_t itemId) {
}
}
+ // 15.25 (sommerrelease26) SHAPE: apply the dust-modified slots on top of the tree perks. modSlot.perkType is
+ // a CLIENT CATALOGUE INDEX (1-323) from rollWeaponProficiencyPerk() -- NOT a server perk enum -- so decode the
+ // index range here into the SAME aggregate fields the tree-perk switch above uses (already-wired hooks pick
+ // them up). Magnitude is linear between the catalogue's rank-0 and rank-10 figures. See PORT.md §8.2.
+ for (const auto &modSlot : playerProficiencyData.modifiedSlots) {
+ const int32_t idx = static_cast(modSlot.perkType);
+ const float rankFraction = static_cast(modSlot.value) / 10.0f;
+ const auto lerp = [rankFraction](float rank0, float rank10) {
+ return rank0 + (rank10 - rank0) * rankFraction;
+ };
+
+ // --- bestiary damage: 251-271 = 250 + bestiaryId (0.50% -> 2.50% vs that race) ---
+ if (idx >= 251 && idx <= 271) {
+ equippedWeaponProficiency.bestiaryRacePercentDamageGain += lerp(0.5f, 2.5f) / 100.0f;
+ equippedWeaponProficiency.bestiaryId = static_cast(idx - 250); // single-slot: last decoded wins
+ continue;
+ }
+
+ switch (idx) {
+ // --- leech / on-hit / on-kill / alpha / omega: 281-288 ---
+ case 281: // mana leech 1.00% -> 8.00% (stored as fraction * 10000 bp)
+ equippedWeaponProficiency.manaLeech += static_cast((lerp(1.0f, 8.0f) / 100.0f) * 10000.0f);
+ break;
+ case 282: // life leech 1.00% -> 16.00%
+ equippedWeaponProficiency.lifeLeech += static_cast((lerp(1.0f, 16.0f) / 100.0f) * 10000.0f);
+ break;
+ case 283: // +2 -> +12 mana on hit
+ equippedWeaponProficiency.manaGainOnHit += static_cast(lerp(2.0f, 12.0f));
+ break;
+ case 284: // +5 -> +25 HP on hit
+ equippedWeaponProficiency.lifeGainOnHit += static_cast(lerp(5.0f, 25.0f));
+ break;
+ case 285: // +4 -> +24 mana on kill
+ equippedWeaponProficiency.manaGainOnKill += static_cast(lerp(4.0f, 24.0f));
+ break;
+ case 286: // +10 -> +50 HP on kill
+ equippedWeaponProficiency.lifeGainOnKill += static_cast(lerp(10.0f, 50.0f));
+ break;
+ case 287: // alpha strike 2.00% -> 10.00% (vs targets above 95% HP)
+ equippedWeaponProficiency.alphaStrikeExtraDamage += lerp(2.0f, 10.0f) / 100.0f;
+ break;
+ case 288: // omega strike 1.00% -> 4.00% (vs targets below 30% HP)
+ equippedWeaponProficiency.omegaStrikeExtraDamage += lerp(1.0f, 4.0f) / 100.0f;
+ break;
+
+ // --- universals: 321 armor pen, 322 elemental pierce, 323 powerful foe ---
+ case 321: // armor penetration 5% -> 15%
+ equippedWeaponProficiency.armorPenetration += lerp(5.0f, 15.0f) / 100.0f;
+ break;
+ case 322: // elemental pierce 5% -> 15% (no element sub-id -> all elements; curve estimated)
+ for (int element = 0; element < COMBAT_COUNT; ++element) {
+ equippedWeaponProficiency.elementalPierce[element] += lerp(5.0f, 15.0f) / 100.0f;
+ }
+ break;
+ case 323: // powerful foe (boss / sinister-embraced) 1% -> 5% (curve estimated)
+ equippedWeaponProficiency.damageGainBossAndSinisterEmbraced += lerp(1.0f, 5.0f) / 100.0f;
+ break;
+
+ default:
+ // 1-250 (per-vocation spell augments) and 291-317 (skill%) intentionally NOT applied yet (§8.3:
+ // augments need the authoritative (region, spellIndex) -> spellId map; skill% needs its real curve
+ // + the combat.cpp auto-attack sign fix). Decoded but parked until live data.
+ break;
+ }
+ }
+
sendStats();
sendSkills();
}
diff --git a/src/creatures/players/player.hpp b/src/creatures/players/player.hpp
index db78f7f8c..3035cadea 100644
--- a/src/creatures/players/player.hpp
+++ b/src/creatures/players/player.hpp
@@ -148,9 +148,24 @@ struct WeaponProficiencyPerk {
uint8_t perkPosition = 0;
};
+// 15.25 (sommerrelease26) SHAPE: a perk slot can be "modified" with dust to roll a custom effect that
+// overrides the slot's tree perk. One entry per modified slot, keyed by (level, position). perkType is a
+// CLIENT CATALOGUE INDEX (1-323), value is the rank 0-10.
+struct WeaponProficiencyModifiedSlot {
+ uint8_t proficiencyLevel = 0;
+ uint8_t perkPosition = 0;
+ WeaponProficiencyPerkType_t perkType = PROFICIENCY_PERK_ATTACK_DAMAGE;
+ uint8_t value = 0;
+};
+
struct WeaponProficiencyData {
uint32_t experience = 0;
std::vector activePerks;
+ std::vector modifiedSlots;
+ // 15.25 (sommerrelease26): transient Reshape state (offer -> pick). Not persisted.
+ uint8_t pendingReshapeLevel = 0;
+ uint8_t pendingReshapePosition = 0;
+ std::vector pendingReshapeOffers;
};
struct WeaponProficiencyAugment {
@@ -1131,11 +1146,26 @@ class Player final : public Creature, public Cylinder, public Bankable {
// Weapon Proficiency
EquippedWeaponProficiencyBonuses &getEquippedWeaponProficiency();
+
+ // Weapon-proficiency Type-32 "Homing Missile": on a weapon hit, rolls each active Type-32 perk's probability
+ // and on success fires an element-typed homing missile at the target.
+ void tryProcWeaponProficiencyHomingMissile(const std::shared_ptr &target);
void sendWeaponProficiencyInfo(const uint16_t itemId) const;
+ void sendBossDifficultySelection(uint8_t selectedDifficulty, const std::vector &numbers, const std::vector &banners, const std::vector &redMods, const std::vector &greenMods) const;
void resetAllWeaponProficiencyPerks(const uint16_t itemId);
void applyEquippedWeaponProficiency(const uint16_t itemId);
void removeEquippedWeaponProficiency(const uint16_t itemId);
void sendWeaponProficiencyExperience(const uint16_t itemId, const uint32_t addProficiencyExperience);
+ // 15.25 (sommerrelease26) SHAPE handlers + helpers (see CLIENT_15.25_e2a4a1_PORT.md §7.5 / §8).
+ uint8_t getWeaponProficiencyVocationRegion(const uint16_t itemId) const;
+ WeaponProficiencyPerkType_t rollWeaponProficiencyPerk(const uint16_t itemId) const;
+ void modifyWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition);
+ void refineWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition);
+ void maximiseWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition);
+ void clearWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition);
+ void reshapeWeaponProficiencySlot(const uint16_t itemId, const uint8_t proficiencyLevel, const uint8_t perkPosition);
+ void pickReshapeWeaponProficiencyOffer(const uint16_t itemId, const uint8_t offerIndex);
+ void sendWeaponProficiencyReshapeOffers(const uint16_t itemId) const;
std::unordered_map weaponProficiencies;
@@ -1191,6 +1221,7 @@ class Player final : public Creature, public Cylinder, public Bankable {
void checkSpellUnlocksOnAdvance(uint32_t oldLevel, uint32_t newLevel, uint32_t oldMagLevel, uint32_t newMagLevel) const;
void sendScreenshotAndBannerBountyTaskFinished(uint16_t raceId) const;
void sendScreenshotAndBannerWeeklyTaskSpecificFinished(uint16_t raceId) const;
+ void sendScreenshotAndBannerLeaderMonsterKilled(uint16_t raceId, uint32_t charmPoints) const;
void onThink(uint32_t interval) override;
diff --git a/src/creatures/players/proficiencies/proficiencies.cpp b/src/creatures/players/proficiencies/proficiencies.cpp
index f7f732ad0..803ff5d72 100644
--- a/src/creatures/players/proficiencies/proficiencies.cpp
+++ b/src/creatures/players/proficiencies/proficiencies.cpp
@@ -63,7 +63,9 @@ bool Proficiencies::loadFromJson(bool /* reloading */) {
const auto &perkJson = perksArray[perkIdx];
const uint8_t positionSlot = static_cast(perkIdx + 1);
const WeaponProficiencyPerkType_t perkType = static_cast(perkJson.at("Type").get());
- const float perkValue = perkJson.at("Value").get();
+ // Some perks carry no "Value" (e.g. the on-hit elemental proc, Type 32, which uses
+ // ElementId/MissileId/Multiplier/Probability instead). Default to 0 so they still load.
+ const float perkValue = perkJson.value("Value", 0.0f);
ProficiencyPerk perk(positionSlot, perkType, perkValue);
@@ -99,6 +101,19 @@ bool Proficiencies::loadFromJson(bool /* reloading */) {
perk.damageType = perkJson.at("DamageType").get();
}
+ // Type 32 (PROFICIENCY_PERK_ON_HIT_HOMING_MISSILE) fields.
+ if (perkJson.contains("MissileId")) {
+ perk.missileId = perkJson.at("MissileId").get();
+ }
+
+ if (perkJson.contains("Multiplier")) {
+ perk.multiplier = perkJson.value("Multiplier", 0.0f);
+ }
+
+ if (perkJson.contains("Probability")) {
+ perk.probability = perkJson.value("Probability", 0.0f);
+ }
+
levelStruct.proficiencyDataPerks.emplace_back(std::move(perk));
}
diff --git a/src/creatures/players/proficiencies/proficiencies.hpp b/src/creatures/players/proficiencies/proficiencies.hpp
index 4410716f0..3c6c371cc 100644
--- a/src/creatures/players/proficiencies/proficiencies.hpp
+++ b/src/creatures/players/proficiencies/proficiencies.hpp
@@ -38,6 +38,12 @@ struct ProficiencyPerk {
uint8_t bestiaryId = 0;
int32_t damageType = 0;
uint8_t range = 0;
+ // Type 32 (PROFICIENCY_PERK_ON_HIT_HOMING_MISSILE): on weapon hit, roll `probability`; on success fire a
+ // homing missile (`missileId` shoot effect) dealing `multiplier` * playerLevel damage of the combat type
+ // derived from `damageType` (parsed from ElementId).
+ uint16_t missileId = 0;
+ float multiplier = 0.0f;
+ float probability = 0.0f;
// std::string bestiaryName = "";
};
diff --git a/src/creatures/players/proficiencies/proficiencies_definitions.hpp b/src/creatures/players/proficiencies/proficiencies_definitions.hpp
index 7ed5dca50..4258b225d 100644
--- a/src/creatures/players/proficiencies/proficiencies_definitions.hpp
+++ b/src/creatures/players/proficiencies/proficiencies_definitions.hpp
@@ -50,6 +50,90 @@ enum WeaponProficiencyPerkType_t : uint16_t {
PROFICIENCY_PERK_OMEGA_STRIKE_EXTRA_DAMAGE = 29,
PROFICIENCY_PERK_ARMOR_PENETRATION = 30,
PROFICIENCY_PERK_ELEMENTAL_PIERCE = 31,
+ PROFICIENCY_PERK_ON_HIT_HOMING_MISSILE = 32,
+};
+
+// 15.25 (sommerrelease26) SHAPE system: the perkType sent in a modified slot / reshape offer (0xC4/0xBB) is an
+// INDEX into the client's shaping catalogue (1-323), NOT this server enum. UNIVERSAL = the per-vocation spell
+// augment OFFSETS (added to region*50 in rollWeaponProficiencyPerk): {1-5 crit-chance, 11-15 crit-extra,
+// 21-25 base-damage}. GENERAL = the vocation-agnostic 251-323 block (bestiary / leech-on-hit-on-kill /
+// alpha-omega / skill% / armor-pen / elemental-pierce / powerful-foe). See CLIENT_15.25_e2a4a1_PORT.md §8.1.
+inline constexpr WeaponProficiencyPerkType_t WEAPON_PROFICIENCY_UNIVERSAL_SHAPEABLE_PERKS[] = {
+ static_cast(1),
+ static_cast(2),
+ static_cast(3),
+ static_cast(4),
+ static_cast(5),
+ static_cast(11),
+ static_cast(12),
+ static_cast(13),
+ static_cast(14),
+ static_cast(15),
+ static_cast(21),
+ static_cast(22),
+ static_cast(23),
+ static_cast(24),
+ static_cast(25),
+};
+
+// 15.25 (sommerrelease26): the GENERAL (vocation-agnostic) shapeable catalogue indices in the 251-323 block.
+// 251-271 = bestiary damage (250 + bestiaryId, 21 races)
+// 281-288 = mana/life leech, mana/life on-hit, mana/life on-kill, alpha strike, omega strike
+// 291-297 = skill% of auto-attacks (290 + skillSlot 1-7); 301-307 = skill% of spells; 311-317 = skill% of healing (313 omitted)
+// 321-323 = armor penetration, elemental pierce, powerful foe
+inline constexpr WeaponProficiencyPerkType_t WEAPON_PROFICIENCY_GENERAL_SHAPEABLE_PERKS[] = {
+ static_cast(251),
+ static_cast(252),
+ static_cast(253),
+ static_cast(254),
+ static_cast(255),
+ static_cast(256),
+ static_cast(257),
+ static_cast(258),
+ static_cast(259),
+ static_cast(260),
+ static_cast(261),
+ static_cast(262),
+ static_cast(263),
+ static_cast(264),
+ static_cast(265),
+ static_cast(266),
+ static_cast(267),
+ static_cast(268),
+ static_cast(269),
+ static_cast(270),
+ static_cast(271),
+ static_cast(281),
+ static_cast(282),
+ static_cast(283),
+ static_cast(284),
+ static_cast(285),
+ static_cast(286),
+ static_cast(287),
+ static_cast(288),
+ static_cast(291),
+ static_cast(292),
+ static_cast(293),
+ static_cast(294),
+ static_cast(295),
+ static_cast(296),
+ static_cast(297),
+ static_cast(301),
+ static_cast(302),
+ static_cast(303),
+ static_cast(304),
+ static_cast(305),
+ static_cast(306),
+ static_cast(307),
+ static_cast(311),
+ static_cast(312),
+ static_cast(314),
+ static_cast(315),
+ static_cast(316),
+ static_cast(317),
+ static_cast(321),
+ static_cast(322),
+ static_cast(323),
};
enum WeaponProficiencyPerkSkills_t : int8_t {
diff --git a/src/io/functions/iologindata_load_player.cpp b/src/io/functions/iologindata_load_player.cpp
index c7219567f..06b50cecc 100644
--- a/src/io/functions/iologindata_load_player.cpp
+++ b/src/io/functions/iologindata_load_player.cpp
@@ -1280,6 +1280,41 @@ void IOLoginDataLoad::loadPlayerWeaponProficiency(const std::shared_ptr
player->weaponProficiencies[itemId] = std::move(data);
}
+
+ // 15.25 (sommerrelease26) SHAPE: trailing modified-slots section. Old blobs end above, so a failed read
+ // simply means the player has no modified slots. See PORT.md §7.5.
+ uint16_t modifiedWeaponCount;
+ if (stream.read(modifiedWeaponCount)) {
+ for (uint16_t i = 0; i < modifiedWeaponCount; ++i) {
+ uint16_t itemId;
+ if (!stream.read(itemId)) {
+ break;
+ }
+
+ uint8_t slotCount;
+ if (!stream.read(slotCount)) {
+ break;
+ }
+
+ auto &data = player->weaponProficiencies[itemId];
+ for (uint8_t j = 0; j < slotCount; ++j) {
+ uint8_t level;
+ uint8_t pos;
+ uint16_t perkTypeRaw;
+ uint8_t value;
+ if (!stream.read(level) || !stream.read(pos) || !stream.read(perkTypeRaw) || !stream.read(value)) {
+ break;
+ }
+
+ WeaponProficiencyModifiedSlot slot {};
+ slot.proficiencyLevel = level;
+ slot.perkPosition = pos;
+ slot.perkType = static_cast(perkTypeRaw);
+ slot.value = value;
+ data.modifiedSlots.push_back(slot);
+ }
+ }
+ }
}
void IOLoginDataLoad::loadPlayerExivaRestrictions(const std::shared_ptr &player) {
diff --git a/src/io/functions/iologindata_save_player.cpp b/src/io/functions/iologindata_save_player.cpp
index 9879a62a2..ce029f052 100644
--- a/src/io/functions/iologindata_save_player.cpp
+++ b/src/io/functions/iologindata_save_player.cpp
@@ -355,6 +355,29 @@ bool IOLoginDataSave::savePlayerFirst(const std::shared_ptr &player) {
}
}
+ // 15.25 (sommerrelease26) SHAPE: trailing modified-slots section. Appended AFTER the main loop so old blobs
+ // (which lack it) still load — the loader treats a missing section as "no modified slots". See PORT.md §7.5.
+ uint16_t modifiedWeaponCount = 0;
+ for (const auto &entry : player->weaponProficiencies) {
+ if (!entry.second.modifiedSlots.empty()) {
+ ++modifiedWeaponCount;
+ }
+ }
+ propWeaponProficiency.write(modifiedWeaponCount);
+ for (const auto &[itemId, proficiency] : player->weaponProficiencies) {
+ if (proficiency.modifiedSlots.empty()) {
+ continue;
+ }
+ propWeaponProficiency.write(itemId);
+ propWeaponProficiency.write(static_cast(proficiency.modifiedSlots.size()));
+ for (const auto &slot : proficiency.modifiedSlots) {
+ propWeaponProficiency.write(slot.proficiencyLevel);
+ propWeaponProficiency.write(slot.perkPosition);
+ propWeaponProficiency.write(static_cast(slot.perkType));
+ propWeaponProficiency.write(slot.value);
+ }
+ }
+
size_t proficiencySize;
const char* proficiencyData = propWeaponProficiency.getStream(proficiencySize);
diff --git a/src/items/weapons/weapons.cpp b/src/items/weapons/weapons.cpp
index 13837da2e..ee48bd60d 100644
--- a/src/items/weapons/weapons.cpp
+++ b/src/items/weapons/weapons.cpp
@@ -383,6 +383,11 @@ void Weapon::internalUseWeapon(const std::shared_ptr &player, const std:
}
}
+ // Weapon-proficiency Type-32 "Homing Missile": on-hit weapon proc. Fires once per resolved hit against a
+ // creature (auto-attack of any weapon type, including each cleaved target). Rolls the perk's Probability and
+ // on success launches an element-typed homing missile; calls combatChangeHealth directly, so no re-entrancy.
+ player->tryProcWeaponProficiencyHomingMissile(target);
+
onUsedWeapon(player, item, target->getTile());
}
diff --git a/src/lua/functions/core/game/lua_enums.cpp b/src/lua/functions/core/game/lua_enums.cpp
index 6e6f5894b..775096b7f 100644
--- a/src/lua/functions/core/game/lua_enums.cpp
+++ b/src/lua/functions/core/game/lua_enums.cpp
@@ -772,6 +772,20 @@ void LuaEnums::initConstAniEnums(lua_State* L) {
registerEnum(L, CONST_ANI_TERRASTORMARROW);
registerEnum(L, CONST_ANI_FROSTSTORMARROW);
registerEnum(L, CONST_ANI_THUNDERSTORMARROW);
+ registerEnum(L, CONST_ANI_HOMING_PHYSICAL);
+ registerEnum(L, CONST_ANI_HOMING_FIRE);
+ registerEnum(L, CONST_ANI_HOMING_EARTH);
+ registerEnum(L, CONST_ANI_HOMING_ENERGY);
+ registerEnum(L, CONST_ANI_HOMING_ICE);
+ registerEnum(L, CONST_ANI_HOMING_HOLY);
+ registerEnum(L, CONST_ANI_HOMING_DEATH);
+ registerEnum(L, CONST_ANI_HOMING_STELLAR_PHYSICAL);
+ registerEnum(L, CONST_ANI_HOMING_STELLAR_FIRE);
+ registerEnum(L, CONST_ANI_HOMING_STELLAR_EARTH);
+ registerEnum(L, CONST_ANI_HOMING_STELLAR_ENERGY);
+ registerEnum(L, CONST_ANI_HOMING_STELLAR_ICE);
+ registerEnum(L, CONST_ANI_HOMING_STELLAR_HOLY);
+ registerEnum(L, CONST_ANI_HOMING_STELLAR_DEATH);
registerEnum(L, CONST_ANI_WEAPONTYPE);
}
diff --git a/src/lua/functions/creatures/monster/monster_functions.cpp b/src/lua/functions/creatures/monster/monster_functions.cpp
index 1d5bd886a..4e6c50984 100644
--- a/src/lua/functions/creatures/monster/monster_functions.cpp
+++ b/src/lua/functions/creatures/monster/monster_functions.cpp
@@ -91,6 +91,8 @@ void MonsterFunctions::init(lua_State* L) {
Lua::registerMethod(L, "Monster", "walkTo", MonsterFunctions::luaMonsterWalkTo);
+ Lua::registerMethod(L, "Monster", "applyEchoWarden", MonsterFunctions::luaMonsterApplyEchoWarden);
+
CharmFunctions::init(L);
LootFunctions::init(L);
MonsterSpellFunctions::init(L);
@@ -914,3 +916,18 @@ int MonsterFunctions::luaMonsterWalkTo(lua_State* L) {
Lua::pushBoolean(L, true);
return 1;
}
+
+int MonsterFunctions::luaMonsterApplyEchoWarden(lua_State* L) {
+ // monster:applyEchoWarden(hpMult[, atkMult = 1.0])
+ const auto &monster = Lua::getUserdataShared(L, 1);
+ if (!monster) {
+ Lua::reportErrorFunc(Lua::getErrorDesc(LUA_ERROR_MONSTER_NOT_FOUND));
+ Lua::pushBoolean(L, false);
+ return 1;
+ }
+
+ const float hpMult = Lua::getNumber(L, 2, 1.0f);
+ const float atkMult = Lua::getNumber(L, 3, 1.0f);
+ Lua::pushBoolean(L, monster->applyEchoWarden(hpMult, atkMult));
+ return 1;
+}
diff --git a/src/lua/functions/creatures/monster/monster_functions.hpp b/src/lua/functions/creatures/monster/monster_functions.hpp
index 6a001c487..f0577c4a2 100644
--- a/src/lua/functions/creatures/monster/monster_functions.hpp
+++ b/src/lua/functions/creatures/monster/monster_functions.hpp
@@ -96,5 +96,7 @@ class MonsterFunctions {
static int luaMonsterWalkTo(lua_State* L);
+ static int luaMonsterApplyEchoWarden(lua_State* L);
+
friend class CreatureFunctions;
};
diff --git a/src/lua/functions/creatures/player/player_functions.cpp b/src/lua/functions/creatures/player/player_functions.cpp
index 5cf2b1bd6..fb1c0b7b2 100644
--- a/src/lua/functions/creatures/player/player_functions.cpp
+++ b/src/lua/functions/creatures/player/player_functions.cpp
@@ -485,6 +485,7 @@ void PlayerFunctions::init(lua_State* L) {
Lua::registerMethod(L, "Player", "sendBannerType", PlayerFunctions::luaPlayersendBannerType);
Lua::registerMethod(L, "Player", "sendQuestProgress", PlayerFunctions::luaPlayerSendQuestStatusUpdate);
+ Lua::registerMethod(L, "Player", "sendLeaderMonsterKilledBanner", PlayerFunctions::luaPlayerSendLeaderMonsterKilledBanner);
Lua::registerMethod(L, "Player", "sendIconBakragore", PlayerFunctions::luaPlayerSendIconBakragore);
Lua::registerMethod(L, "Player", "removeIconBakragore", PlayerFunctions::luaPlayerRemoveIconBakragore);
@@ -511,6 +512,7 @@ void PlayerFunctions::init(lua_State* L) {
Lua::registerMethod(L, "Player", "applyImbuementScrollToItem", PlayerFunctions::luaPlayerApplyImbuementScrollToItem);
Lua::registerMethod(L, "Player", "onClearAllImbuementsOnEtcher", PlayerFunctions::luaPlayerOnClearAllImbuementsOnEtcher);
Lua::registerMethod(L, "Player", "sendWeaponProficiencyExperience", PlayerFunctions::luaPlayerSendWeaponProficiencyExperience);
+ Lua::registerMethod(L, "Player", "sendBossDifficultySelection", PlayerFunctions::luaPlayerSendBossDifficultySelection);
// OTCR Features
Lua::registerMethod(L, "Player", "getMapShader", PlayerFunctions::luaPlayerGetMapShader);
@@ -5452,6 +5454,19 @@ int PlayerFunctions::luaPlayerSendQuestStatusUpdate(lua_State* L) {
return 0;
}
+int PlayerFunctions::luaPlayerSendLeaderMonsterKilledBanner(lua_State* L) {
+ // player:sendLeaderMonsterKilledBanner(raceId, charmPoints) -> "Echo Warden Killed" banner (shows the creature)
+ const auto &player = Lua::getUserdataShared(L, 1);
+ if (!player) {
+ lua_pushnil(L);
+ return 1;
+ }
+
+ player->sendScreenshotAndBannerLeaderMonsterKilled(Lua::getNumber(L, 2), Lua::getNumber(L, 3));
+ Lua::pushBoolean(L, true);
+ return 1;
+}
+
int PlayerFunctions::luaPlayerSendIconBakragore(lua_State* L) {
// player:sendIconBakragore()
const auto &player = Lua::getUserdataShared(L, 1);
@@ -5782,6 +5797,60 @@ int PlayerFunctions::luaPlayerSendWeaponProficiencyExperience(lua_State* L) {
return 1;
}
+int PlayerFunctions::luaPlayerSendBossDifficultySelection(lua_State* L) {
+ // player:sendBossDifficultySelection(selectedDifficulty, numbers{}, banners{}, redMods{}, greenMods{})
+ const auto &player = Lua::getUserdataShared(L, 1);
+ if (!player) {
+ Lua::reportErrorFunc(Lua::getErrorDesc(LUA_ERROR_PLAYER_NOT_FOUND));
+ Lua::pushBoolean(L, false);
+ return 1;
+ }
+
+ const uint8_t selectedDifficulty = Lua::getNumber(L, 2, 0);
+
+ auto readNumbers = [L](int idx, std::vector &out) {
+ if (!lua_istable(L, idx)) {
+ return;
+ }
+ for (int i = 1;; ++i) {
+ lua_rawgeti(L, idx, i);
+ if (lua_isnil(L, -1)) {
+ lua_pop(L, 1);
+ break;
+ }
+ out.push_back(Lua::getNumber(L, -1, 0));
+ lua_pop(L, 1);
+ }
+ };
+ auto readStrings = [L](int idx, std::vector &out) {
+ if (!lua_istable(L, idx)) {
+ return;
+ }
+ for (int i = 1;; ++i) {
+ lua_rawgeti(L, idx, i);
+ if (lua_isnil(L, -1)) {
+ lua_pop(L, 1);
+ break;
+ }
+ out.push_back(Lua::getString(L, -1, ""));
+ lua_pop(L, 1);
+ }
+ };
+
+ std::vector numbers;
+ std::vector banners;
+ std::vector redMods;
+ std::vector greenMods;
+ readNumbers(3, numbers);
+ readStrings(4, banners);
+ readStrings(5, redMods);
+ readStrings(6, greenMods);
+
+ player->sendBossDifficultySelection(selectedDifficulty, numbers, banners, redMods, greenMods);
+ Lua::pushBoolean(L, true);
+ return 1;
+}
+
int PlayerFunctions::luaPlayerGetMapShader(lua_State* L) {
// player:getMapShader()
const auto &player = Lua::getUserdataShared(L, 1);
diff --git a/src/lua/functions/creatures/player/player_functions.hpp b/src/lua/functions/creatures/player/player_functions.hpp
index c43141be1..b41cc637d 100644
--- a/src/lua/functions/creatures/player/player_functions.hpp
+++ b/src/lua/functions/creatures/player/player_functions.hpp
@@ -459,6 +459,7 @@ class PlayerFunctions {
static int luaPlayersendBannerType(lua_State* L);
static int luaPlayerSendQuestStatusUpdate(lua_State* L);
+ static int luaPlayerSendLeaderMonsterKilledBanner(lua_State* L);
static int luaPlayerSendIconBakragore(lua_State* L);
static int luaPlayerRemoveIconBakragore(lua_State* L);
@@ -485,6 +486,7 @@ class PlayerFunctions {
static int luaPlayerApplyImbuementScrollToItem(lua_State* L);
static int luaPlayerOnClearAllImbuementsOnEtcher(lua_State* L);
static int luaPlayerSendWeaponProficiencyExperience(lua_State* L);
+ static int luaPlayerSendBossDifficultySelection(lua_State* L);
static int luaPlayerGetMapShader(lua_State* L);
static int luaPlayerSetMapShader(lua_State* L);
diff --git a/src/server/network/protocol/protocolgame.cpp b/src/server/network/protocol/protocolgame.cpp
index 9748ad987..963b976c8 100644
--- a/src/server/network/protocol/protocolgame.cpp
+++ b/src/server/network/protocol/protocolgame.cpp
@@ -1438,6 +1438,9 @@ void ProtocolGame::parsePacketFromDispatcher(NetworkMessage &msg, uint8_t recvby
case 0xB3:
parseWeaponProficiency(msg);
break;
+ case 0xC2:
+ parseBossDifficultySelection(msg);
+ break;
case 0xBA:
parseSoulSeals(msg);
break;
@@ -1617,7 +1620,9 @@ void ProtocolGame::GetTileDescription(const std::shared_ptr &tile, Network
int32_t count;
std::shared_ptr- ground = tile->getGround();
- if (ground) {
+ // 15.25+ clients reject a zero client-id appearance on a map tile ("field has more than
+ // one zero id appearance"); skip any tile thing whose ItemType resolves to id 0.
+ if (ground && Item::items[ground->getID()].id != 0) {
AddItem(msg, ground);
count = 1;
} else {
@@ -1627,6 +1632,9 @@ void ProtocolGame::GetTileDescription(const std::shared_ptr &tile, Network
const TileItemVector* items = tile->getItemList();
if (items) {
for (auto it = items->getBeginTopItem(), end = items->getEndTopItem(); it != end; ++it) {
+ if (Item::items[(*it)->getID()].id == 0) {
+ continue; // skip zero client-id appearance (15.25+ map parser rejects it)
+ }
AddItem(msg, *it);
count++;
@@ -1671,6 +1679,9 @@ void ProtocolGame::GetTileDescription(const std::shared_ptr &tile, Network
if (items) {
for (auto it = items->getBeginDownItem(), end = items->getEndDownItem(); it != end; ++it) {
+ if (Item::items[(*it)->getID()].id == 0) {
+ continue; // skip zero client-id appearance (15.25+ map parser rejects it)
+ }
AddItem(msg, *it);
if (++count == 10) {
@@ -2655,72 +2666,78 @@ void ProtocolGame::parseBestiarysendMonsterData(NetworkMessage &msg) {
newmsg.add(mtype->info.bestiaryToUnlock);
newmsg.addByte(mtype->info.bestiaryStars);
- newmsg.addByte(mtype->info.bestiaryOccurrence);
- std::vector lootList = mtype->info.lootItems;
- newmsg.addByte(lootList.size());
- for (const LootBlock &loot : lootList) {
- int8_t difficult = g_iobestiary().calculateDifficult(loot.chance);
- bool shouldAddItem = false;
-
- switch (currentLevel) {
- case 1:
- shouldAddItem = false;
- break;
- case 2:
- if (difficult < 2) {
- shouldAddItem = true;
- }
- break;
- case 3:
- if (difficult < 3) {
+ // 15.25 (sommerrelease26): in the e2a4a1 client everything past the stars byte is gated on
+ // currentLevel != 0, and the occurrence byte is now followed by an extra (unconfirmed) byte.
+ if (currentLevel != 0) {
+ newmsg.addByte(mtype->info.bestiaryOccurrence);
+ newmsg.addByte(0);
+
+ std::vector lootList = mtype->info.lootItems;
+ newmsg.addByte(lootList.size());
+ for (const LootBlock &loot : lootList) {
+ int8_t difficult = g_iobestiary().calculateDifficult(loot.chance);
+ bool shouldAddItem = false;
+
+ switch (currentLevel) {
+ case 1:
+ shouldAddItem = false;
+ break;
+ case 2:
+ if (difficult < 2) {
+ shouldAddItem = true;
+ }
+ break;
+ case 3:
+ if (difficult < 3) {
+ shouldAddItem = true;
+ }
+ break;
+ case 4:
shouldAddItem = true;
- }
- break;
- case 4:
- shouldAddItem = true;
- break;
- }
+ break;
+ }
- newmsg.add(g_configManager().getBoolean(SHOW_LOOTS_IN_BESTIARY) || shouldAddItem == true ? loot.id : 0);
- newmsg.addByte(difficult);
- newmsg.addByte(0); // 1 if special event - 0 if regular loot (?)
- if (g_configManager().getBoolean(SHOW_LOOTS_IN_BESTIARY) || shouldAddItem == true) {
- newmsg.addString(loot.name);
- newmsg.addByte(loot.countmax > 0 ? 0x1 : 0x0);
+ newmsg.add(g_configManager().getBoolean(SHOW_LOOTS_IN_BESTIARY) || shouldAddItem == true ? loot.id : 0);
+ newmsg.addByte(difficult);
+ newmsg.addByte(0); // 1 if special event - 0 if regular loot (?)
+ if (g_configManager().getBoolean(SHOW_LOOTS_IN_BESTIARY) || shouldAddItem == true) {
+ newmsg.addString(loot.name);
+ newmsg.addByte(loot.countmax > 0 ? 0x1 : 0x0);
+ }
}
- }
- if (currentLevel > 1) {
- newmsg.add(mtype->info.bestiaryCharmsPoints);
- int8_t attackmode = 0;
- if (!mtype->info.isHostile) {
- attackmode = 2;
- } else if (mtype->info.targetDistance) {
- attackmode = 1;
+ if (currentLevel > 1) {
+ newmsg.add(mtype->info.bestiaryCharmsPoints);
+ int8_t attackmode = 0;
+ if (!mtype->info.isHostile) {
+ attackmode = 2;
+ } else if (mtype->info.targetDistance) {
+ attackmode = 1;
+ }
+
+ newmsg.addByte(attackmode);
+ newmsg.addByte(0x02);
+ newmsg.add(mtype->info.healthMax);
+ newmsg.add(mtype->info.experience);
+ newmsg.add(mtype->getBaseSpeed());
+ newmsg.add(mtype->info.armor);
+ newmsg.addDouble(mtype->info.mitigation);
}
- newmsg.addByte(attackmode);
- newmsg.addByte(0x02);
- newmsg.add(mtype->info.healthMax);
- newmsg.add(mtype->info.experience);
- newmsg.add(mtype->getBaseSpeed());
- newmsg.add(mtype->info.armor);
- newmsg.addDouble(mtype->info.mitigation);
- }
+ if (currentLevel > 2) {
+ std::map elements = g_iobestiary().getMonsterElements(mtype);
- if (currentLevel > 2) {
- std::map elements = g_iobestiary().getMonsterElements(mtype);
+ newmsg.addByte(elements.size());
+ for (auto &element : elements) {
+ newmsg.addByte(element.first);
+ newmsg.add(element.second);
+ }
- newmsg.addByte(elements.size());
- for (auto &element : elements) {
- newmsg.addByte(element.first);
- newmsg.add(element.second);
+ newmsg.add(1);
+ newmsg.addString(mtype->info.bestiaryLocations);
}
-
- newmsg.add(1);
- newmsg.addString(mtype->info.bestiaryLocations);
- }
+ } // end 15.25 (sommerrelease26) currentLevel != 0 gate
writeToOutputBuffer(newmsg);
}
@@ -3296,10 +3313,14 @@ void ProtocolGame::parseBestiarySendCreatures(NetworkMessage &msg) {
}
}
+ // 15.25 (sommerrelease26): the e2a4a1 client reads a new per-creature byte right after the
+ // race id (before progress). It appears to be a "known/unlocked" flag, so derive it from the
+ // kill progress (0 keeps the silhouette, 1 reveals the entry). The progress block also gained
+ // an extra trailing byte (sent as 0 until its meaning is confirmed).
+ newmsg.addByte(progress > 0 ? 1 : 0);
+ newmsg.addByte(progress);
if (progress > 0) {
- newmsg.addByte(progress);
newmsg.addByte(occurrence);
- } else {
newmsg.addByte(0);
}
@@ -3542,30 +3563,22 @@ void ProtocolGame::parseSendResourceBalance() {
void ProtocolGame::parseSendResourceBalance(NetworkMessage &msg) {
// Portable forge open (client UI): empty 0xED requests the forge item list (0x87).
+ // Do NOT prime m_lastForgeOpenTime here: leaving it stale makes the follow-up
+ // forge-resource request trigger a full re-open (sendOpenForge), which is what
+ // arms the window buttons. This mirrors the working path (use-item / /openforge).
if (!msg.canRead(1)) {
- m_lastForgeOpenTime = OTSYS_TIME();
+ // Empty 0xED (portable-forge button): open the exaltation forge.
sendOpenForge();
return;
}
const auto resourceType = static_cast(msg.getByte());
- switch (resourceType) {
- case RESOURCE_FORGE_DUST:
- case RESOURCE_FORGE_SLIVER:
- case RESOURCE_FORGE_CORES: {
- const uint32_t now = OTSYS_TIME();
- if (now - m_lastForgeOpenTime > 1000) {
- m_lastForgeOpenTime = now;
- sendOpenForge();
- } else {
- sendResourceBalance(resourceType);
- }
- break;
- }
- default:
- sendResourceBalance(resourceType);
- break;
- }
+ // 15.25 (sommerrelease26): answer with the requested resource balance only. The Weapon
+ // Proficiency "Modify" panel polls the forge dust via 0xED (with a resource byte); the old
+ // portable-forge re-open hack popped the exaltation forge and never delivered the dust to
+ // the proficiency panel ("Not enough dust available"). Sending the balance fixes both the
+ // stray forge window and the missing dust.
+ sendResourceBalance(resourceType);
}
void ProtocolGame::sendResourceBalance(Resource_t resourceType) {
@@ -3600,6 +3613,12 @@ void ProtocolGame::sendResourceBalance(Resource_t resourceType) {
value = coreCount;
break;
}
+ case RESOURCE_FORGE_DUST_LIMIT:
+ // 15.25 (sommerrelease26): the client polls 0x49 for the forge dust cap (shown as the
+ // "/limit" next to current dust, and used by the Increase Dust Limit preview). The 0xEE
+ // parser reads this in the default branch as u64, which is what sendResourceBalance emits.
+ value = player->getForgeDustLevel();
+ break;
case RESOURCE_LESSER_GEMS:
value = player->getItemTypeCount(player->getVocation()->getWheelGemId(WheelGemQuality_t::Lesser));
break;
@@ -3846,6 +3865,7 @@ void ProtocolGame::addCreatureIcon(NetworkMessage &msg, const std::shared_ptr(icon.category));
msg.add(icon.count);
+ msg.addByte(0); // 15.25 (sommerrelease26): new trailing per-icon byte
}
}
@@ -6432,32 +6452,10 @@ void ProtocolGame::sendForgingData() {
msg.add(price);
}
- // (conversion) (left column top) Cost to make 1 bottom item - 20
- msg.addByte(static_cast(g_configManager().getNumber(FORGE_COST_ONE_SLIVER)));
- // (conversion) (left column bottom) How many items to make - 3
- msg.addByte(static_cast(g_configManager().getNumber(FORGE_SLIVER_AMOUNT)));
- // (conversion) (middle column top) Cost to make 1 - 50
- msg.addByte(static_cast(g_configManager().getNumber(FORGE_CORE_COST)));
- // (conversion) (right column top) Current stored dust limit minus this number = cost to increase stored dust limit - 75
- msg.addByte(75);
- // (conversion) (right column bottom) Starting stored dust limit
- msg.add(player->getForgeDustLevel());
- // (conversion) (right column bottom) Max stored dust limit - 325
- msg.add(g_configManager().getNumber(FORGE_MAX_DUST));
- // (normal fusion) dust cost - 100
- msg.addByte(static_cast(g_configManager().getNumber(FORGE_FUSION_DUST_COST)));
- // (convergence fusion) dust cost - 130
- msg.addByte(static_cast(g_configManager().getNumber(FORGE_CONVERGENCE_FUSION_DUST_COST)));
- // (normal transfer) dust cost - 100
- msg.addByte(static_cast(g_configManager().getNumber(FORGE_TRANSFER_DUST_COST)));
- // (convergence transfer) dust cost - 160
- msg.addByte(static_cast(g_configManager().getNumber(FORGE_CONVERGENCE_TRANSFER_DUST_COST)));
- // (fusion) Base success rate - 50
- msg.addByte(static_cast(g_configManager().getNumber(FORGE_BASE_SUCCESS_RATE)));
- // (fusion) Bonus success rate - 15
- msg.addByte(static_cast(g_configManager().getNumber(FORGE_BONUS_SUCCESS_RATE)));
- // (fusion) Tier loss chance after reduction - 50
- msg.addByte(static_cast(g_configManager().getNumber(FORGE_TIER_LOSS_REDUCTION)));
+ // 15.25 (sommerrelease26): the 13.30 forge-config block (slot costs, dust limits,
+ // success rates) was dropped by the new client, which reads a single trailing byte
+ // here instead. Send 0 to keep alignment (forge config UI may show defaults).
+ msg.addByte(0);
// Update player resources
parseSendResourceBalance();
@@ -6697,7 +6695,7 @@ void ProtocolGame::sendOpenForge() {
msg.addByte(convergenceTransferCount);
msg.setBufferPosition(dustLevelPosition);
- msg.add(player->getForgeDustLevel()); // Player dust limit
+ msg.addByte(static_cast(std::min(player->getForgeDustLevel(), 0xFF))); // Player dust limit
writeToOutputBuffer(msg);
// Update forging informations
sendForgingData();
@@ -7428,6 +7426,7 @@ void ProtocolGame::sendCancelTarget() {
NetworkMessage msg;
msg.addByte(0xA3);
msg.add(0x00);
+ msg.add(0x00); // 15.25 (sommerrelease26): client reads a 2nd u32 here now
writeToOutputBuffer(msg);
}
@@ -7747,6 +7746,12 @@ void ProtocolGame::sendAddTileItem(const Position &pos, uint32_t stackpos, const
return;
}
+ // Don't send a zero client-id appearance on a tile — the 15.25+ client rejects it
+ // ("field has more than one zero id appearance"); it can't render anyway.
+ if (!item || Item::items[item->getID()].id == 0) {
+ return;
+ }
+
NetworkMessage msg;
msg.addByte(0x6A);
msg.addPosition(pos);
@@ -7760,6 +7765,11 @@ void ProtocolGame::sendUpdateTileItem(const Position &pos, uint32_t stackpos, co
return;
}
+ // Don't send a zero client-id appearance on a tile — the 15.25+ client rejects it.
+ if (!item || Item::items[item->getID()].id == 0) {
+ return;
+ }
+
NetworkMessage msg;
msg.addByte(0x6B);
msg.addPosition(pos);
@@ -8928,6 +8938,9 @@ void ProtocolGame::AddCreature(NetworkMessage &msg, const std::shared_ptr(creature->getStepSpeed());
addCreatureIcon(msg, creature);
+ // 15.25 (sommerrelease26): the second creature-icon list (count2) is inline here in
+ // AddCreature only — sendCreatureIcon (0x8B) sends each list as its own typed message.
+ msg.addByte(0);
msg.addByte(player->getSkullClient(creature));
msg.addByte(player->getPartyShield(otherPlayer));
@@ -10851,6 +10864,22 @@ void ProtocolGame::sendScreenshotAndBannerWeeklyTaskSpecificFinished(uint16_t ra
writeToOutputBuffer(msg);
}
+void ProtocolGame::sendScreenshotAndBannerLeaderMonsterKilled(uint16_t raceId, uint32_t charmPoints) {
+ if (oldProtocol) {
+ return;
+ }
+
+ // Client GameEventTypeLeaderMonsterKilled (banner subtype 0x0e): creature race id (uint16) + charm
+ // points (uint32). The client resolves the race id to the creature and renders the
+ // "Echo Warden Killed / You have received N Charm Points" banner with the creature shown.
+ NetworkMessage msg;
+ msg.addByte(0x75);
+ msg.addByte(SCREENSHOT_AND_BANNER_TYPE_LEADER_MONSTER);
+ msg.add(raceId);
+ msg.add(charmPoints);
+ writeToOutputBuffer(msg);
+}
+
void ProtocolGame::sendOutfitWindowCustomOTCR(NetworkMessage &msg) {
if (!isOTCR) {
return;
@@ -11309,6 +11338,64 @@ void ProtocolGame::parseWeaponProficiency(NetworkMessage &msg) {
}
player->applyEquippedWeaponProficiency(itemId);
+
+ } else if (type == WEAPON_PROFICIENCY_MODIFY_SLOT) {
+ const uint16_t itemId = msg.get();
+ const uint8_t proficiencyLevel = msg.getByte();
+ const uint8_t perkPosition = msg.getByte();
+ while (msg.canRead(1)) { // trailing flag byte (observed 0x49) — purpose unknown; drain it.
+ msg.getByte();
+ }
+ player->modifyWeaponProficiencySlot(itemId, proficiencyLevel, perkPosition);
+
+ } else if (type == WEAPON_PROFICIENCY_REFINE_SLOT) {
+ const uint16_t itemId = msg.get();
+ const uint8_t proficiencyLevel = msg.getByte();
+ const uint8_t perkPosition = msg.getByte();
+ while (msg.canRead(1)) { // trailing flag byte (observed 0xCF) — drain it.
+ msg.getByte();
+ }
+ player->refineWeaponProficiencySlot(itemId, proficiencyLevel, perkPosition);
+
+ } else if (type == WEAPON_PROFICIENCY_MAXIMISE_SLOT) {
+ const uint16_t itemId = msg.get();
+ const uint8_t proficiencyLevel = msg.getByte();
+ const uint8_t perkPosition = msg.getByte();
+ while (msg.canRead(1)) { // trailing flag byte (observed 0x90) — drain it.
+ msg.getByte();
+ }
+ player->maximiseWeaponProficiencySlot(itemId, proficiencyLevel, perkPosition);
+
+ } else if (type == WEAPON_PROFICIENCY_RESHAPE_SLOT) {
+ const uint16_t itemId = msg.get();
+ const uint8_t proficiencyLevel = msg.getByte();
+ const uint8_t perkPosition = msg.getByte();
+ while (msg.canRead(1)) { // trailing flag byte (observed 0x46 / 0x60) — drain it.
+ msg.getByte();
+ }
+ player->reshapeWeaponProficiencySlot(itemId, proficiencyLevel, perkPosition);
+
+ } else if (type == WEAPON_PROFICIENCY_PICK_OFFER) {
+ // Payload CONFIRMED live (D8 0C 00 00 00 00): itemId(u16) + slot level + slot pos + chosen offer index + flag.
+ const uint16_t itemId = msg.canRead(2) ? msg.get() : 0;
+ const uint8_t proficiencyLevel = msg.canRead(1) ? msg.getByte() : 0; // echoed slot (unused)
+ const uint8_t perkPosition = msg.canRead(1) ? msg.getByte() : 0;
+ const uint8_t offerIndex = msg.canRead(1) ? msg.getByte() : 0;
+ (void)proficiencyLevel;
+ (void)perkPosition;
+ while (msg.canRead(1)) {
+ msg.getByte();
+ }
+ player->pickReshapeWeaponProficiencyOffer(itemId, offerIndex);
+
+ } else if (type == WEAPON_PROFICIENCY_CLEAR_SLOT) {
+ const uint16_t itemId = msg.get();
+ const uint8_t proficiencyLevel = msg.getByte();
+ const uint8_t perkPosition = msg.getByte();
+ while (msg.canRead(1)) { // trailing flag byte (observed 0xF2) — drain it.
+ msg.getByte();
+ }
+ player->clearWeaponProficiencySlot(itemId, proficiencyLevel, perkPosition);
}
}
@@ -11343,10 +11430,128 @@ void ProtocolGame::sendWeaponProficiencyInfo(const uint16_t itemId) {
msg.addByte(perk.proficiencyLevel - 1);
msg.addByte(perk.perkPosition - 1);
}
+ // 15.25 (sommerrelease26) SHAPE: a SECOND list right after the active perks carries the modified slots:
+ // [level(0-based), position(0-based), perkType:u16 (client catalogue index), value]. The client renders
+ // the rolled perk from the catalogue index. See PORT.md §7.4.
+ msg.addByte(static_cast(proficiency.modifiedSlots.size()));
+ for (const auto &slot : proficiency.modifiedSlots) {
+ msg.addByte(slot.proficiencyLevel - 1);
+ msg.addByte(slot.perkPosition - 1);
+ msg.add(static_cast(slot.perkType));
+ msg.addByte(slot.value);
+ }
writeToOutputBuffer(msg);
}
}
+void ProtocolGame::sendWeaponProficiencyReshapeOffers(const uint16_t itemId) {
+ if (oldProtocol || !player) {
+ return;
+ }
+
+ auto it = player->weaponProficiencies.find(itemId);
+ if (it == player->weaponProficiencies.end()) {
+ return;
+ }
+ const auto &offers = it->second.pendingReshapeOffers;
+
+ // 15.25 (sommerrelease26): Reshape offers = opcode 0xBB (Ghidra-confirmed FUN_140601bf0). NOT 0xC7 (that is
+ // CyclopediaCurrentHouseData). Wire: u16 itemId · byte curLevel · byte curPos · byte count · count×{u16 perkType, byte value}.
+ // Each offer uses the same perkType+rank encoding as a 0xC4 modified slot (no name string). See PORT.md §7.4.
+ uint8_t rank = 1;
+ for (const auto &slot : it->second.modifiedSlots) {
+ if (slot.proficiencyLevel == it->second.pendingReshapeLevel && slot.perkPosition == it->second.pendingReshapePosition) {
+ rank = std::max(1, slot.value);
+ break;
+ }
+ }
+
+ NetworkMessage msg;
+ msg.addByte(0xBB);
+ msg.add(itemId);
+ msg.addByte(it->second.pendingReshapeLevel - 1); // current slot (0-based on the wire, like active perks)
+ msg.addByte(it->second.pendingReshapePosition - 1);
+ msg.addByte(static_cast(offers.size()));
+ for (const auto perkType : offers) {
+ msg.add(static_cast(perkType));
+ msg.addByte(rank);
+ }
+
+ writeToOutputBuffer(msg);
+}
+
+void ProtocolGame::sendBossDifficultySelection(uint8_t selectedDifficulty, const std::vector &numbers, const std::vector &banners, const std::vector