Compare commits

...

15 Commits

Author SHA1 Message Date
Docker Build 7c57abd69f Paragon: weapon-class subclass bypass for proc talents (Maelstrom Weapon any weapon)
Cross-class wildcard now also relaxes the EquippedItemSubClassMask
gate on weapon-class proc talents so e.g. Maelstrom Weapon procs
from any weapon a Paragon has equipped, not just the talent's stock
melee subset (axe / mace / staff / fist / dagger / 2H sword/axe/mace).

Three independent gates run in the proc chain; all three needed the
bypass for the proc to actually fire:

- Player::HasItemFitToSpellRequirements -- talent-attach check at
  item-equip / login time. Without this the passive talent aura
  never even applies for a Paragon wielding a non-stock weapon.
- Player::CheckAttackFitToAuraRequirement -- per-swing match the
  proc engine uses to decide whether attackType + weapon is
  compatible with the aura's EquippedItemClass / SubClassMask.
- Aura::IsProcTriggeredOnEvent (SpellAuras.cpp) -- per-event proc
  evaluator that calls Item::IsFitToSpellRequirements again,
  independently of the previous two. Was the proc-killing gate
  before this commit: talent attached, swing matched, but this
  evaluator returned 0 charges for any weapon outside the stock
  subclass mask, so no stack was ever applied.

All three bypasses are gated on:
- IsParagonWildcardCaller(this) (player class 12 + config flag
  Paragon.WildcardFamilyMatching = 1).
- spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON. ARMOR-class
  gates (shield) are deliberately left alone -- shield-required
  talents (Shield Specialization, Shield Block, etc.) still need
  an actual shield equipped.

Each bypass also requires *some* weapon to be in the relevant slot
(MAINHAND / OFFHAND / RANGED). Unarmed Paragons do not auto-activate
every weapon-gated talent in the game.

Net effect for Paragon characters with Maelstrom Weapon talented:
proc fires from any melee weapon -- 1H sword / polearm / spear /
fist / dagger / staff / 2H weapons / axes / maces. Stock Shamans
and every other non-Paragon class are unchanged.

Caveat (not addressed by this commit): the talent's stock ProcFlags
(0xC00014) only fire on melee swings + melee abilities. Ranged
auto-attacks (bow / gun / crossbow / wand) fire
PROC_FLAG_DONE_RANGED_AUTO_ATTACK (0x40), which the proc engine
never matches against this talent, so the new weapon-subclass
bypass is moot for those weapons. Adding ranged-auto-attack support
would require a Paragon-only ProcFlags expansion at the proc
engine layer; deferred until requested.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:19:42 -04:00
Docker Build e649402163 Paragon: cross-class talents + Warrior stance bypass + Mirror Image spellbook draw
Server-side cross-class wildcard pass for several talents that were
previously locked to a single SpellFamilyName, plus a server+client
Warrior stance bypass and a Paragon-aware Mirror Image rebuild that
mimics the owner's spellbook instead of stock Frostbolt/Fire Blast.

Talent expansions (Paragon owners only; stock classes unchanged):
- Cold Snap (11958): resets cooldown of any Frost-school spell.
- Nature's Swiftness (17116, 16188) + Predator's Swiftness (69369):
  instant-cast on any Nature-school spell.
- Vampiric Embrace (15286): leech-heals from any single-target
  Shadow-school spell.
- Fingers of Frost (44543/44545) + Frostbite (11071/12496/12497):
  proc from any Frost-school chill effect (DK Howling Blast / Icy
  Touch / Chains of Ice, Hunter Frost Trap, Shaman Frost Shock,
  cross-class chill auras via SPELL_AURA_MOD_DECREASE_SPEED).
- Maelstrom Weapon (53817): now also affects Mage Fireball (133),
  Frostbolt (116), and Arcane Blast (30451) at every rank, both
  for cast-time/cost spellmod and for stack consumption.

Warrior stance bypass:
- SpellInfo::CheckShapeshift returns SPELL_CAST_OK whenever a
  Paragon caster hits any Stances!=0 spell (no SpellFamilyName
  gate). Stock classes still see the regular form rules.
- Client side: patch-enUS-4.MPQ now zeroes Stances on every
  SPELLFAMILY_WARRIOR Spell.dbc row (105 spells) so the engine's
  pre-cast "Must be in Battle/Defensive/Berserker Stance" check
  no longer eats CMSG_CAST_SPELL packets for Paragons. Server
  bypass enforces the actual decision; stock Warriors still
  error mid-cast if they actually click while out of stance.
- patch-enUS-5.MPQ Lua tooltip post-processor recolors and
  appends "(Paragon: bypassed)" to "Requires *Stance*" lines on
  Warrior abilities, plus Paragon notes on Maelstrom Weapon and
  Mirror Image tooltips. Action-bar UseAction wrapper routes
  stance-gated Warrior spell clicks through CastSpellByName so
  the stance-zero DBC + server bypass actually run.

Mirror Image:
- npc_pet_mage_mirror_image rebuilds its spell list from the
  Paragon owner's spellbook on InitializeAI AND JustEngagedWith
  (the second pass + events.Reset clears any stale events the
  CasterAI base scheduler may have queued from stock 59637 /
  59638 entries before the rebuild ran).
- Curated filter keeps single-target damaging spells (instant,
  cast-time, or channeled) with a base cooldown <=10s, with the
  "damaging" definition expanded to include
  SPELL_EFFECT_TRIGGER_MISSILE and
  SPELL_AURA_PERIODIC_TRIGGER_SPELL so Arcane Missiles
  qualifies. Rejects passives, AoE, melee/ranged weapon strikes,
  item/reagent/stance/equip-gated, and lower spell ranks.
- UpdateAI picks a random spell from the curated list per cast
  and reschedules the next pick by the actually-cast spell's
  cast/channel duration + 750ms breather, so a 5s Arcane
  Missiles channel waits its full duration before re-rolling
  rather than visually looping across four images.

Helpers:
- Unit::IsParagonWildcardCaller / Unit::ParagonFamilyMatches
  used by Spell.cpp, SpellInfo.cpp, Player.cpp,
  SpellAuraEffects.cpp, and the spell scripts.
- SpellInfo::CheckShapeshift signature gains an optional caster
  pointer; all call sites updated.

SQL migrations under modules/mod-paragon/data/sql/db-world/updates/:
- 2026_05_11_01.sql  Vampiric Embrace spell_proc relax + script
                     gate (CheckProc enforces stock for non-Paragon).
- 2026_05_11_02.sql  Maelstrom Weapon spell_proc relax (initial,
                     superseded by _04 below for stack-consumption fix).
- 2026_05_11_03.sql  Fingers of Frost / Frostbite spell_proc relax
                     and spell_script_names binding.
- 2026_05_11_04.sql  Maelstrom Weapon spell_proc fixup: restore
                     SpellPhaseMask=1 (CAST) and AttributesMask=8
                     (REQ_SPELLMOD); previous _02 set 8/0 which
                     silently dropped every proc event.

Diagnostics from this debugging session demoted from LOG_INFO to
LOG_DEBUG (silent at default info level) so production logs stay
quiet but the probes remain available for reproducing future
regressions: pet_mage.cpp MirrorImage probe/kept/rebuild/init/
engage/cast lines and SpellInfo.cpp CheckShapeshift bypass line.

CLIENT-PATCHES.md updated to document the new Warrior stance DBC
patcher (_patch_spell_dbc_stances.py), the spell-tooltip post-
processor and stance UseAction wrapper in patch-enUS-5.MPQ, and
the Mirror Image / Maelstrom Weapon Paragon notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 14:54:05 -04:00
Docker Build a1c9172beb Paragon cross-class family wildcard + Predatory Strikes proc
- CONFIG_PARAGON_WILDCARD_FAMILY + Paragon.WildcardFamilyMatching (reloadable)
- SpellInfo::IsAffected / IsAffectedBySpellMod(listenerOwner) for Paragon proc/mod wildcard
- SpellMgr::CanSpellTriggerProcOnEvent(procOwner) + Aura::IsProcTriggeredOnEvent wiring
- Player::IsAffectedBySpellmod passes listener for SpellMod wildcard
- ParagonFamilyMatches helper + Nourish / Shred-Maul bleed gate usage in Unit.cpp
- Spell::prepare: Paragon consumes 69369 for Nature spells <10s base cast (non-channeled)
- spell_paragon_predatory_strikes + SQL 2026_05_11_00.sql (spell_proc + spell_script_names)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 01:25:18 -04:00
Docker Build b408c8a95d Add script to start auth and world servers detached from SSH
Runs authserver then worldserver from /root/azeroth-server/bin by default,
kills existing instances, and uses nohup/disown so processes survive logout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 00:15:36 -05:00
Docker Build f88a303327 feat(launcher): Linux play wrapper, patch UX, Gitea sync cleanup
- Play on Linux: use launch.linux_wrapper (wine) or linux_steam_uri; chmod .exe after install
- Windows: retry EBUSY on MPQ replace; download to .new before rename
- After successful sync: remove .bak-* backups; realmlist only Data/enUS (ignore enGB)
- Gitea/distro merge: skip Fractured-Launcher* from GitHub assets (CI default-branch build wins)
- Omit blockmap and builder-debug from staged artifacts and Gitea uploads; upload script validates before clearing attachments
- README and launcher version 1.0.12

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 23:20:22 -05:00
Docker Build 8ad6a2aca3 Paragon: cascade guard for class skill lines + panel catalog backfill
The skill-line cascade in Player::learnSkillRewardedSpells re-fires from
_LoadSkills (every login), UpdateSkillsForLevel (every level-up),
UpdateSkillPro (every weapon-skill tick on a training dummy), and
SetSkill (first time a class skill is granted). Each pass re-grants
every SkillLineAbility-tagged class ability on the matching skill line,
which leaks Blood Presence / Death Coil / Death Grip / etc. back into
the spellbook within seconds even after the player intentionally
refunded them via the Character Advancement panel.

Path B fix: a 5-line guard at the top of learnSkillRewardedSpells skips
the cascade for class-category skill lines on CLASS_PARAGON characters.
mod-paragon already calls Player::learnSpell directly for the abilities
the player actually purchased (and their attached passives), so the
panel becomes the sole authority over class abilities. Profession,
weapon, language, and racial cascades stay enabled so recipe auto-learn,
weapon proficiencies, and racial perks still work.

Side effect: passives that previously rode along on the cascade
(Forceful Deflection on Blood Strike, Runic Focus on Icy Touch) must be
force-attached the same way Blood Plague / Frost Fever already are.
Extend kAttached and kFixup in Paragon_Essence.cpp to do that; existing
characters self-heal on next login.

Backfill paragon_spell_ae_cost for 42 spells newly exposed by the panel
after the ClassMask=0 filter was removed from the client catalog
generator (Lava Burst, Hex, Evocation, Kill Shot, Path of Frost,
Horn of Winter, Rune Strike, Raise Ally, Dark Command, etc.). Migration
is INSERT IGNORE so any per-spell tuning on existing rows is preserved.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 23:53:13 -04:00
Docker Build 36ac3dbd1d fix(launcher): force .MPQ extension uppercase on disk for WoW compatibility
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 22:09:01 -05:00
Docker Build 24d1ae71d9 fix(launcher): install release MPQs under Data/enUS (not Data root)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 22:06:48 -05:00
Docker Build 9cef99f0ff feat(launcher): sync release assets from manifest or attachment list (no fixed exe name)
- default files []: resolve sync list from patch-manifest keys, else discover
  release attachments (exclude launcher artifacts).
- Explicit files[] still overrides; strip deprecated Wow-patched.exe on merge.
- listReleaseAttachmentNames + fetchGiteaReleaseRecord helpers.
- Version 1.0.7; README config docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 22:04:48 -05:00
Docker Build f409ffad12 fix(launcher): Gitea http URL; Wine Z: path + Wow.exe case check
- baked-gitea-channel: http:// for brassnet mirror.
- win-game-dir: map Unix /home/... to Z:\ under win32 (Wine folder picker).
- resolveGameDir + saveGameDir + patch paths use it; Wow.exe resolved case-insensitively.
- Version 1.0.6; README checklist for Wine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 21:54:04 -05:00
Docker Build c1f7eaa153 fix(launcher): clearer fetch errors for Gitea TLS/DNS (fetch failed)
- fetchOrThrow wraps global fetch with TLS/DNS/refused hints + URL (sanitized).
- Use in gitea-release, github paths; fetchToFile already benefits.
- README checklist for sync Wow.exe fetch failed; version 1.0.5.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 21:46:48 -05:00
Docker Build b455db0db8 fix(launcher): drop patch-Z.MPQ from default files and migrate old configs
- default-launcher.json files: only Wow-patched.exe from release.
- config-store: strip deprecated patch-Z.MPQ from merged files; rewrite
  launcher.json on load if user still had that entry.
- Docs/scripts examples updated; version 1.0.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 21:36:50 -05:00
Docker Build 1fb284cb5c docs(launcher): clarify userData path wording for Linux config
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 21:33:07 -05:00
Docker Build ebd8d81924 fix(launcher): Linux/macOS packaged config in userData (AppImage EROFS)
AppImage mounts read-only at /tmp/.mount_*; writing launcher.json beside
execPath failed. Use app.getPath('userData') for linux/darwin when packaged.
Bump version to 1.0.3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 21:32:43 -05:00
Docker Build 362084b829 ci(gitea-sync): validate workflow_dispatch tag; reject release title as ref
- Trim input; fail fast if tag contains whitespace (common mistake: pasting
  release title instead of git tag).
- Multiline GITHUB_OUTPUT for tag value safety.
- README checklist + input description clarify tag vs title.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 21:21:45 -05:00
48 changed files with 2065 additions and 159 deletions
+13 -2
View File
@@ -74,8 +74,6 @@ jobs:
if (Test-Path tools/fractured-launcher-electron/dist/latest.yml) {
Copy-Item tools/fractured-launcher-electron/dist/latest.yml launcher-publish/
}
Get-ChildItem tools/fractured-launcher-electron/dist/*.blockmap -ErrorAction SilentlyContinue |
Copy-Item -Destination launcher-publish/
- uses: actions/upload-artifact@v4
with:
@@ -87,6 +85,13 @@ jobs:
if: github.repository == 'Dawnforger/Fractured'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
sparse-checkout: |
tools/fractured-launcher-electron/scripts
sparse-checkout-cone-mode: true
- uses: actions/download-artifact@v4
with:
name: electron-dist
@@ -97,6 +102,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
. tools/fractured-launcher-electron/scripts/release-sync-filters.sh
TAG="${{ needs.meta.outputs.tag }}"
mkdir -p combined
mkdir -p /tmp/from-main
@@ -104,6 +110,11 @@ jobs:
shopt -s nullglob
for f in /tmp/from-main/*; do
if [ -f "$f" ]; then
bn=$(basename "$f")
if should_skip_merge_from_github "$bn"; then
echo "Skipping GitHub release asset (CI launcher or excluded): $bn"
continue
fi
cp -f "$f" combined/
fi
done
+2 -3
View File
@@ -49,7 +49,6 @@ jobs:
path: |
tools/fractured-launcher-electron/dist/*.exe
tools/fractured-launcher-electron/dist/latest.yml
tools/fractured-launcher-electron/dist/*.blockmap
electron-launcher-linux:
runs-on: ubuntu-latest
@@ -77,5 +76,5 @@ jobs:
if-no-files-found: warn
path: |
tools/fractured-launcher-electron/dist/*.AppImage
tools/fractured-launcher-electron/dist/*.yml
tools/fractured-launcher-electron/dist/*.blockmap
tools/fractured-launcher-electron/dist/latest.yml
tools/fractured-launcher-electron/dist/latest-linux.yml
+31 -7
View File
@@ -29,7 +29,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag on this GitHub repo (must exist; e.g. v1.0.0)'
description: 'Git tag only (e.g. v0.7.11-paragon-foo). NOT the release title — open the release and copy the tag next to the title.'
required: true
type: string
@@ -51,11 +51,28 @@ jobs:
id: t
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
RAW="${{ github.event.inputs.tag }}"
else
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
RAW="${{ github.event.release.tag_name }}"
fi
TAG="$(printf '%s' "$RAW" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
if [ -z "$TAG" ]; then
echo '::error::Tag input is empty. Paste the git tag (e.g. v0.7.11-…).'
exit 1
fi
if printf '%s' "$TAG" | grep -q '[[:space:]]'; then
echo '::error::Tag contains whitespace — that is usually the **release title**, not the tag. On GitHub → Releases → open the release → copy the **tag** (short ref like v0.7.11-…), not the long title line.'
exit 1
fi
fi
{
echo "tag<<__TAG_EOF__"
echo "$TAG"
echo "__TAG_EOF__"
} >> "$GITHUB_OUTPUT"
build-electron:
needs: meta
@@ -87,6 +104,7 @@ jobs:
working-directory: tools/fractured-launcher-electron
run: |
npm ci
node -p "'Launcher package.json version: ' + require('./package.json').version"
npm run pack:win
- name: Stage launcher files for upload
@@ -97,8 +115,6 @@ jobs:
if (Test-Path tools/fractured-launcher-electron/dist/latest.yml) {
Copy-Item tools/fractured-launcher-electron/dist/latest.yml launcher-publish/
}
Get-ChildItem tools/fractured-launcher-electron/dist/*.blockmap -ErrorAction SilentlyContinue |
Copy-Item -Destination launcher-publish/
- uses: actions/upload-artifact@v4
with:
@@ -133,6 +149,7 @@ jobs:
working-directory: tools/fractured-launcher-electron
run: |
npm ci
node -p "'Launcher package.json version: ' + require('./package.json').version"
npm run pack:linux
- name: Stage Linux launcher for upload
@@ -142,8 +159,9 @@ jobs:
mkdir -p launcher-linux-publish
shopt -s nullglob
cp -f tools/fractured-launcher-electron/dist/*.AppImage launcher-linux-publish/ 2>/dev/null || true
cp -f tools/fractured-launcher-electron/dist/*.yml launcher-linux-publish/ 2>/dev/null || true
cp -f tools/fractured-launcher-electron/dist/*.blockmap launcher-linux-publish/ 2>/dev/null || true
for f in tools/fractured-launcher-electron/dist/latest.yml tools/fractured-launcher-electron/dist/latest-linux.yml; do
if [ -f "$f" ]; then cp -f "$f" launcher-linux-publish/; fi
done
ls -la launcher-linux-publish/
if ! compgen -G "launcher-linux-publish/*.AppImage" > /dev/null; then
echo "No AppImage under dist/ — electron-builder linux target failed" >&2
@@ -189,6 +207,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
. tools/fractured-launcher-electron/scripts/release-sync-filters.sh
TAG="${{ needs.meta.outputs.tag }}"
mkdir -p combined
mkdir -p /tmp/from-main
@@ -196,6 +215,11 @@ jobs:
shopt -s nullglob
for f in /tmp/from-main/*; do
if [ -f "$f" ]; then
bn=$(basename "$f")
if should_skip_merge_from_github "$bn"; then
echo "Skipping GitHub release asset (CI launcher or excluded): $bn"
continue
fi
cp -f "$f" combined/
fi
done
@@ -21,7 +21,7 @@ when a release is published here (workflow **Sync release to Fractured-Distro**)
| Artifact | Size | Purpose |
|---|---|---|
| `patch-enUS-4.MPQ` | ~5 MB | DBC + GlueXML bake. Adds `CLASS_PARAGON` (id 12), the character-create slot, glue strings, game-table DBCs, and a patched `Spell.dbc`: **(1)** `RuneCostID` zeroed on every rune-cost spell so nonDeath Knight clients still send DK casts (rune costs are shown via `RuneFrame.lua`); **(2)** `Reagent[]` / `ReagentCount[]` zeroed on every spell whose `SpellFamilyName` is non-zero (all class abilities), while profession crafts (`SpellFamilyName == 0`) keep their materials. Both edits mirror server load-time corrections so client preflight and server validation stay aligned. Required for character creation as Paragon to even show up. |
| `patch-enUS-5.MPQ` | ~57 KB | FrameXML overrides. Replaces stock `PlayerFrame.lua` / `RuneFrame.lua` / `ComboFrame.lua` / `UnitFrame.lua` / `SpellBookFrame.lua` + `SpellBookFrame.xml` with Paragon-aware versions: rune simulator, combo-point simulator, server-authoritative resource sync over the `PARAA` addon channel, action-button usability + click guards, an expanded spellbook (higher `MAX_SPELLS`, 24 skill-line tabs instead of stock 8) so all-class spells render, Paragon stat tooltips on the character sheet (including filtering duplicate “attack power from strength” lines so the paper doll matches server AP), a tooltip post-processor that appends ", Paragon" to the "Classes:" line on class-restricted gear / glyphs (the server bypasses `AllowableClass` for class 12, but the engine paints the line red and omits Paragon — the Lua hook recolors it green and adds the name so the player can tell it's wearable), and **PetFrame** re-anchored so the **pet unit frame sits below the rune row** for Paragon (stock layout had runes overlapping the pet portrait). The paper-doll **ammo slot** follows stock visibility rules (shown for hunters / ranged weapons; hidden when `UnitHasRelicSlot` applies). |
| `patch-enUS-5.MPQ` | ~64 KB | FrameXML overrides. Replaces stock `PlayerFrame.lua` / `RuneFrame.lua` / `ComboFrame.lua` / `UnitFrame.lua` / `SpellBookFrame.lua` + `SpellBookFrame.xml` with Paragon-aware versions: rune simulator, combo-point simulator, server-authoritative resource sync over the `PARAA` addon channel, action-button usability + click guards, an expanded spellbook (higher `MAX_SPELLS`, 24 skill-line tabs instead of stock 8) so all-class spells render, Paragon stat tooltips on the character sheet (including filtering duplicate “attack power from strength” lines so the paper doll matches server AP), a tooltip post-processor that appends ", Paragon" to the "Classes:" line on class-restricted gear / glyphs (the server bypasses `AllowableClass` for class 12, but the engine paints the line red and omits Paragon — the Lua hook recolors it green and adds the name so the player can tell it's wearable), a **spell-tooltip post-processor** that (1) recolors and appends "(Paragon: bypassed)" to "Requires *Stance*" lines on Warrior abilities (server-side `SpellInfo::CheckShapeshift` skips stance enforcement for Paragons on `SPELLFAMILY_WARRIOR` spells, but the client still renders the requirement from the stock `Stances` DBC field which we deliberately leave unzeroed so stock Warriors keep enforcement), (2) appends a Paragon line to **Maelstrom Weapon** (53817) noting that Fireball / Frostbolt / Arcane Blast also benefit, and (3) appends a Paragon line to **Mirror Image** (55342) noting that the images cast random damage spells with a cast time from the caster's spellbook instead of Frostbolt — all three patches gate on `UnitClass("player") == "PARAGON"` so stock-class tooltips are byte-identical to vanilla, and **PetFrame** re-anchored so the **pet unit frame sits below the rune row** for Paragon (stock layout had runes overlapping the pet portrait). A **Warrior stance click bypass** wraps `UseAction` so that for Paragon characters, clicking an action slot bound to a stance-gated Warrior ability (Whirlwind, Charge, Pummel, Shield Slam, Hamstring, Overpower, Shield Bash, Shield Block, Disarm, Revenge, Spell Reflection, Recklessness, Bladestorm, Shockwave, Concussion Blow, Last Stand, Sweeping Strikes, Mocking Blow, Heroic Fury, Slam, Devastate, Intercept) routes through `CastSpellByName(name)` instead of the engine's stance-gated `UseAction` path; the engine's stance pre-check inside `UseAction` would otherwise drop the cast packet client-side and our server-side `CheckShapeshift` bypass would never get to run. Stock classes never enter the bypass branch. The paper-doll **ammo slot** follows stock visibility rules (shown for hunters / ranged weapons; hidden when `UnitHasRelicSlot` applies). |
| `patch-enUS-6.MPQ` | ~134 KB | The `ParagonAdvancement` addon. Replaces the talent pane (`N` key) for Paragon characters with the Character Advancement panel: per-class spell tabs, talent grid, Overview/Search tabs, AE/TE currency, commit / reset / preview, login-time toast suppression, a **PETS** tab with live hunter pet talent trees (preview learn, no TE/AE cost), a dedicated **Reset Pet Talents** control (server `PARAA` `C RESET PET TALENTS` — instant, no gold, no confirmation; requires matching worldserver), bottom-row **Reset all Abilities / Reset Build / Reset all Talents** disabled while on the PETS tab so those paths cannot dismiss the pet or unlearn Tame Beast, and a **Builds** page (full-pane overlay opened from the bottom-row Builds button) for saving named, icon-tagged loadouts: New Build (+) icon picker reuses `MACRO_ICON_FILENAMES`, right-click for edit/delete, shift-left-click to favorite (favorites bubble to the top), left-click pops a Load Build confirm. Build swaps reset + refund AE/TE, re-spend on the saved recipe, and **park hunter pets** to `PET_SAVE_NOT_IN_SLOT` so their name/talents/exp are preserved across swaps. |
| `Wow.exe` | ~7.5 MB | 3.3.5a (build 12340) client byte-patched to skip the MPQ signature check so custom `patch-enUS-N.MPQ` files load. Diff against stock is a few bytes; everything else is unchanged. |
@@ -251,7 +251,8 @@ order on a maintainer machine:
1. `fractured-tooling/from-workspace-root/_patch_spell_dbc_runes.py` — stage `Spell.dbc` with `RuneCostID` cleared.
2. `fractured-tooling/from-workspace-root/_patch_spell_dbc_reagents.py` — same staged `Spell.dbc`, clear class-spell reagents for client preflight.
3. `fractured-tooling/from-workspace-root/_make_paragon_dbc_patch.py` — rebuild `ChrClasses` / `CharBaseInfo` / game tables, then pack `patch-enUS-4.MPQ`.
3. `fractured-tooling/from-workspace-root/_patch_spell_dbc_stances.py` — same staged `Spell.dbc`, zero the `Stances` field on every `SpellFamilyName == 4` (Warrior) row so the client engine's "Must be in Battle/Defensive/Berserker Stance" pre-cast check stops eating `CMSG_CAST_SPELL` packets for Paragon casters who never picked the stance form. The server's `SpellInfo::CheckShapeshift` Paragon bypass takes over from there. Stock Warriors still see the server-side stance error mid-cast if they actually click while out of stance.
4. `fractured-tooling/from-workspace-root/_make_paragon_dbc_patch.py` — rebuild `ChrClasses` / `CharBaseInfo` / game tables, then pack `patch-enUS-4.MPQ`.
The patched `Wow.exe` is a one-time hex-edit of the stock 3.3.5a
client. The diff is publicly documented in the WoW emulation community
@@ -21,6 +21,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(10, 1),
(17, 1),
(53, 1),
(66, 1),
(72, 1),
(75, 1),
(78, 1),
@@ -30,6 +31,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(118, 1),
(120, 1),
(122, 1),
(126, 1),
(130, 1),
(131, 1),
(132, 1),
@@ -52,6 +54,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(469, 1),
(475, 1),
(498, 1),
(526, 1),
(527, 1),
(528, 1),
(543, 1),
@@ -73,22 +76,28 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(676, 1),
(686, 1),
(687, 1),
(688, 1),
(689, 1),
(691, 1),
(693, 1),
(694, 1),
(697, 1),
(698, 1),
(702, 1),
(703, 1),
(706, 1),
(710, 1),
(712, 1),
(740, 1),
(755, 1),
(759, 1),
(768, 1),
(770, 1),
(772, 1),
(774, 1),
(779, 1),
(781, 1),
(783, 1),
(845, 1),
(853, 1),
(871, 1),
@@ -104,6 +113,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(1038, 1),
(1044, 1),
(1064, 1),
(1066, 1),
(1079, 1),
(1082, 1),
(1098, 1),
@@ -176,6 +186,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(2812, 1),
(2825, 1),
(2893, 1),
(2894, 1),
(2908, 1),
(2912, 1),
(2944, 1),
@@ -194,6 +205,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(3565, 1),
(3566, 1),
(3567, 1),
(3714, 1),
(3738, 1),
(4987, 1),
(5116, 1),
@@ -205,7 +217,9 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(5185, 1),
(5209, 1),
(5211, 1),
(5215, 1),
(5215, 1);
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(5217, 1),
(5221, 1),
(5225, 1),
@@ -215,11 +229,10 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(5308, 1),
(5384, 1),
(5484, 1),
(5487, 1),
(5500, 1),
(5502, 1),
(5504, 1);
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(5504, 1),
(5675, 1),
(5676, 1),
(5697, 1),
@@ -244,6 +257,8 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(6770, 1),
(6785, 1),
(6789, 1),
(6795, 1),
(6807, 1),
(6940, 1),
(7294, 1),
(7302, 1),
@@ -283,6 +298,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(11418, 1),
(11419, 1),
(11420, 1),
(12051, 1),
(13159, 1),
(13161, 1),
(13163, 1),
@@ -297,6 +313,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(16857, 1),
(16914, 1),
(18499, 1),
(19263, 1),
(19740, 1),
(19742, 1),
(19746, 1),
@@ -323,7 +340,6 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(20252, 1),
(20484, 1),
(20736, 1),
(21084, 1),
(21562, 1),
(21849, 1),
(22568, 1),
@@ -331,6 +347,8 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(22812, 1),
(22842, 1),
(23028, 1),
(23161, 1),
(23214, 1),
(23920, 1),
(23922, 1),
(24275, 1),
@@ -349,6 +367,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(29722, 1),
(29858, 1),
(29893, 1),
(30449, 1),
(30451, 1),
(30455, 1),
(30482, 1),
@@ -372,12 +391,14 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(33745, 1),
(33763, 1),
(33786, 1),
(33943, 1),
(34026, 1),
(34074, 1),
(34428, 1),
(34433, 1),
(34477, 1),
(34600, 1),
(34767, 1),
(35715, 1),
(35717, 1),
(36936, 1),
@@ -398,7 +419,9 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(47541, 1),
(47568, 1),
(47897, 1),
(48018, 1),
(48018, 1);
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(48020, 1),
(48045, 1),
(48263, 1),
@@ -416,14 +439,19 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(49576, 1),
(49998, 1),
(50464, 1),
(50769, 1),
(50842, 1),
(51505, 1),
(51514, 1),
(51722, 1),
(51723, 1),
(52610, 1);
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(51730, 1),
(52127, 1),
(52610, 1),
(53140, 1),
(53142, 1),
(53271, 1),
(53351, 1),
(53407, 1),
(53408, 1),
(53600, 1),
@@ -432,15 +460,23 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(54428, 1),
(55342, 1),
(55694, 1),
(56222, 1),
(56641, 1),
(56815, 1),
(57330, 1),
(57755, 1),
(57934, 1),
(57994, 1),
(60192, 1),
(61846, 1),
(61999, 1),
(62078, 1),
(62124, 1),
(62757, 1),
(64382, 1),
(64843, 1);
(64843, 1),
(64901, 1),
(66842, 1),
(66843, 1),
(66844, 1);
@@ -0,0 +1,62 @@
-- mod-paragon: backfill paragon_spell_ae_cost rows for spells newly exposed
-- by the Character Advancement panel after removing the over-aggressive
-- ClassMask=0 filter from tools/_gen_paragon_advancement_spells_lua.py.
--
-- The base file (data/sql/db-world/base/paragon_spell_ae_cost.sql) was
-- regenerated alongside this migration so fresh deployments already have
-- these rows. Existing servers do not re-run base files on content change,
-- so this update inserts the new (spell_id, ae_cost) pairs idempotently.
-- INSERT IGNORE keeps any per-row tuning a server operator may have already
-- applied to spell_ids that happen to overlap.
--
-- New ids include: 51505 Lava Burst (Shaman), 12051 Evocation / 1066 Aqueous
-- Form / Hex / Mage Ward / Spellsteal (Mage), 53351 Kill Shot / 19263
-- Deterrence / 53271 Master's Call (Hunter), 3714 Path of Frost / 57330
-- Horn of Winter / 56815 Rune Strike / 61999 Raise Ally / 56222 Dark Command
-- (DK), and 39 other trainer-taught class abilities whose stock
-- SkillLineAbility.dbc rows have ClassMask=0 (the skill line itself pins the
-- class for these rows; ClassMask is redundant on class-spec lines).
INSERT IGNORE INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(66, 1), -- Invisibility (Mage)
(126, 1), -- Eye of Kilrogg (Warlock)
(526, 1), -- Cure Toxins (Shaman)
(688, 1), -- Summon Imp (Warlock)
(691, 1), -- Summon Felhunter (Warlock)
(697, 1), -- Summon Voidwalker (Warlock)
(712, 1), -- Summon Succubus (Warlock)
(768, 1), -- Cat Form (Druid)
(783, 1), -- Travel Form (Druid)
(1066, 1), -- Aqueous Form (Mage)
(2894, 1), -- Fire Resistance Totem (Shaman)
(3714, 1), -- Path of Frost (DK)
(5215, 1), -- Prowl (Druid)
(5487, 1), -- Bear Form (Druid)
(5504, 1), -- Conjure Refreshment (Mage)
(6795, 1), -- Growl (Druid)
(6807, 1), -- Maul (Druid)
(12051, 1), -- Evocation (Mage)
(19263, 1), -- Deterrence (Hunter)
(23161, 1), -- Summon Dreadsteed (Warlock)
(23214, 1), -- Summon Charger (Paladin)
(30449, 1), -- Spellsteal (Mage)
(33943, 1), -- Flight Form (Druid)
(34767, 1), -- Summon Felguard (Warlock)
(48018, 1), -- Demonic Circle: Summon (Warlock)
(50769, 1), -- Revive (Druid)
(51505, 1), -- Lava Burst (Shaman)
(51514, 1), -- Hex (Shaman)
(51730, 1), -- Earthliving Weapon (Shaman)
(52127, 1), -- Water Shield (Shaman)
(52610, 1), -- Savage Roar (Druid)
(53271, 1), -- Master's Call (Hunter)
(53351, 1), -- Kill Shot (Hunter)
(56222, 1), -- Dark Command (DK)
(56815, 1), -- Rune Strike (DK)
(57330, 1), -- Horn of Winter (DK)
(61999, 1), -- Raise Ally (DK)
(64843, 1), -- Divine Hymn (Priest)
(64901, 1), -- Hymn of Hope (Priest)
(66842, 1), -- Call of the Elements (Shaman totem set)
(66843, 1), -- Call of the Ancestors (Shaman totem set)
(66844, 1); -- Call of the Spirits (Shaman totem set)
@@ -0,0 +1,60 @@
-- mod-paragon: Predatory Strikes (16972 / 16974 / 16975) Cataclysm-style
-- finisher proc for CLASS_PARAGON characters.
--
-- The 3.3.5 Predatory Strikes is a passive AP / ranged-attack-power talent
-- with no proc payload. The Cataclysm redesign added "Predator's Swiftness"
-- (69369), which makes the next Nature spell <10s base cast time instant.
-- That buff already exists in the WotLK Spell.dbc (because Blizzard reused
-- the spell id), but no server-side trigger ever calls CastSpell(69369) on
-- a 3.3.5 server. We need both halves: the proc handler AND a spell_proc
-- row so the proc evaluator actually invokes our AuraScript.
--
-- AuraScript binding: spell_paragon_predatory_strikes is registered in
-- modules/mod-paragon/src/Paragon_SC.cpp. It checks
-- (a) caster is CLASS_PARAGON,
-- (b) source spell consumes combo points (NeedsComboPoints),
-- (c) source spell deals damage (DmgClass MELEE/RANGED + at least one
-- damage effect or periodic-damage aura -- filters Slice and Dice,
-- Savage Roar, Maim, Kidney Shot, Expose Armor, Recuperate),
-- then rolls a per-rank chance of (CP * 3 / 5 / 7)% to cast 69369 on
-- the caster.
--
-- spell_proc row params:
-- ProcFlags = 0x40000 = PROC_FLAG_DONE_SPELL_MELEE_DMG_CLASS
-- SpellTypeMask = 0x3 (DAMAGE | HEAL bitmask in proc engine; we
-- filter precisely in CheckProc anyway, the
-- mask just gates "spell-type events" through)
-- SpellPhaseMask = 0x2 = PROC_SPELL_PHASE_CAST -- fires DURING cast
-- so player->GetComboPoints() inside HandleProc
-- still returns the pre-_handle_finish_phase
-- value.
-- Chance = 100 (the per-CP chance is rolled inside the script)
--
-- Note: this row's SpellFamilyName / SpellFamilyMask are 0 so the proc
-- engine's IsAffected check is a wildcard at the entry-level. The
-- AuraScript's CheckProc owns all real filtering. Combined with Phase A
-- (Paragon SpellFamilyName wildcard) this is harmless on stock classes
-- because non-Paragon characters cannot learn Predatory Strikes via the
-- Character Advancement panel.
DELETE FROM `spell_script_names`
WHERE `spell_id` IN (16972, 16974, 16975)
AND `ScriptName` = 'spell_paragon_predatory_strikes';
INSERT INTO `spell_script_names` (`spell_id`, `ScriptName`) VALUES
(16972, 'spell_paragon_predatory_strikes'),
(16974, 'spell_paragon_predatory_strikes'),
(16975, 'spell_paragon_predatory_strikes');
DELETE FROM `spell_proc` WHERE `SpellId` IN (16972, 16974, 16975);
INSERT INTO `spell_proc`
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
`Chance`, `Cooldown`, `Charges`)
VALUES
(16972, 0, 0, 0, 0, 0, 0x40000, 0x3, 0x2, 0, 0, 0, 0, 100.0, 0, 0),
(16974, 0, 0, 0, 0, 0, 0x40000, 0x3, 0x2, 0, 0, 0, 0, 100.0, 0, 0),
(16975, 0, 0, 0, 0, 0, 0x40000, 0x3, 0x2, 0, 0, 0, 0, 100.0, 0, 0);
@@ -0,0 +1,46 @@
-- mod-paragon: Vampiric Embrace (15286) cross-family wildcard.
--
-- Stock 3.3.5 spell_proc row for Vampiric Embrace gates by SpellFamilyName=6
-- (PRIEST) plus a Priest-Shadow-damage SpellFamilyMask. That blocks the proc
-- engine from ever calling the AuraScript's CheckProc when a Paragon casts a
-- non-Priest Shadow-school spell (e.g. Warlock Shadow Bolt, Death Knight
-- Death Coil, etc.), because IsAffected's familyFlags gate fails before
-- CheckProc runs even though the Phase A wildcard already loosens the
-- familyName equality test (see SpellInfo::IsAffected, listenerOwner overload).
--
-- We relax this row so:
-- * SchoolMask=32 (SHADOW) kept -- proc-engine still gates by school
-- * SpellTypeMask=1 (DAMAGE) kept -- only damage events trigger CheckProc
-- * SpellPhaseMask=2 (HIT) kept -- post-hit phase
-- * AttributesMask=2 (TRIGGERED) kept -- triggered-spell payloads still proc
-- * SpellFamilyName=0 wildcard -- IsAffected short-circuits to true
-- * SpellFamilyMask{0,1,2}=0 wildcard -- no flag-bit gating at this layer
--
-- Real filtering moves into spell_pri_vampiric_embrace::CheckProc, which
-- branches on IsParagonWildcardCaller(GetTarget()):
--
-- * For Paragon owners with `Paragon.WildcardFamilyMatching = 1`, accept any
-- single-target Shadow-school spell (Mind Sear / AoE shadow spells like
-- Seed of Corruption / Hellfire are filtered there via IsAffectingArea
-- and the existing Mind Sear bit-mask).
--
-- * For stock Priest owners (and for the non-wildcard runtime path), the
-- CheckProc re-enforces the EXACT original gate -- SpellFamilyName=6 plus
-- the original 0x0280A010 / 0x00002402 / 0x00000008 SpellFamilyMask bits
-- -- so behavior is byte-identical to before this change for any caster
-- that is not a Paragon.
--
-- Net effect: Paragon characters with VE learned now leech-heal off any
-- single-target Shadow spell they cast (Death Coil, Shadow Bolt, Searing
-- Pain, Drain Soul, etc.); stock Shadow Priests are unchanged.
DELETE FROM `spell_proc` WHERE `SpellId` = 15286;
INSERT INTO `spell_proc`
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
`Chance`, `Cooldown`, `Charges`)
VALUES
(15286, 32, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0);
@@ -0,0 +1,51 @@
-- mod-paragon: Maelstrom Weapon (53817) cross-family wildcard.
--
-- Stock 3.3.5 spell_proc row for Maelstrom Weapon gates by SpellFamilyName=11
-- (SHAMAN) plus a Shaman SpellFamilyMask covering Lightning Bolt, Chain
-- Lightning, Lesser Healing Wave, Healing Wave and Hex (Mask0=451,
-- Mask1=32768). The proc engine therefore never delivers an event to the
-- AuraScript when a Paragon casts a non-Shaman cast-time spell, even if the
-- IsAffected wildcard relaxes SpellFamilyName equality (the SpellFamilyMask
-- AND-with-target-FamilyFlags check still fails because Mage / Warlock /
-- Druid spell-class bits do not overlap with Shaman bits).
--
-- We relax this row so:
-- * SchoolMask=0 wildcard -- proc engine no longer gates by school
-- * SpellTypeMask=1 (DAMAGE) kept -- only damage spells trigger CheckProc
-- * SpellPhaseMask=8 (FINISH) kept -- post-cast phase, on cast finish
-- * SpellFamilyName=0 wildcard -- IsAffected short-circuits to true
-- * SpellFamilyMask{0,1,2}=0 wildcard -- no flag-bit gating at this layer
--
-- Real filtering moves into spell_sha_maelstrom_weapon::CheckProc:
--
-- * For stock Shaman owners (and for the non-wildcard runtime path), the
-- CheckProc re-enforces the EXACT original gate -- SpellFamilyName=11
-- plus the original Mask0=451 / Mask1=32768 bits -- so behavior is
-- byte-identical to before this change for any caster that is not a
-- Paragon.
--
-- * For Paragon owners with `Paragon.WildcardFamilyMatching = 1`, the
-- stock allowlist still passes, AND we additionally accept the curated
-- Mage cast-time nukes Fireball / Frostbolt / Arcane Blast (any rank,
-- matched via GetFirstRankSpell).
--
-- The matching IsAffectedBySpellMod hook in SpellInfo.cpp ensures the cast
-- time + power cost spellmods on aura 53817 also bridge across families for
-- the same Mage spell allowlist, so Paragons get the full Maelstrom Weapon
-- experience (instant cast at 5 stacks + reduced mana cost) on Fireball,
-- Frostbolt and Arcane Blast.
--
-- Net effect: Paragon characters with Maelstrom Weapon learned now spend
-- stacks on Mage cast-time nukes in addition to the stock Shaman list;
-- stock Enhancement Shamans are unchanged.
DELETE FROM `spell_proc` WHERE `SpellId` = 53817;
INSERT INTO `spell_proc`
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
`Chance`, `Cooldown`, `Charges`)
VALUES
(53817, 0, 0, 0, 0, 0, 0, 1, 8, 0, 0, 0, 0, 0, 0, 0);
@@ -0,0 +1,66 @@
-- mod-paragon: Frostbite + Fingers of Frost cross-family wildcard.
--
-- Both talents are normally gated by SpellFamilyName=3 (MAGE) plus a Mage
-- SpellFamilyMask covering specific Mage Frost spells (Frostbolt / Frost Nova
-- / Cone of Cold / Blizzard / Frostfire Bolt / Deep Freeze for FoF; Frost
-- slow-applying spells for Frostbite). That blocks the proc engine from
-- delivering an event to the AuraScript when a Paragon casts a non-Mage
-- Frost-school chill effect (DK Howling Blast / Icy Touch / Chains of Ice,
-- Hunter Frost Trap, Shaman Frost Shock, etc.), because IsAffected's
-- familyFlags AND-with-target-FamilyFlags check fails before CheckProc runs
-- even after the Paragon family-name wildcard.
--
-- We relax these rows so:
-- * SchoolMask=16 (FROST) gate by Frost school at the proc engine
-- * SpellTypeMask=1 (DAMAGE) only damage events trigger CheckProc
-- * SpellPhaseMask=2 (HIT) post-hit phase
-- * AttributesMask=2 (TRIGGERED) triggered chill payloads still proc
-- * SpellFamilyName=0 wildcard -- IsAffected short-circuits to true
-- * SpellFamilyMask{0,1,2}=0 wildcard -- no flag-bit gating at this layer
--
-- Real filtering moves into the Mage AuraScripts:
--
-- spell_mage_fingers_of_frost_talent attached to 44543 / 44545
-- stock Mage : SpellFamilyName=MAGE AND original Mask0 0x100120 / Mask1 0x1000
-- Paragon : accept (FROST + DAMAGE gate already enforced)
--
-- spell_mage_frostbite attached to 11071 / 12496 / 12497
-- stock Mage : SpellFamilyName=MAGE AND original Mage Frost-slow Mask
-- (Frostbolt / Frost Nova / Cone of Cold / Blizzard / FFB)
-- Paragon : accept iff proc spell applies SPELL_AURA_MOD_DECREASE_SPEED
-- OR the Paragon already has a slow on the proc target
-- (covers the Improved-Blizzard-style "chill via separate
-- triggered aura" cross-class case)
--
-- Net effect: Paragon characters with these talents now have FoF / Frostbite
-- proc off cross-class Frost-school chill effects; stock Mages are unchanged.
DELETE FROM `spell_proc` WHERE `SpellId` IN (44543, 44545, 11071, 12496, 12497);
INSERT INTO `spell_proc`
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
`Chance`, `Cooldown`, `Charges`)
VALUES
-- Fingers of Frost talent ranks (Chance 7% / 15% preserved from stock row).
(44543, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 7, 0, 0),
(44545, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 15, 0, 0),
-- Frostbite talent ranks (5% / 10% / 15% per rank, leave Chance=0 to use
-- the DBC ProcChance which already encodes the per-rank percentage).
(11071, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0),
(12496, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0),
(12497, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0);
-- Bind the new AuraScripts (defined in src/server/scripts/Spells/spell_mage.cpp).
DELETE FROM `spell_script_names`
WHERE `spell_id` IN (44543, 44545, 11071, 12496, 12497)
AND `ScriptName` IN ('spell_mage_fingers_of_frost_talent', 'spell_mage_frostbite');
INSERT INTO `spell_script_names` (`spell_id`, `ScriptName`) VALUES
(44543, 'spell_mage_fingers_of_frost_talent'),
(44545, 'spell_mage_fingers_of_frost_talent'),
(11071, 'spell_mage_frostbite'),
(12496, 'spell_mage_frostbite'),
(12497, 'spell_mage_frostbite');
@@ -0,0 +1,51 @@
-- mod-paragon: Maelstrom Weapon (53817) spell_proc fixup.
--
-- The previous migration (2026_05_11_02.sql) had two bugs in the rewritten
-- spell_proc row that prevented stack consumption from firing at all -- not
-- just for our new Mage targets, but also for the stock Shaman cast-time
-- spells (Lightning Bolt, Chain Lightning, Lesser Healing Wave, etc.).
--
-- Bugs:
--
-- * SpellPhaseMask was set to 8. The valid values for SpellPhaseMask are
-- PROC_SPELL_PHASE_CAST = 1
-- PROC_SPELL_PHASE_HIT = 2
-- PROC_SPELL_PHASE_FINISH = 4
-- (see SpellMgr.h). Anything else, including 8, never matches a real
-- proc event, so the proc engine silently dropped every event before it
-- reached the AuraScript. The original stock row uses 1 (CAST), which
-- is what fires when the cast packet's setup phase completes -- exactly
-- when we want the spellmod-affected cast to consume the buff.
--
-- * AttributesMask was set to 0. The original stock row uses 8
-- PROC_ATTR_REQ_SPELLMOD = 0x8
-- which says "only proc on spells that were affected by one of this
-- aura's spellmods". This is the bridge between IsAffectedBySpellMod
-- (which records the aura into Spell::m_appliedMods when calculating
-- cast time / cost) and the proc system (which then knows that the cast
-- used the buff and should consume a charge). Without this attribute,
-- the proc would either fire too aggressively or not at all depending
-- on subsequent gating, but in practice the engine relies on it to
-- correlate spellmod use with stack consumption.
--
-- Fixed row keeps the same family/mask wildcards from the previous
-- migration (so the Paragon Mage allowlist in spell_sha_maelstrom_weapon's
-- CheckProc still gets the chance to filter), restores SpellPhaseMask=1
-- (CAST) and AttributesMask=8 (REQ_SPELLMOD) to match stock semantics, and
-- resets SpellTypeMask to 0 (any spell type -- the REQ_SPELLMOD attribute
-- already gates by "was the buff actually used", so an extra DAMAGE filter
-- is redundant and would block e.g. Lesser Healing Wave on stock Shamans).
--
-- This restores stock Shaman behavior byte-identically and lets Paragon
-- Mage casts also consume stacks via the IsAffectedBySpellMod allowlist.
DELETE FROM `spell_proc` WHERE `SpellId` = 53817;
INSERT INTO `spell_proc`
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
`Chance`, `Cooldown`, `Charges`)
VALUES
(53817, 0, 0, 0, 0, 0, 0, 0, 1, 0, 8, 0, 0, 0, 0, 0);
+29 -9
View File
@@ -1203,10 +1203,21 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId)
// spellbook icons. The correct *passive* spellbook entries the
// player is supposed to see are 59879 / 59921 (the descriptive
// "Passive disease" rows; SPELL_ATTR0_PASSIVE bit set).
// After the Paragon class-skill cascade guard landed in
// Player::learnSkillRewardedSpells, NONE of the DK skill-line
// cascade rewards are auto-granted any more — so passives that
// used to ride along on a class skill cascade (Forceful
// Deflection on Blood Strike, Runic Focus on Icy Touch) must be
// explicitly attached here, the same way Blood Plague / Frost
// Fever are. Add new entries when a panel-purchased active is
// expected to come with a passive spellbook entry that no
// SPELL_EFFECT_LEARN_SPELL on the parent provides.
struct AttachedPassive { uint32 parentHead; uint32 attachedSpell; };
static AttachedPassive const kAttached[] = {
{ 45462, 59879 }, // Plague Strike -> Blood Plague (passive entry)
{ 45477, 59921 }, // Icy Touch -> Frost Fever (passive entry)
{ 45477, 59921 }, // Icy Touch -> Frost Fever (passive entry)
{ 45477, 61455 }, // Icy Touch -> Runic Focus (parry from spell power)
{ 45902, 49410 }, // Blood Strike -> Forceful Deflection (parry from strength)
};
// Self-heal: a previous build of mod-paragon (briefly shipped)
@@ -3889,16 +3900,25 @@ public:
lb.child, lb.parent, player->GetName());
}
}
// 2b) Re-attach the correct passive spellbook entry (59879 /
// 59921) for any panel-purchased Plague Strike / Icy Touch
// that's missing it. `learnSpell` here can re-fire the DK
// skill-line cascade and re-grant Blood Presence / Death
// Coil / Death Grip / Forceful Deflection — Step 3's
// scoped sweep is what cleans those up.
// 2b) Re-attach the correct passive spellbook entries for any
// panel-purchased parent that is missing them. After the
// class-skill cascade guard in
// Player::learnSkillRewardedSpells, the cascade no longer
// fires for Paragons, so these attachments are the ONLY
// source for the disease passive icons (Blood Plague /
// Frost Fever) and the small DK weapon passives (Forceful
// Deflection from Blood Strike, Runic Focus from Icy
// Touch). Existing characters predating the guard may
// have FD/RF in their spellbook from the cascade but no
// panel_spell_children row tying them to the parent;
// re-running learnSpell when they already have the spell
// just records the child row and is a no-op otherwise.
struct LegacyFix { uint32 parent; uint32 correctChild; };
static LegacyFix const kFixup[] = {
{ 45462, 59879 },
{ 45477, 59921 },
{ 45462, 59879 }, // Plague Strike -> Blood Plague (passive)
{ 45477, 59921 }, // Icy Touch -> Frost Fever (passive)
{ 45477, 61455 }, // Icy Touch -> Runic Focus
{ 45902, 49410 }, // Blood Strike -> Forceful Deflection
};
for (auto const& lf : kFixup)
{
+128
View File
@@ -514,8 +514,136 @@ class spell_paragon_arcane_torrent : public SpellScript
}
};
// Predatory Strikes (16972 / 16974 / 16975) for Paragon: re-implements the
// Cataclysm-era proc behavior of the talent so a Paragon's damaging
// finishers (Eviscerate / Envenom / Ferocious Bite / Rip / Rupture) can
// roll Predator's Swiftness (69369) -- the same buff that real druids
// get from the Cata redesign of this talent. Combined with the
// Spell::prepare interception in core (Spell.cpp), 69369 makes the
// Paragon's NEXT Nature-school spell with a base cast time below 10s
// instant cast: Chain Lightning, Lightning Bolt, Healing Touch, Wrath,
// Nourish, etc. -- not just the Druid-family Nature subset that the
// stock SPELLMOD_CASTING_TIME mask on 69369 covers.
//
// Filter logic:
// - Source spell must consume combo points (NeedsComboPoints() — gates
// out non-finisher combo-point builders).
// - "Damaging finisher": SPELL_ATTR1_FINISHING_MOVE_DAMAGE (Eviscerate,
// Envenom, Ferocious Bite, ...) OR a SPELL_ATTR1_FINISHING_MOVE_DURATION
// finisher that applies periodic damage (Rip, Rupture). Duration
// finishers that only heal (Recuperate) or only buff / CC / armor shred
// (Slice and Dice, Savage Roar, Kidney Shot, Maim, Expose Armor) are
// rejected.
//
// Chance per combo point matches the Cataclysm tuning that the user's
// client tooltip text reflects: rank 1 = 3% per CP, rank 2 = 5% per CP,
// rank 3 = 7% per CP. At 5 CP that is 15% / 25% / 35%, capped at 100%.
//
// Combo-point read happens during PROC_SPELL_PHASE_CAST, which fires in
// Spell::cast → Spell::ProcReflectProcs / Unit::ProcDamageAndSpellFor
// BEFORE Spell::_handle_finish_phase clears the player's combo points
// (see Spell.cpp:_handle_finish_phase clearing combo points). So
// player->GetComboPoints() inside HandleProc returns the pre-clear value.
class spell_paragon_predatory_strikes : public AuraScript
{
PrepareAuraScript(spell_paragon_predatory_strikes);
static constexpr uint32 SPELL_PARAGON_PREDATORS_SWIFTNESS = 69369;
bool Validate(SpellInfo const* /*spellInfo*/) override
{
return ValidateSpellInfo({ SPELL_PARAGON_PREDATORS_SWIFTNESS });
}
bool CheckProc(ProcEventInfo& eventInfo)
{
SpellInfo const* spellInfo = eventInfo.GetSpellInfo();
if (!spellInfo || !spellInfo->NeedsComboPoints())
return false;
if (spellInfo->HasAttribute(SPELL_ATTR1_FINISHING_MOVE_DAMAGE))
return true;
if (spellInfo->HasAttribute(SPELL_ATTR1_FINISHING_MOVE_DURATION))
{
bool periodicHeal = false;
bool periodicDamage = false;
for (SpellEffectInfo const& eff : spellInfo->Effects)
{
if (eff.Effect != SPELL_EFFECT_APPLY_AURA && eff.Effect != SPELL_EFFECT_APPLY_AREA_AURA_PARTY
&& eff.Effect != SPELL_EFFECT_PERSISTENT_AREA_AURA)
continue;
switch (eff.ApplyAuraName)
{
case SPELL_AURA_PERIODIC_HEAL:
case SPELL_AURA_PERIODIC_HEALTH_FUNNEL:
case SPELL_AURA_OBS_MOD_HEALTH:
periodicHeal = true;
break;
case SPELL_AURA_PERIODIC_DAMAGE:
case SPELL_AURA_PERIODIC_DAMAGE_PERCENT:
case SPELL_AURA_PERIODIC_LEECH:
periodicDamage = true;
break;
default:
break;
}
}
if (periodicHeal)
return false;
return periodicDamage;
}
return false;
}
void HandleProc(ProcEventInfo& eventInfo)
{
PreventDefaultAction();
Unit* actor = eventInfo.GetActor();
Player* player = actor ? actor->ToPlayer() : nullptr;
if (!player || player->getClass() != CLASS_PARAGON)
return;
uint8 const cp = player->GetComboPoints();
if (cp == 0)
return;
SpellInfo const* talent = GetSpellInfo();
if (!talent)
return;
uint32 pctPerCP = 0;
switch (talent->Id)
{
case 16972: pctPerCP = 3; break;
case 16974: pctPerCP = 5; break;
case 16975: pctPerCP = 7; break;
default:
return;
}
uint32 const chance = std::min<uint32>(100u, pctPerCP * uint32(cp));
if (!roll_chance_i(int32(chance)))
return;
player->CastSpell(player, SPELL_PARAGON_PREDATORS_SWIFTNESS, true);
}
void Register() override
{
DoCheckProc += AuraCheckProcFn(spell_paragon_predatory_strikes::CheckProc);
OnProc += AuraProcFn(spell_paragon_predatory_strikes::HandleProc);
}
};
void AddSC_paragon()
{
new Paragon_PlayerScript();
RegisterSpellScript(spell_paragon_arcane_torrent);
RegisterSpellScript(spell_paragon_predatory_strikes);
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Start AzerothCore authserver + worldserver detached from the SSH session (nohup + disown).
# Stops any already-running authserver/worldserver processes first.
#
# Usage:
# sudo bash scripts/start-azeroth-servers.sh
# AZEROTH_BIN=/path/to/azeroth-server/bin bash scripts/start-azeroth-servers.sh
#
# Environment:
# AZEROTH_BIN — directory with authserver and worldserver (default: /root/azeroth-server/bin)
# AZEROTH_LOG_DIR — log directory (default: <parent of bin>/logs)
set -euo pipefail
BIN_DIR="${AZEROTH_BIN:-/root/azeroth-server/bin}"
LOG_DIR="${AZEROTH_LOG_DIR:-$(cd "$(dirname "$BIN_DIR")" && pwd)/logs}"
AUTH_BIN="${BIN_DIR}/authserver"
WORLD_BIN="${BIN_DIR}/worldserver"
if [[ ! -x "$AUTH_BIN" ]]; then
echo "error: not found or not executable: $AUTH_BIN" >&2
exit 1
fi
if [[ ! -x "$WORLD_BIN" ]]; then
echo "error: not found or not executable: $WORLD_BIN" >&2
exit 1
fi
pkill -x authserver 2>/dev/null || true
pkill -x worldserver 2>/dev/null || true
sleep 1
mkdir -p "$LOG_DIR"
cd "$BIN_DIR"
nohup "$AUTH_BIN" >>"$LOG_DIR/authserver.log" 2>&1 &
disown || true
sleep 2
nohup "$WORLD_BIN" >>"$LOG_DIR/worldserver.log" 2>&1 &
disown || true
echo "Started authserver and worldserver (survives SSH disconnect)."
echo "Bin: $BIN_DIR"
echo "Logs: $LOG_DIR/authserver.log"
echo " $LOG_DIR/worldserver.log"
@@ -4767,6 +4767,36 @@ Respawn.DynamicEscortNPC = 0
Respawn.ForceCompatibilityMode = 0
#
# Paragon.WildcardFamilyMatching
# Description: Fractured / Paragon class (CLASS_PARAGON, id 12) only.
# When enabled, the SpellFamilyName equality check is
# wildcarded for Paragon characters in proc evaluation
# (SpellMgr::CanSpellTriggerProcOnEvent), talent
# SpellMod application (Player::ApplySpellMod /
# SpellInfo::IsAffectedBySpellMod), and the
# ParagonFamilyMatches() helper used by ad-hoc
# `switch (SpellFamilyName)` listener gates in
# Unit/SpellEffects/SpellAuraEffects code.
# This makes cross-class talent procs and modifiers
# (e.g. Predator's Swiftness 69369 making Shaman
# Chain Lightning instant cast off a Rogue Eviscerate
# finisher) apply to Paragon characters even when the
# listener was authored for one specific class family.
# SpellFamilyFlags / class-mask flag-bit checks still
# run, so listener gates that explicitly opt into a
# subset of spells via flag bits are still respected.
# Stock classes (Warrior / Paladin / etc.) are NEVER
# wildcarded; this only affects players whose class
# id is CLASS_PARAGON. Set to 0 to disable the
# wildcard at runtime (no rebuild required) if a
# regression appears.
# Default: 1 - (Enabled, Paragon characters get cross-class procs/mods)
# 0 - (Disabled, Paragon characters are gated by stock family equality)
#
Paragon.WildcardFamilyMatching = 1
#
###################################################################################################
+60 -3
View File
@@ -7143,6 +7143,16 @@ bool Player::CheckAttackFitToAuraRequirement(WeaponAttackType attackType, AuraEf
if (spellInfo->EquippedItemClass == -1)
return true;
// Fractured / Paragon: cross-class wildcard relaxes weapon-class subclass
// gates on per-swing proc matches. A Paragon's talent list spans every
// class so a stock weapon-subclass mask (e.g. Maelstrom Weapon's
// axe/mace/staff/fist/dagger restriction) excludes weapons the player
// can legitimately wield. Accept any equipped weapon in attackType slot
// when listener is a Paragon AND the spell gates on ITEM_CLASS_WEAPON;
// ITEM_CLASS_ARMOR (shield) gates still enforce the original mask.
if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON && IsParagonWildcardCaller(this))
return GetWeaponForAttack(attackType, true) != nullptr;
Item* item = GetWeaponForAttack(attackType, true);
if (!item || !item->IsFitToSpellRequirements(spellInfo))
return false;
@@ -7208,7 +7218,7 @@ void Player::ApplyEquipSpell(SpellInfo const* spellInfo, Item* item, bool apply,
return;
// Cannot be used in this stance/form
if (spellInfo->CheckShapeshift(GetShapeshiftForm()) != SPELL_CAST_OK)
if (spellInfo->CheckShapeshift(GetShapeshiftForm(), this) != SPELL_CAST_OK)
return;
if (form_change) // check aura active state from other form
@@ -7228,7 +7238,7 @@ void Player::ApplyEquipSpell(SpellInfo const* spellInfo, Item* item, bool apply,
if (form_change) // check aura compatibility
{
// Cannot be used in this stance/form
if (spellInfo->CheckShapeshift(GetShapeshiftForm()) == SPELL_CAST_OK)
if (spellInfo->CheckShapeshift(GetShapeshiftForm(), this) == SPELL_CAST_OK)
return; // and remove only not compatible at form change
}
@@ -9773,7 +9783,11 @@ bool Player::IsAffectedBySpellmod(SpellInfo const* spellInfo, SpellModifier* mod
if (mod->op == SPELLMOD_DURATION && spellInfo->GetDuration() == -1)
return false;
return spellInfo->IsAffectedBySpellMod(mod);
// Fractured / Paragon: pass the player owning the modifier aura so the
// SpellFamilyName equality check can be wildcarded for CLASS_PARAGON.
// Stock classes hit the same code path with `this` as a non-Paragon
// unit, which makes IsAffected behave identically to the 2-arg form.
return spellInfo->IsAffectedBySpellMod(mod, this);
}
template <class T>
@@ -12017,6 +12031,28 @@ void Player::learnSkillRewardedSpells(uint32 skill_id, uint32 skill_value)
uint32 raceMask = getRaceMask();
uint32 classMask = getClassMask();
// Fractured / Paragon: the Character Advancement panel is the sole
// authority over which class abilities a Paragon owns. The skill-line
// cascade re-fires from _LoadSkills (every login), UpdateSkillsForLevel
// (every level-up), UpdateSkillPro (every weapon-skill tick on a
// training dummy), and SetSkill (first time a class skill is granted).
// Each of those re-grants every SLA-tagged class ability on the
// matching skill line — leaking Blood Presence / Death Coil / Death
// Grip / etc. back into the spellbook within seconds even after the
// player intentionally refunded them via the panel. Skip the cascade
// for class-category skill lines on Paragon characters; mod-paragon
// calls Player::learnSpell directly for the abilities the player
// actually purchased, including their attached passives. Profession,
// weapon, language, and racial skill cascades stay enabled so things
// like recipe auto-learn, weapon proficiencies, and racial perks
// still work.
if (getClass() == CLASS_PARAGON)
{
if (SkillLineEntry const* sl = sSkillLineStore.LookupEntry(skill_id))
if (sl->categoryId == SKILL_CATEGORY_CLASS)
return;
}
// Get all abilities for this skill and sort by MinSkillLineRank (lowest to highest)
auto abilities = GetSkillLineAbilitiesBySkillLine(skill_id);
std::vector<SkillLineAbilityEntry const*> sortedAbilities(abilities.begin(), abilities.end());
@@ -12555,6 +12591,27 @@ bool Player::HasItemFitToSpellRequirements(SpellInfo const* spellInfo, Item cons
if (spellInfo->EquippedItemClass < 0)
return true;
// Fractured / Paragon: cross-class wildcard relaxes weapon-class subclass
// gates so passive talent auras (e.g. Maelstrom Weapon talents 51528-51532)
// attach for any equipped weapon, not just the talent's restrictive
// subclass mask. Mirrors CheckAttackFitToAuraRequirement so per-swing
// proc match agrees with talent-attach time. Still requires *some* weapon
// to be equipped (otherwise unarmed Paragons would auto-activate every
// weapon-gated talent in the game). ITEM_CLASS_ARMOR (shield) is left
// alone -- shield-gated talents still need an actual shield.
if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON && IsParagonWildcardCaller(this))
{
for (uint8 i = EQUIPMENT_SLOT_MAINHAND; i < EQUIPMENT_SLOT_TABARD; ++i)
if (Item const* item = GetUseableItemByPos(INVENTORY_SLOT_BAG_0, i))
if (item != ignoreItem)
if (ItemTemplate const* proto = item->GetTemplate())
if (proto->Class == ITEM_CLASS_WEAPON)
return true;
// No weapon equipped at all -- fall through to stock logic, which
// returns false for passive talent auras (correct: an unarmed
// Paragon shouldn't have weapon talents active).
}
// scan other equipped items for same requirements (mostly 2 daggers/etc)
// for optimize check 2 used cases only
switch (spellInfo->EquippedItemClass)
+57 -8
View File
@@ -72,11 +72,37 @@
#include "Util.h"
#include "Vehicle.h"
#include "World.h"
#include "WorldConfig.h"
#include "WorldPacket.h"
#include <algorithm>
#include <cmath>
#include <limits>
// Fractured / Paragon: single source of truth for the runtime "is this
// caller eligible for the cross-class wildcard?" question. Centralizing
// here keeps every dependent behavior (family-name skip in
// SpellInfo::IsAffected, PERIODIC_LEECH disease counting in
// GetDiseasesByCaster, instant-cast intercept in Spell::prepare for
// Predator's / Nature's Swiftness, Vampiric Embrace CheckProc cross-family
// path, etc.) flipping in lockstep when the config flag is toggled.
bool IsParagonWildcardCaller(Unit const* listener)
{
return listener && listener->getClass() == CLASS_PARAGON
&& sWorld->getBoolConfig(CONFIG_PARAGON_WILDCARD_FAMILY);
}
// Fractured / Paragon: cross-class wildcard helper used by ad-hoc
// `switch (SpellFamilyName)` listener gates in Unit / SpellEffects /
// SpellAuraEffects. Returns true when the listener is a CLASS_PARAGON
// player and the wildcard config flag is enabled, otherwise falls back
// to strict family-name equality.
bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 actualFamily)
{
if (IsParagonWildcardCaller(listener))
return true;
return expectedFamily == actualFamily;
}
float baseMoveSpeed[MAX_MOVE_TYPE] =
{
2.5f, // MOVE_WALK
@@ -6122,17 +6148,40 @@ AuraEffect* Unit::IsScriptOverriden(SpellInfo const* spell, int32 script) const
uint32 Unit::GetDiseasesByCaster(ObjectGuid casterGUID, uint8 mode)
{
static const AuraType diseaseAuraTypes[] =
ObjectGuid drwGUID;
// Fractured / Paragon: when the caller (the unit whose strike is
// counting diseases -- e.g. Death Strike heal, Blood Strike / Heart
// Strike / Obliterate per-disease damage, Glyph of Scourge Strike
// refresh) is a CLASS_PARAGON player AND Paragon.WildcardFamilyMatching
// is on, also walk SPELL_AURA_PERIODIC_LEECH. That picks up Priest
// Devouring Plague, which uses ApplyAuraName 53 (PERIODIC_LEECH) instead
// of 3 (PERIODIC_DAMAGE) and is therefore invisible to the stock loop
// even though its Dispel field is DISPEL_DISEASE. A full Spell.dbc scan
// confirms Devouring Plague is the ONLY entry that satisfies both
// `Dispel == DISPEL_DISEASE` and a leech periodic effect, so this does
// not accidentally drag any other spell into the disease pool. Stock
// (non-Paragon) callers fall through to the original 2-entry iteration
// and observe identical behavior.
bool paragonWildcardLeech = false;
if (Player* playerCaster = ObjectAccessor::GetPlayer(*this, casterGUID))
{
drwGUID = playerCaster->getRuneWeaponGUID();
paragonWildcardLeech = IsParagonWildcardCaller(playerCaster);
}
AuraType diseaseAuraTypes[4] =
{
SPELL_AURA_PERIODIC_DAMAGE, // Frost Fever and Blood Plague
SPELL_AURA_LINKED, // Crypt Fever and Ebon Plague
SPELL_AURA_NONE,
SPELL_AURA_NONE
};
ObjectGuid drwGUID;
if (Player* playerCaster = ObjectAccessor::GetPlayer(*this, casterGUID))
drwGUID = playerCaster->getRuneWeaponGUID();
if (paragonWildcardLeech)
{
diseaseAuraTypes[2] = SPELL_AURA_PERIODIC_LEECH; // Priest Devouring Plague (Paragon-only)
diseaseAuraTypes[3] = SPELL_AURA_NONE;
}
uint32 diseases = 0;
for (uint8 index = 0; diseaseAuraTypes[index] != SPELL_AURA_NONE; ++index)
@@ -9702,7 +9751,7 @@ uint32 Unit::SpellHealingBonusTaken(Unit* caster, SpellInfo const* spellProto, u
// Nourish cast - 20% bonus if target has Rejuvenation, Regrowth, Lifebloom, or Wild Growth from caster
// Glyph of Nourish is handled by spell_dru_nourish script
if (spellProto->SpellFamilyName == SPELLFAMILY_DRUID && spellProto->SpellFamilyFlags[1] & 0x2000000 && caster)
if (ParagonFamilyMatches(caster, SPELLFAMILY_DRUID, spellProto->SpellFamilyName) && spellProto->SpellFamilyFlags[1] & 0x2000000 && caster)
{
AuraEffectList const& auras = GetAuraEffectsByType(SPELL_AURA_PERIODIC_HEAL);
for (AuraEffectList::const_iterator i = auras.begin(); i != auras.end(); ++i)
@@ -10421,7 +10470,7 @@ uint32 Unit::MeleeDamageBonusTaken(Unit* attacker, uint32 pdamage, WeaponAttackT
uint64 mechanicMask = spellProto->GetAllEffectsMechanicMask();
// Shred, Maul - "Effects which increase Bleed damage also increase Shred damage"
if (spellProto->SpellFamilyName == SPELLFAMILY_DRUID && spellProto->SpellFamilyFlags[0] & 0x00008800)
if (ParagonFamilyMatches(attacker, SPELLFAMILY_DRUID, spellProto->SpellFamilyName) && spellProto->SpellFamilyFlags[0] & 0x00008800)
mechanicMask |= (1ULL << MECHANIC_BLEED);
if (mechanicMask)
+19
View File
@@ -2268,6 +2268,25 @@ private:
ValuesUpdateCache _valuesUpdateCache;
};
// Fractured / Paragon: returns true iff `listener` is a CLASS_PARAGON player
// AND `Paragon.WildcardFamilyMatching` is enabled. Single source of truth for
// the gate that controls every cross-class wildcard path (family-name skip in
// SpellInfo::IsAffected, leech-aura disease counting in
// Unit::GetDiseasesByCaster, the cross-school instant-cast intercept in
// Spell::prepare for Predator's / Nature's Swiftness, the Vampiric Embrace
// CheckProc cross-family path, etc.). Centralizing the check means runtime
// kill-switching the wildcard config flips every behavior together.
[[nodiscard]] bool IsParagonWildcardCaller(Unit const* listener);
// Fractured / Paragon: helper for ad-hoc `switch (SpellFamilyName)` listener
// gates scattered across Unit / SpellEffects / SpellAuraEffects. When the
// listener (i.e. the unit holding the gating talent / aura) is a Paragon
// AND `Paragon.WildcardFamilyMatching` is enabled, accept any source family
// so cross-class procs / bonuses can fire. Stock classes use stock equality.
// Defined inline here so call sites do not need an extra include for World.h
// beyond what they already include via Unit.h's transitive headers.
[[nodiscard]] bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 actualFamily);
namespace Acore
{
// Binary predicate for sorting Units based on percent value of a power
@@ -1630,7 +1630,7 @@ void AuraEffect::HandleShapeshiftBoosts(Unit* target, bool apply) const
// Xinef: Remove autoattack spells
if (Spell* spell = target->GetCurrentSpell(CURRENT_MELEE_SPELL))
if (spell->GetSpellInfo()->CheckShapeshift(newAura ? newAura->GetMiscValue() : 0) != SPELL_CAST_OK)
if (spell->GetSpellInfo()->CheckShapeshift(newAura ? newAura->GetMiscValue() : 0, target) != SPELL_CAST_OK)
spell->cancel(true);
}
}
+22 -2
View File
@@ -2175,7 +2175,9 @@ uint8 Aura::GetProcEffectMask(AuraApplication* aurApp, ProcEventInfo& eventInfo,
return 0;
// do checks against db data
if (!sSpellMgr->CanSpellTriggerProcOnEvent(*procEntry, eventInfo))
// Fractured / Paragon: the unit that owns this aura is the listener;
// pass it through so cross-family procs can match for Paragon players.
if (!sSpellMgr->CanSpellTriggerProcOnEvent(*procEntry, eventInfo, aurApp->GetTarget()))
return 0;
// check if spell was affected by this aura's spellmod (used by Arcane Potency and similar effects)
@@ -2249,8 +2251,26 @@ uint8 Aura::GetProcEffectMask(AuraApplication* aurApp, ProcEventInfo& eventInfo,
item = target->ToPlayer()->GetUseableItemByPos(INVENTORY_SLOT_BAG_0, EQUIPMENT_SLOT_OFFHAND);
}
if (!item || item->IsBroken() || !item->IsFitToSpellRequirements(GetSpellInfo()))
if (!item || item->IsBroken())
return 0;
if (!item->IsFitToSpellRequirements(GetSpellInfo()))
{
// Fractured / Paragon: cross-class wildcard relaxes weapon-
// class subclass gates on per-event proc evaluation. This
// mirrors Player::CheckAttackFitToAuraRequirement and
// Player::HasItemFitToSpellRequirements -- without this
// third bypass, the talent attaches (HasItemFit lets it),
// the per-swing match accepts the weapon (CheckAttackFit
// lets it), but IsProcTriggeredOnEvent still kills the
// proc here for any weapon outside the talent's stock
// subclass mask (e.g. Maelstrom Weapon on a Paragon
// wielding a 1H sword or polearm). Restricted to
// ITEM_CLASS_WEAPON so shield-gated talents still need
// an actual shield.
if (!(GetSpellInfo()->EquippedItemClass == ITEM_CLASS_WEAPON
&& IsParagonWildcardCaller(target)))
return 0;
}
}
}
+48 -1
View File
@@ -51,6 +51,7 @@
#include "Vehicle.h"
#include "World.h"
#include "WorldPacket.h"
#include <array>
#include <cmath>
/// @todo: this import is not necessary for compilation and marked as unused by the IDE
@@ -3540,6 +3541,52 @@ SpellCastResult Spell::prepare(SpellCastTargets const* targets, AuraEffect const
if (m_caster->ToPlayer()->GetCommandStatus(CHEAT_CASTTIME))
m_casttime = 0;
// Fractured / Paragon: cross-class "next Nature spell becomes instant"
// intercept for the three buffs that share that semantic in 3.3.5:
//
// 69369 - Predator's Swiftness (Cataclysm proc payload triggered by
// our spell_paragon_predatory_strikes; see Paragon_SC.cpp)
// 17116 - Druid Nature's Swiftness
// 16188 - Shaman Nature's Swiftness
//
// All three apply SPELL_AURA_ADD_PCT_MODIFIER on SPELLMOD_CASTING_TIME
// gated by a Druid- or Shaman-only SpellClassMask, so a Paragon with the
// buff cannot instant-cast a Nature spell from a different family
// (e.g. a Druid NS Paragon casting Shaman Chain Lightning, or a Shaman
// NS Paragon casting Druid Healing Touch). Tooltip text on all three
// promises "next Nature spell with a base cast time below 10 sec becomes
// instant"; honor that here for CLASS_PARAGON callers when the wildcard
// config is on. The stock SpellMod path is untouched -- real Druids /
// Shamans / proc consumers continue to hit the existing class-mask code
// path unchanged.
if (Player* paragonCaster = m_caster->ToPlayer())
{
if (m_casttime > 0
&& IsParagonWildcardCaller(paragonCaster)
&& (m_spellInfo->SchoolMask & SPELL_SCHOOL_MASK_NATURE)
&& m_spellInfo->CastTimeEntry
&& !m_spellInfo->IsChanneled()
&& !HasTriggeredCastFlag(TRIGGERED_FULL_MASK)
&& m_spellInfo->CalcCastTime() < 10 * IN_MILLISECONDS)
{
static constexpr std::array<uint32, 3> kParagonNatureInstantBuffs =
{
69369u, // Predator's Swiftness (Paragon proc payload)
17116u, // Druid Nature's Swiftness
16188u // Shaman Nature's Swiftness
};
for (uint32 buffId : kParagonNatureInstantBuffs)
{
if (paragonCaster->HasAura(buffId))
{
m_casttime = 0;
paragonCaster->RemoveAurasDueToSpell(buffId);
break; // consume only one buff per cast
}
}
}
}
// don't allow channeled spells / spells with cast time to be casted while moving
// (even if they are interrupted on moving, spells with almost immediate effect get to have their effect processed before movement interrupter kicks in)
if ((m_spellInfo->IsChanneled() || m_casttime) && m_caster->IsPlayer() && m_caster->isMoving() && m_spellInfo->InterruptFlags & SPELL_INTERRUPT_FLAG_MOVEMENT && !IsTriggered())
@@ -5722,7 +5769,7 @@ SpellCastResult Spell::CheckCast(bool strict, uint32* /*param1*/, uint32* /*para
if (checkForm)
{
// Cannot be used in this stance/form
SpellCastResult shapeError = m_spellInfo->CheckShapeshift(m_caster->GetShapeshiftForm());
SpellCastResult shapeError = m_spellInfo->CheckShapeshift(m_caster->GetShapeshiftForm(), m_caster);
if (shapeError != SPELL_CAST_OK)
return shapeError;
+92 -3
View File
@@ -27,6 +27,8 @@
#include "SpellAuraDefines.h"
#include "SpellAuraEffects.h"
#include "SpellMgr.h"
#include "World.h"
#include "WorldConfig.h"
uint32 GetTargetFlagMask(SpellTargetObjectTypes objType)
{
@@ -1323,11 +1325,26 @@ bool SpellInfo::HasInitialAggro() const
}
bool SpellInfo::IsAffected(uint32 familyName, flag96 const& familyFlags) const
{
return IsAffected(familyName, familyFlags, nullptr);
}
bool SpellInfo::IsAffected(uint32 familyName, flag96 const& familyFlags,
Unit const* listenerOwner) const
{
if (!familyName)
return true;
if (familyName != SpellFamilyName)
// Fractured / Paragon: when the unit that owns the listening proc /
// spellmod aura is a Paragon, accept any source family. The class
// mask flag-bit check below still runs, so listeners that explicitly
// opt into a subset of spells via SpellFamilyFlags / class mask are
// still respected; only the family-name equality gate is wildcarded.
bool const wildcardFamily = listenerOwner
&& listenerOwner->getClass() == CLASS_PARAGON
&& sWorld->getBoolConfig(CONFIG_PARAGON_WILDCARD_FAMILY);
if (!wildcardFamily && familyName != SpellFamilyName)
return false;
if (familyFlags && !(familyFlags & SpellFamilyFlags))
@@ -1342,6 +1359,11 @@ bool SpellInfo::IsAffectedBySpellMods() const
}
bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod) const
{
return IsAffectedBySpellMod(mod, nullptr);
}
bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod, Unit const* listenerOwner) const
{
// xinef: dont check duration mod
if (mod->op != SPELLMOD_DURATION)
@@ -1356,7 +1378,42 @@ bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod) const
if (!sScriptMgr->OnIsAffectedBySpellModCheck(affectSpell, this, mod))
return true;
return IsAffected(affectSpell->SpellFamilyName, mod->mask);
if (IsAffected(affectSpell->SpellFamilyName, mod->mask, listenerOwner))
return true;
// Fractured / Paragon: explicit cross-family allowlist for specific
// listener auras whose SpellClassMask cannot otherwise bridge classes.
// The standard IsAffected wildcard relaxes SpellFamilyName equality but
// still requires SpellClassMask & SpellFamilyFlags to overlap; for these
// Paragon-only cross-class enablers the source spells live in different
// families with non-overlapping class bits, so we whitelist by mod owner
// spell ID + target spell first-rank ID. Stock classes never enter here
// because IsParagonWildcardCaller short-circuits on non-Paragon owners.
if (IsParagonWildcardCaller(listenerOwner))
{
switch (mod->spellId)
{
case 53817: // Shaman: Maelstrom Weapon
{
// Allow any rank of Mage Fireball / Frostbolt / Arcane Blast to
// benefit from the cast-time + cost reduction spellmod.
if (SpellFamilyName == SPELLFAMILY_MAGE)
{
SpellInfo const* first = GetFirstRankSpell();
uint32 firstId = first ? first->Id : Id;
if (firstId == 133 /*Fireball*/
|| firstId == 116 /*Frostbolt*/
|| firstId == 30451 /*Arcane Blast*/)
return true;
}
break;
}
default:
break;
}
}
return false;
}
bool SpellInfo::CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const
@@ -1441,7 +1498,7 @@ bool SpellInfo::IsAuraExclusiveBySpecificPerCasterWith(SpellInfo const* spellInf
}
}
SpellCastResult SpellInfo::CheckShapeshift(uint32 form) const
SpellCastResult SpellInfo::CheckShapeshift(uint32 form, Unit const* caster /*= nullptr*/) const
{
// talents that learn spells can have stance requirements that need ignore
// (this requirement only for client-side stance show in talent description)
@@ -1449,6 +1506,38 @@ SpellCastResult SpellInfo::CheckShapeshift(uint32 form) const
(Effects[0].Effect == SPELL_EFFECT_LEARN_SPELL || Effects[1].Effect == SPELL_EFFECT_LEARN_SPELL || Effects[2].Effect == SPELL_EFFECT_LEARN_SPELL))
return SPELL_CAST_OK;
// Fractured / Paragon: Paragons learn Warrior abilities through Advancement
// without picking up Battle/Defensive/Berserker Stance, so stance-gated
// Warrior spells (e.g. Whirlwind, Sunder Armor, Shield Slam) would otherwise
// be uncastable. Bypass the stance check for Paragon casters on any spell
// that has a non-zero Stances bitmask, regardless of SpellFamilyName.
//
// We previously gated this on SpellFamilyName == SPELLFAMILY_WARRIOR, but a
// number of SPELLFAMILY_GENERIC spells (notably the iconic Warrior toolbox
// -- Berserker Rage 18499, Sunder Armor 7405 / 11596 / 11597 / 25225 /
// 47467, Charge 100 / 6178 / 11578, Pummel 6552 / 6554, Shield Bash 72 /
// 1671 / 1672 / 29704, Retaliation 20230, Recklessness 1719, Shield Wall
// 871, etc.) carry the Stances bitmask but live under SPELLFAMILY_GENERIC
// (family 0). The previous narrower gate let those re-trigger the stance
// failure for Paragons. Widening to "any non-zero Stances + Paragon" is
// safe because:
//
// * The bypass returns SPELL_CAST_OK only when IsParagonWildcardCaller
// is true -- stock classes never enter this branch.
// * Druid form-gated spells (Cat Form / Bear Form / Moonkin / Tree)
// still fire the Druid GCD/form rules elsewhere; CheckShapeshift is
// about *requiring* a form to cast, which is exactly what we want
// to bypass for Paragons (they never picked the form).
// * Item enchant scrolls and other shapeshift-marked utility spells
// remain unaffected because they aren't in a Paragon's spellbook.
if (Stances != 0 && IsParagonWildcardCaller(caster))
{
LOG_DEBUG("server.scripts",
"[paragon-diag] CheckShapeshift bypass: spell={} family={} stances=0x{:x} form={}",
Id, SpellFamilyName, Stances, form);
return SPELL_CAST_OK;
}
uint32 stanceMask = (form ? 1 << (form - 1) : 0);
if (stanceMask & StancesNot) // can explicitly not be casted in this stance
+13 -1
View File
@@ -494,9 +494,21 @@ public:
bool HasInitialAggro() const;
[[nodiscard]] bool IsAffected(uint32 familyName, flag96 const& familyFlags) const;
// Fractured / Paragon overload. When `listenerOwner` is a CLASS_PARAGON
// unit and Paragon.WildcardFamilyMatching is enabled, the
// SpellFamilyName equality check is skipped (flag-bit check still runs)
// so cross-class procs / spellmods can react to the spell. Passing
// nullptr (or any non-Paragon unit) reproduces the stock 2-arg
// behavior; the 2-arg form forwards to this overload with nullptr.
[[nodiscard]] bool IsAffected(uint32 familyName, flag96 const& familyFlags,
Unit const* listenerOwner) const;
bool IsAffectedBySpellMods() const;
bool IsAffectedBySpellMod(SpellModifier const* mod) const;
// Fractured / Paragon overload: pass the player who owns the modifier
// aura so wildcard-family matching can apply when that player is a
// Paragon. Stock callers may forward to this with nullptr.
bool IsAffectedBySpellMod(SpellModifier const* mod, Unit const* listenerOwner) const;
bool CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const;
bool CanDispelAura(SpellInfo const* auraSpellInfo) const;
@@ -509,7 +521,7 @@ public:
bool IsAuraExclusiveBySpecificWith(SpellInfo const* spellInfo) const;
bool IsAuraExclusiveBySpecificPerCasterWith(SpellInfo const* spellInfo) const;
SpellCastResult CheckShapeshift(uint32 form) const;
SpellCastResult CheckShapeshift(uint32 form, Unit const* caster = nullptr) const;
SpellCastResult CheckLocation(uint32 map_id, uint32 zone_id, uint32 area_id, Player* player = nullptr, bool strict = true) const;
SpellCastResult CheckTarget(Unit const* caster, WorldObject const* target, bool implicit = true) const;
SpellCastResult CheckExplicitTarget(Unit const* caster, WorldObject const* target, Item const* itemTarget = nullptr) const;
+6 -2
View File
@@ -842,7 +842,8 @@ SpellProcEntry const* SpellMgr::GetSpellProcEntry(uint32 spellId) const
return nullptr;
}
bool SpellMgr::CanSpellTriggerProcOnEvent(SpellProcEntry const& procEntry, ProcEventInfo& eventInfo) const
bool SpellMgr::CanSpellTriggerProcOnEvent(SpellProcEntry const& procEntry, ProcEventInfo& eventInfo,
Unit const* procOwner /*= nullptr*/) const
{
// proc type doesn't match
if (!(eventInfo.GetTypeMask() & procEntry.ProcFlags))
@@ -873,7 +874,10 @@ bool SpellMgr::CanSpellTriggerProcOnEvent(SpellProcEntry const& procEntry, ProcE
// check spell family name/flags (if set) for spells
if (eventInfo.GetTypeMask() & SPELL_PROC_FLAG_MASK)
if (SpellInfo const* eventSpellInfo = eventInfo.GetSpellInfo())
if (!eventSpellInfo->IsAffected(procEntry.SpellFamilyName, procEntry.SpellFamilyMask))
// Fractured / Paragon: thread the proc-aura owner so a Paragon
// listener accepts cross-family source spells. See
// SpellInfo::IsAffected(family, flags, listenerOwner).
if (!eventSpellInfo->IsAffected(procEntry.SpellFamilyName, procEntry.SpellFamilyMask, procOwner))
return false;
// check spell type mask (if set)
+6 -1
View File
@@ -699,7 +699,12 @@ public:
// Spell proc table
[[nodiscard]] SpellProcEntry const* GetSpellProcEntry(uint32 spellId) const;
bool CanSpellTriggerProcOnEvent(SpellProcEntry const& procEntry, ProcEventInfo& eventInfo) const;
// Fractured / Paragon: `procOwner` is the unit that holds the listening
// proc aura. Passing it lets SpellInfo::IsAffected wildcard the family
// check when the listener is on a CLASS_PARAGON player. Non-Paragon
// owners (or nullptr) reproduce stock behavior exactly.
bool CanSpellTriggerProcOnEvent(SpellProcEntry const& procEntry, ProcEventInfo& eventInfo,
Unit const* procOwner = nullptr) const;
// Spell bonus data table
[[nodiscard]] SpellBonusEntry const* GetSpellBonusData(uint32 spellId) const;
+6
View File
@@ -684,4 +684,10 @@ void WorldConfig::BuildConfigCache()
// Achievement
SetConfigValue<uint32>(CONFIG_ACHIEVEMENT_REALM_FIRST_KILL_WINDOW, "Achievement.RealmFirstKillWindow", 60);
// Fractured / Paragon: cross-class wildcard for SpellFamilyName gating.
// Default ON because the Paragon class is designed around it; flip to 0
// (no rebuild required) if a regression appears and stock family
// gating needs to be restored without backing out the code.
SetConfigValue<bool>(CONFIG_PARAGON_WILDCARD_FAMILY, "Paragon.WildcardFamilyMatching", true);
}
+6
View File
@@ -495,6 +495,12 @@ enum ServerConfigs
CONFIG_NEW_CHAR_STRING,
CONFIG_VALIDATE_SKILL_LEARNED_BY_SPELLS,
CONFIG_ACHIEVEMENT_REALM_FIRST_KILL_WINDOW,
// Fractured / Paragon: when true, CLASS_PARAGON characters bypass the
// SpellFamilyName equality check in proc / spellmod / aura listener
// gates so cross-class talent procs and modifiers can interact with
// spells learned from other classes (e.g. Predator's Swiftness 69369
// making Shaman Chain Lightning instant). Stock classes are unaffected.
CONFIG_PARAGON_WILDCARD_FAMILY,
MAX_NUM_SERVER_CONFIGS
};
+312 -3
View File
@@ -64,10 +64,227 @@ struct npc_pet_mage_mirror_image : CasterAI
uint32 dist = urand(1, 5);
bool _delayAttack;
// Fractured / Paragon: when the owner is a Paragon character with the
// wildcard config enabled, replace the stock Frostbolt + Fireblast
// allowlist (loaded by CombatAI from creature_template_spell for
// creature 31216) with a curated list of damaging spells from the
// owner's spellbook. UpdateAI's override picks a random spell from
// the list per cast so the rotation isn't deterministic.
//
// The image still casts as itself (not via the owner), so spell
// coefficients apply to the image's stats -- spells naturally do less
// damage than they would in the owner's hands. We accept that as the
// cost of "free cross-class spell variety" rather than try to rebalance
// every player spell here.
static bool IsDamagingForMirrorImage(SpellInfo const* si)
{
// Direct damage effect.
if (si->HasEffect(SPELL_EFFECT_SCHOOL_DAMAGE))
return true;
// Spells like Arcane Missiles (TRIGGER_MISSILE) and most channeled
// multi-tick nukes route their damage through a child spell, so the
// parent has no SCHOOL_DAMAGE effect of its own. Accept that here.
if (si->HasEffect(SPELL_EFFECT_TRIGGER_MISSILE))
return true;
// DoTs and channels-as-aura (Mind Flay, Curse of Doom, Immolate,
// Corruption, Vampiric Touch, Drain Life leech, etc.). Also accept
// PERIODIC_TRIGGER_SPELL auras -- that's how Arcane Missiles fires
// each individual missile (parent has Aura=23 -> child damaging
// spell). Same pattern is used by Hunter Volley, Curse of Doom (in
// some ranks), and similar tick-by-trigger spells.
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
{
uint32 aura = si->Effects[i].ApplyAuraName;
if (aura == SPELL_AURA_PERIODIC_DAMAGE
|| aura == SPELL_AURA_PERIODIC_DAMAGE_PERCENT
|| aura == SPELL_AURA_PERIODIC_LEECH
|| aura == SPELL_AURA_PERIODIC_TRIGGER_SPELL)
return true;
}
return false;
}
void RebuildSpellsFromOwnerSpellbookForParagon(Player* owner)
{
SpellVct curated;
curated.reserve(8);
uint32 scanned = 0, kept = 0, rejInactive = 0, rejPassive = 0, rejWeaponStrike = 0,
rejNoDmg = 0, rejAoe = 0, rejGate = 0, rejLongCD = 0, rejLowRank = 0;
// For diagnosis: collect IDs of spells we'd expect to keep (Fireball,
// Frostbolt, Lightning Bolt, Mind Blast, Shadow Bolt, etc.) but that
// we instead reject. The sample is small so per-spell logging is OK.
auto trackProbe = [&](uint32 spellId, char const* phase)
{
// Only log "interesting" spell IDs to avoid 177-line spam per image.
// These are first-rank IDs of common cross-class single-target nukes.
static constexpr uint32 probes[] = {
133, 116, 30451, // Mage: Fireball, Frostbolt, Arcane Blast
5143, // Mage: Arcane Missiles (channel via PERIODIC_TRIGGER_SPELL)
403, 529, 8042, // Shaman: Lightning Bolt, Chain Lightning, Earth Shock
585, 14914, // Priest: Smite, Holy Fire
8092, 15407, // Priest: Mind Blast, Mind Flay
686, 348, // Warlock: Shadow Bolt, Immolate (DoT w/ cast time)
5176, 2912, // Druid: Wrath, Starfire
635, // Paladin: Holy Light
};
for (uint32 probe : probes)
{
if (spellId == probe)
{
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage probe spell={} phase={}",
spellId, phase);
return;
}
// Also walk rank chain: if the spellbook has rank N of probe,
// probe matches via GetFirstRankSpell.
if (SpellInfo const* si = sSpellMgr->GetSpellInfo(spellId))
if (SpellInfo const* first = si->GetFirstRankSpell())
if (first->Id == probe)
{
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage probe spell={} (rank of {}) phase={}",
spellId, probe, phase);
return;
}
}
};
for (auto const& kv : owner->GetSpellMap())
{
++scanned;
uint32 spellId = kv.first;
PlayerSpell const* ps = kv.second;
if (!ps || ps->State == PLAYERSPELL_REMOVED || !ps->Active)
{
++rejInactive;
trackProbe(spellId, "inactive");
continue;
}
SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spellId);
if (!spellInfo)
continue;
// Spec (per user): damaging single-target spells, instant or
// cast-time or channeled all OK, no melee/ranged "strike" style
// weapon-attack abilities, and no long-cooldown spells (>10s) so
// the image cycles through a varied rotation rather than blowing
// a 2-min cooldown once.
if (spellInfo->IsPassive()) { ++rejPassive; trackProbe(spellId, "passive"); continue; }
if (!IsDamagingForMirrorImage(spellInfo)) { ++rejNoDmg; trackProbe(spellId, "noDmg"); continue; }
if (spellInfo->IsAffectingArea()) { ++rejAoe; trackProbe(spellId, "aoe"); continue; }
if (spellInfo->DmgClass == SPELL_DAMAGE_CLASS_MELEE
|| spellInfo->DmgClass == SPELL_DAMAGE_CLASS_RANGED) { ++rejWeaponStrike; trackProbe(spellId, "weaponStrike"); continue; }
// Reject anything with a base cooldown longer than 10s (either
// RecoveryTime or CategoryRecoveryTime). A 0/very-short CD is
// fine. The mage Mirror Image only lives for 30s, so anything
// gated by a long CD would only ever fire once anyway.
uint32 cd = std::max(spellInfo->RecoveryTime, spellInfo->CategoryRecoveryTime);
if (cd > 10000) { ++rejLongCD; trackProbe(spellId, "longCD"); continue; }
// Skip spells the image would never realistically be able to
// cast successfully or whose side-effects don't make sense on a
// pet (totems, summons, item / reagent / focus requirements,
// ranged-weapon / shapeshift / stealth gates, profession spells,
// teleports, etc.).
char const* gateReason = nullptr;
if (spellInfo->RequiresSpellFocus) gateReason = "focus";
else if (spellInfo->Reagent[0] > 0) gateReason = "reagent";
else if (spellInfo->Stances || spellInfo->StancesNot) gateReason = "stance";
else if (spellInfo->EquippedItemClass >= 0) gateReason = "equipped";
else if (spellInfo->IsCooldownStartedOnEvent()) gateReason = "cdEvent";
else if (spellInfo->HasAttribute(SPELL_ATTR0_PASSIVE)) gateReason = "attrPassive";
else if (spellInfo->HasAttribute(SPELL_ATTR0_DO_NOT_DISPLAY)) gateReason = "attrHidden";
// SPELL_ATTR0_NOT_SHAPESHIFTED is intentionally NOT a gate -- it
// means "cannot be cast while caster IS shapeshifted", not "this
// spell requires a shapeshift". The attribute is set on every
// standard caster nuke (Fireball, Frostbolt, Lightning Bolt,
// Shadow Bolt, etc.) and Mirror Images are never shapeshifted,
// so the runtime check trivially passes for them. Filtering on
// it here was the bug that left the curated list empty.
else if (spellInfo->HasAttribute(SPELL_ATTR0_ONLY_STEALTHED)) gateReason = "attrStealth";
// SPELL_ATTR1_NO_AUTOCAST_AI is intentionally NOT a gate -- it is set
// on most player nukes (Fireball / Lightning Bolt / Shadow Bolt) to
// stop class pets from auto-casting them. Mirror Images are
// server-curated player-spell mimics, so we WANT to auto-cast
// those exact spells.
else if (spellInfo->HasAttribute(SPELL_ATTR2_FAIL_ON_ALL_TARGETS_IMMUNE)) gateReason = "attrFailImmune";
else if (spellInfo->HasEffect(SPELL_EFFECT_SUMMON)) gateReason = "fxSummon";
else if (spellInfo->HasEffect(SPELL_EFFECT_SUMMON_PET)) gateReason = "fxSummonPet";
else if (spellInfo->HasEffect(SPELL_EFFECT_TELEPORT_UNITS)) gateReason = "fxTeleport";
else if (spellInfo->HasEffect(SPELL_EFFECT_TRANS_DOOR)) gateReason = "fxTransDoor";
else if (spellInfo->HasEffect(SPELL_EFFECT_OPEN_LOCK)) gateReason = "fxOpenLock";
else if (spellInfo->HasEffect(SPELL_EFFECT_INSTAKILL)) gateReason = "fxInstakill";
else if (spellInfo->HasEffect(SPELL_EFFECT_LEARN_SPELL)) gateReason = "fxLearn";
if (gateReason) { ++rejGate; trackProbe(spellId, gateReason); continue; }
// Ignore spell ranks below the highest the player owns -- the
// spellbook contains all learned ranks; we want only the latest.
if (SpellInfo const* nextRank = spellInfo->GetNextRankSpell())
if (owner->HasSpell(nextRank->Id))
{ ++rejLowRank; trackProbe(spellId, "lowRank"); continue; }
++kept;
curated.push_back(spellId);
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage kept spell={} ({})",
spellId,
spellInfo->SpellName[0] ? spellInfo->SpellName[0] : "?");
}
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage rebuild owner={} scanned={} kept={} "
"rejInactive={} rejPassive={} rejNoDmg={} rejAoe={} rejWeaponStrike={} rejLongCD={} rejGate={} rejLowRank={}",
owner->GetName(), scanned, kept,
rejInactive, rejPassive, rejNoDmg, rejAoe, rejWeaponStrike, rejLongCD, rejGate, rejLowRank);
if (curated.empty())
{
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage rebuild for {} produced empty list, keeping stock 59637/59638",
owner->GetName());
return; // keep stock 59637 / 59638 fallback
}
// Log the first few spell IDs we picked so we can verify the list.
std::string sample;
for (size_t i = 0; i < curated.size() && i < 8; ++i)
{
if (!sample.empty())
sample += ',';
sample += std::to_string(curated[i]);
}
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage rebuild swapping spells for {} (sample: {})",
owner->GetName(), sample);
spells.swap(curated);
}
void InitializeAI() override
{
CasterAI::InitializeAI();
// Fractured / Paragon: do the spellbook rebuild EARLY -- before
// owner->CastSpell(CLONE_ME) and before any threat-list inheritance,
// because any of those can synchronously fire JustEngagedWith on the
// image and cause CasterAI::JustEngagedWith to schedule events from
// the stock [59638 Frostbolt, 59637 Fireblast] m_spells[] entries
// before our swap takes effect. The override of JustEngagedWith
// below also reasserts the swap + flushes events, so even if a later
// combat-entry path fires JustEngagedWith again it picks up the
// curated list.
if (Unit* owner = me->GetOwner())
if (Player* playerOwner = owner->ToPlayer())
if (IsParagonWildcardCaller(playerOwner))
RebuildSpellsFromOwnerSpellbookForParagon(playerOwner);
_delayAttack = true;
me->m_Events.AddEventAtOffset([this]()
{
@@ -76,11 +293,21 @@ struct npc_pet_mage_mirror_image : CasterAI
Unit* owner = me->GetOwner();
if (!owner)
{
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage InitializeAI: no owner, spells.size={} (stock)",
spells.size());
return;
}
// Clone Me!
owner->CastSpell(me, SPELL_MAGE_CLONE_ME, true);
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage InitializeAI: post-rebuild spells.size={} first={}",
spells.size(),
spells.empty() ? 0u : spells.front());
// xinef: Glyph of Mirror Image (4th copy)
float angle = 0.0f;
switch (me->GetUInt32Value(UNIT_CREATED_BY_SPELL))
@@ -139,6 +366,37 @@ struct npc_pet_mage_mirror_image : CasterAI
me->m_Events.AddEventAtOffset(new DeathEvent(*me), 29500ms);
}
void JustEngagedWith(Unit* who) override
{
// Fractured / Paragon: re-apply the spellbook rebuild here as well,
// because the engagement can fire synchronously from inside
// InitializeAI (via owner->CastSpell(CLONE_ME) or summon-side threat
// propagation) BEFORE InitializeAI's own rebuild call has run.
// Re-running it here is cheap and idempotent: the curated list is
// re-derived from the owner's current spellbook, and we wipe any
// previously-scheduled events so the stock 59637 / 59638 entries
// CasterAI may already have queued get evicted before scheduling.
if (Unit* owner = me->GetOwner())
if (Player* playerOwner = owner->ToPlayer())
if (IsParagonWildcardCaller(playerOwner))
{
RebuildSpellsFromOwnerSpellbookForParagon(playerOwner);
events.Reset();
}
std::string sample;
for (size_t i = 0; i < spells.size() && i < 8; ++i)
{
if (!sample.empty()) sample += ',';
sample += std::to_string(spells[i]);
}
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage JustEngagedWith: spells.size={} sample=[{}] who={}",
spells.size(), sample, who ? who->GetName() : "<null>");
CasterAI::JustEngagedWith(who);
}
// Do not reload Creature templates on evade mode enter - prevent visual lost
void EnterEvadeMode(EvadeReason /*why*/) override
{
@@ -217,10 +475,61 @@ struct npc_pet_mage_mirror_image : CasterAI
if (me->HasUnitState(UNIT_STATE_CASTING))
return;
if (uint32 spellId = events.ExecuteEvent())
if (uint32 queuedId = events.ExecuteEvent())
{
events.RescheduleEvent(spellId, spellId == 59637 ? 6500ms : 2500ms);
me->CastSpell(me->GetVictim(), spellId, false);
// Fractured / Paragon: when the curated spellbook list is in
// play, pick a random spell from it for THIS cast instead of
// using the EventMap-scheduled spellId directly. The events
// queue (populated by CasterAI::JustEngagedWith) is otherwise
// deterministic for our small list and the image ends up
// rotating in lockstep; randomizing here makes each image
// (and each cast) feel like a mage ad-libbing from the
// player's repertoire.
uint32 actualId = queuedId;
bool isParagon = false;
if (Unit* owner = me->GetOwner())
if (Player* playerOwner = owner->ToPlayer())
if (IsParagonWildcardCaller(playerOwner) && !spells.empty())
{
actualId = spells[urand(0, uint32(spells.size()) - 1)];
isParagon = true;
}
// Reschedule the queue based on the spell we actually cast,
// not the one originally queued. For channeled spells this
// matters: Arcane Missiles is a 5s channel, so if we keep
// rescheduling every 2.5s the image is always either mid-
// channel or immediately re-rolling for another channel,
// and over four images you see effectively continuous
// Arcane Missiles. Wait for cast/channel to finish + a
// small breather before picking again.
Milliseconds nextDelay = (queuedId == 59637 ? 6500ms : 2500ms);
if (isParagon)
{
if (SpellInfo const* picked = sSpellMgr->GetSpellInfo(actualId))
{
uint32 castMs = picked->CalcCastTime();
uint32 chanMs = 0;
if (picked->IsChanneled())
{
int32 dur = picked->GetDuration();
if (dur > 0)
chanMs = uint32(dur);
}
uint32 minMs = std::max(castMs, chanMs) + 750; // breather
if (Milliseconds(minMs) > nextDelay)
nextDelay = Milliseconds(minMs);
}
}
events.RescheduleEvent(queuedId, nextDelay);
SpellCastResult castRes = me->CastSpell(me->GetVictim(), actualId, false);
LOG_DEBUG("server.scripts",
"[paragon-diag] MirrorImage cast spell={} victim={} result={} nextDelay={}ms",
actualId,
me->GetVictim() ? me->GetVictim()->GetName() : "<null>",
uint32(castRes),
uint32(nextDelay.count()));
}
}
};
+115 -2
View File
@@ -490,12 +490,22 @@ class spell_mage_cold_snap : public SpellScript
{
Player* caster = GetCaster()->ToPlayer();
// immediately finishes the cooldown on Frost spells
//
// Fractured / Paragon: ParagonFamilyMatches() drops the
// SpellFamilyName == SPELLFAMILY_MAGE gate when the caster is a
// CLASS_PARAGON player AND Paragon.WildcardFamilyMatching is on,
// so any Frost-school spell in the Paragon's spellbook with a real
// recovery time (Howling Blast, Frost Shock, Frost Trap, etc.)
// also gets its cooldown wiped. Stock Mage callers fall through to
// strict family-name equality and observe identical behavior.
PlayerSpellMap const& spellMap = caster->GetSpellMap();
for (PlayerSpellMap::const_iterator itr = spellMap.begin(); itr != spellMap.end(); ++itr)
{
SpellInfo const* spellInfo = sSpellMgr->AssertSpellInfo(itr->first);
if (spellInfo->SpellFamilyName == SPELLFAMILY_MAGE && (spellInfo->GetSchoolMask() & SPELL_SCHOOL_MASK_FROST) && spellInfo->Id != SPELL_MAGE_COLD_SNAP && spellInfo->GetRecoveryTime() > 0)
if (ParagonFamilyMatches(caster, SPELLFAMILY_MAGE, spellInfo->SpellFamilyName)
&& (spellInfo->GetSchoolMask() & SPELL_SCHOOL_MASK_FROST)
&& spellInfo->Id != SPELL_MAGE_COLD_SNAP
&& spellInfo->GetRecoveryTime() > 0)
{
SpellCooldowns::iterator citr = caster->GetSpellCooldownMap().find(spellInfo->Id);
if (citr != caster->GetSpellCooldownMap().end() && citr->second.needSendToClient)
@@ -946,6 +956,107 @@ class spell_mage_summon_water_elemental : public SpellScript
}
};
// 44543, 44545 - Fingers of Frost (talent ranks - the proc-trigger aura, NOT the
// 74396 buff aura that is APPLIED when this talent fires).
//
// Stock spell_proc gates this talent by SpellFamilyName=MAGE plus a
// SpellFamilyMask covering the Mage Frost spells that count as "chill-effect
// dealers" (Frostbolt / Frost Nova / Cone of Cold / Blizzard / Frostfire Bolt /
// Deep Freeze etc.). For Paragon characters with `Paragon.WildcardFamilyMatching`
// enabled, we relax the spell_proc row to wildcard family/mask + SchoolMask=
// FROST + SpellTypeMask=DAMAGE so that any Frost-school damage spell (DK Howling
// Blast / Icy Touch, Hunter Frost Trap / Wing Clip-as-frost, Shaman Frost Shock,
// Druid Hibernate damage payload, etc.) reaches this CheckProc; this script
// then re-enforces the stock Mage allowlist for non-Paragon owners and lets
// Paragons through unconditionally (the FROST + DAMAGE gate already happens at
// the spell_proc layer, so any spell reaching us here is safe to accept).
class spell_mage_fingers_of_frost_talent : public AuraScript
{
PrepareAuraScript(spell_mage_fingers_of_frost_talent);
bool CheckProc(ProcEventInfo& eventInfo)
{
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
if (!procSpell)
return false;
// Stock Mage allowlist: re-derive from this talent's own effect-0
// SpellClassMask so behavior matches the original auto-generated
// proc filter exactly (no risk of mask drift across DBC versions).
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE
&& (GetSpellInfo()->Effects[EFFECT_0].SpellClassMask & procSpell->SpellFamilyFlags))
return true;
return IsParagonWildcardCaller(GetUnitOwner());
}
void Register() override
{
DoCheckProc += AuraCheckProcFn(spell_mage_fingers_of_frost_talent::CheckProc);
}
};
// 11071, 12496, 12497 - Frostbite (talent ranks - the proc-trigger aura that
// chains into 12494 Frostbite freeze).
//
// Stock spell_proc (auto-generated from DBC) gates this talent by Mage family +
// the talent's effect SpellClassMask (Mage Frost slow-applying spells). For
// Paragon characters we relax the row to SchoolMask=FROST wildcard so that
// chill-applying Frost spells from any class can reach this CheckProc; the
// Paragon path additionally requires the proc spell to actually apply a slow
// (SPELL_AURA_MOD_DECREASE_SPEED) so that pure damage Frost spells without a
// chill component (e.g. raw Ice Lance on a non-frozen target) do NOT freeze.
// Stock Mage owners get the original behavior re-enforced here.
class spell_mage_frostbite : public AuraScript
{
PrepareAuraScript(spell_mage_frostbite);
bool CheckProc(ProcEventInfo& eventInfo)
{
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
if (!procSpell)
return false;
// Stock Mage path: re-derive from this talent's own effect-0
// SpellClassMask so behavior matches the original auto-generated
// proc filter exactly (no risk of mask drift across DBC versions).
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE
&& (GetSpellInfo()->Effects[EFFECT_0].SpellClassMask & procSpell->SpellFamilyFlags))
return true;
if (!IsParagonWildcardCaller(GetUnitOwner()))
return false;
// Paragon path: any Frost-school spell that applies a chill effect
// (decrease-speed aura). The spell_proc row already gates by
// SchoolMask=FROST so we only need to verify chill semantics here.
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
{
if (procSpell->Effects[i].ApplyAuraName == SPELL_AURA_MOD_DECREASE_SPEED)
return true;
}
// Also accept the Improved-Blizzard-style cross-class case where the
// chill is applied by a separate triggered aura: if the proc spell's
// damage hit landed and the target already has a chill from us, treat
// it as eligible. Cheap and matches player expectations for Paragon.
if (Unit* procTarget = eventInfo.GetProcTarget())
{
Unit::AuraEffectList const& slows = procTarget->GetAuraEffectsByType(SPELL_AURA_MOD_DECREASE_SPEED);
for (AuraEffect const* slowEff : slows)
if (slowEff->GetCasterGUID() == GetUnitOwner()->GetGUID())
return true;
}
return false;
}
void Register() override
{
DoCheckProc += AuraCheckProcFn(spell_mage_frostbite::CheckProc);
}
};
// 74396 - Fingers of Frost
class spell_mage_fingers_of_frost : public AuraScript
{
@@ -1631,5 +1742,7 @@ void AddSC_mage_spell_scripts()
RegisterSpellScript(spell_mage_polymorph_cast_visual);
RegisterSpellScript(spell_mage_summon_water_elemental);
RegisterSpellScript(spell_mage_fingers_of_frost);
RegisterSpellScript(spell_mage_fingers_of_frost_talent);
RegisterSpellScript(spell_mage_frostbite);
RegisterSpellScript(spell_mage_magic_absorption);
}
+28 -2
View File
@@ -1005,12 +1005,38 @@ class spell_pri_vampiric_embrace : public AuraScript
bool CheckProc(ProcEventInfo& eventInfo)
{
// Not proc from Mind Sear
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
if (!procSpell)
return false;
return !(procSpell->SpellFamilyFlags[1] & 0x80000);
// Stock: filter Mind Sear (the damage-tick spell carries this
// SpellFamilyFlags[1] bit; the channel itself is filtered by the
// standard data-row mask). Kept as a bit-test so the stock priest
// path is byte-identical to before this change.
if (procSpell->SpellFamilyFlags[1] & 0x80000)
return false;
// Fractured / Paragon: any single-target Shadow-school damage spell
// procs Vampiric Embrace, not just Priest Shadow spells. The
// SchoolMask=Shadow gate is enforced by the spell_proc data row
// (SchoolMask=32). The data-row family/mask was wildcarded in
// mod-paragon's 2026_05_11_01.sql update so this CheckProc fires for
// cross-family Shadow spells; here we add the single-target
// requirement (Mind Sear was already filtered above; this also
// catches AoE Warlock Shadow spells like Seed of Corruption,
// Hellfire, etc. that a Paragon could otherwise cast).
if (IsParagonWildcardCaller(GetTarget()))
return !procSpell->IsAffectingArea();
// Stock priest path: re-enforce the original Priest Shadow damage
// gate that used to live entirely in the data row. Without this,
// wildcarding the data row would let item-cast Shadow effects
// (consumables, trinkets) accidentally proc VE on stock priests.
if (procSpell->SpellFamilyName != SPELLFAMILY_PRIEST)
return false;
return (procSpell->SpellFamilyFlags[0] & 0x0280A010)
|| (procSpell->SpellFamilyFlags[1] & 0x00002402)
|| (procSpell->SpellFamilyFlags[2] & 0x00000008);
}
void HandleProc(AuraEffect const* aurEff, ProcEventInfo& eventInfo)
@@ -1790,6 +1790,44 @@ class spell_sha_maelstrom_weapon : public AuraScript
});
}
// Fractured / Paragon: spell_proc row 53817 is data-relaxed (wildcard
// family/mask) so cross-class spells can reach this CheckProc. We
// restore the original Shaman gating here for stock callers and add
// the Paragon-only Mage Fireball / Frostbolt / Arcane Blast allowlist
// mirroring the IsAffectedBySpellMod hook in SpellInfo.cpp.
bool CheckProc(ProcEventInfo& eventInfo)
{
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
if (!procSpell)
return false;
// Stock allowlist (Shaman): Lightning Bolt, Chain Lightning,
// Lesser Healing Wave, Healing Wave, Hex. Encoded as the original
// SpellFamilyMask values from the pre-relaxation spell_proc row
// (Mask0 = 451, Mask1 = 32768).
bool stockMatch = procSpell->SpellFamilyName == SPELLFAMILY_SHAMAN
&& ((procSpell->SpellFamilyFlags[0] & 451u)
|| (procSpell->SpellFamilyFlags[1] & 32768u));
if (stockMatch)
return true;
if (!IsParagonWildcardCaller(GetUnitOwner()))
return false;
// Paragon path: also accept the curated Mage cast-time nukes.
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE)
{
SpellInfo const* first = procSpell->GetFirstRankSpell();
uint32 firstId = first ? first->Id : procSpell->Id;
if (firstId == 133 /*Fireball*/
|| firstId == 116 /*Frostbolt*/
|| firstId == 30451 /*Arcane Blast*/)
return true;
}
return false;
}
void HandleBonus(AuraEffect const* /*aurEff*/, AuraEffectHandleModes /*mode*/)
{
if (GetStackAmount() < int32(GetSpellInfo()->StackAmount))
@@ -1805,6 +1843,7 @@ class spell_sha_maelstrom_weapon : public AuraScript
void Register() override
{
DoCheckProc += AuraCheckProcFn(spell_sha_maelstrom_weapon::CheckProc);
OnEffectApply += AuraEffectApplyFn(spell_sha_maelstrom_weapon::HandleBonus, EFFECT_0, SPELL_AURA_ADD_PCT_MODIFIER, AURA_EFFECT_HANDLE_CHANGE_AMOUNT);
}
};
+19 -12
View File
@@ -14,7 +14,7 @@ npm install
npm start
```
On first run, `launcher.json` is created next to the app (dev: in this folder).
On first run, **`launcher.json`** is created: **dev** next to the app in this folder; **Windows packaged** — beside the `.exe`; **Linux AppImage / macOS packaged** — under Electron **`app.getPath('userData')`** (typically under **`~/.config/`**, folder name from the app; AppImage mount is read-only so config cannot live beside the binary).
### Where patches download from
@@ -88,7 +88,7 @@ CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml
1. Triggers on **release published** on **`Dawnforger/Fractured`** (or **workflow_dispatch** with a tag).
2. Builds **Windows** (NSIS + portable) and **Linux** (AppImage) in parallel, each using **`tools/fractured-launcher-electron` from the default branch** (overlaid onto the tag checkout), so older release tags never ship a launcher missing new **`lib/*.js`** files.
3. Downloads **all assets** attached to that **GitHub** release (MPQs, patched `Wow.exe`, etc.).
4. Merges with the built launcher artifacts and uploads everything to a **Gitea release** with the **same tag** (existing attachments on that Gitea release are replaced).
4. Merges with the built launcher artifacts and uploads to a **Gitea release** with the **same tag** (existing attachments on that Gitea release are replaced). **Launcher installers** attached on GitHub (**`Fractured-Launcher*`**, case-insensitive) are **not** merged — the **CI build from the default branch** is the only source of launcher binaries, so an old installer on the GitHub release cannot “stick” on Gitea next to a newer build. **`*.blockmap`** and **`builder-debug.yml`** are omitted from the merge and from Gitea uploads.
**GitHub Actions secrets** (repository → Settings → Secrets and variables → Actions):
@@ -118,12 +118,18 @@ CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml
### Sync did not run / Gitea unchanged — checklist
1. **Git tag ≠ GitHub Release** — Only **Releases** (published on the GitHub **Releases** page) trigger this workflow. If your teammate only **`git push --tags`**, create a **Release** from that tag and click **Publish** (or run **Actions → Sync release to Gitea → Run workflow** and enter the tag).
2. **Draft release** — Must click **Publish release**; drafts do not mirror.
3. **Workflow on default branch** — GitHub runs `release` workflows from the **default branch** (e.g. `main`). Ensure `.github/workflows/gitea-release-sync.yml` is merged there.
4. **Repo name guard** — Jobs use `if: github.repository == 'Dawnforger/Fractured'`. Forks or renames must change that line or runs are skipped.
5. **Secrets****`GITEA_BASE_URL`**, **`GITEA_TOKEN`**, **`GITEA_OWNER`**, **`GITEA_REPO`** must be set under **Settings → Secrets and variables → Actions**. A failed “Upload to Gitea” step usually prints which is missing.
6. **Actions tab** — Open the latest **Sync release to Gitea** run; a red **build-electron** (old tag without `package-lock.json`, etc.) or **Upload to Gitea** step shows the real error.
7. **HTTP 422 `repo is empty`** — The Gitea repo has **no commits** yet. Push any initial commit (e.g. **Add README** in the Gitea web UI, or `git push` to **`main`**). Optionally set **`GITEA_TARGET_REF`** to match your real default branch if it is not **`main`**. From this repo you can run **`scripts/bootstrap-gitea-repo.sh`** (see script header for `GITEA_*` env or pass the HTTPS/SSH clone URL as the first argument).
2. **Manual run: tag vs title****Run workflow** must receive the **git tag** (e.g. `v0.7.11-paragon-…`), copied from the release pages tag badge. Pasting the **release title** (long line with spaces/parentheses) breaks `git fetch` with `invalid refspec`.
3. **Draft release** — Must click **Publish release**; drafts do not mirror.
4. **Workflow on default branch** — GitHub runs `release` workflows from the **default branch** (e.g. `main`). Ensure `.github/workflows/gitea-release-sync.yml` is merged there.
5. **Repo name guard** — Jobs use `if: github.repository == 'Dawnforger/Fractured'`. Forks or renames must change that line or runs are skipped.
6. **Secrets****`GITEA_BASE_URL`**, **`GITEA_TOKEN`**, **`GITEA_OWNER`**, **`GITEA_REPO`** must be set under **Settings → Secrets and variables → Actions**. A failed “Upload to Gitea step usually prints which is missing.
7. **Actions tab** — Open the latest **Sync release to Gitea** run; a red **build-electron** (old tag without `package-lock.json`, etc.) or **Upload to Gitea** step shows the real error.
8. **HTTP 422 `repo is empty`** — The Gitea repo has **no commits** yet. Push any initial commit (e.g. **Add README** in the Gitea web UI, or `git push` to **`main`**). Optionally set **`GITEA_TARGET_REF`** to match your real default branch if it is not **`main`**. From this repo you can run **`scripts/bootstrap-gitea-repo.sh`** (see script header for `GITEA_*` env or pass the HTTPS/SSH clone URL as the first argument).
9. **`sync Wow.exe: fetch failed`** — Often **HTTPS/TLS** to Gitea; use **`http://…`** in **`lib/baked-gitea-channel.js`** if you only serve plain HTTP, or fix certs / **`NODE_EXTRA_CA_CERTS`**. Ensure **`Wow-patched.exe`** exists on the release (**`release_tag`**: `latest` vs pinned). Errors include the failing URL when possible.
10. **Wine + Windows portable** — If the folder picker returns **`/home/...`**, the launcher maps it to **`Z:\home\...`** (Wines Unix root). **`Wow.exe`** is matched case-insensitively for Linux-backed folders. Re-save the WoW folder after upgrading if validation still fails.
11. **`EBUSY` / file locked on Windows** — The client (or antivirus) may keep **`.MPQ`** files open. The launcher **retries** for a short window and downloads the new file **before** replacing the old one; if sync still fails, **exit WoW** (and any tool previewing that folder) and run **Download updates** again.
12. **`.bak-*` clutter** — When **Download updates** finishes without error, the launcher removes matching **`*.bak-YYYYMMDD-HHmmss`** files from earlier runs (same pattern it uses when replacing files). Failed syncs do not delete backups.
13. **Gitea still shows an old launcher version** — The sync workflow overlays **`tools/fractured-launcher-electron` from the default branch**, so **`package.json`** there defines the built version. Ensure launcher changes are **merged to `main`** before publishing the GitHub release (or re-run **Actions → Sync release to Gitea** after merging). Previously, **Fractured-Launcher\*** files **attached on the GitHub release** were merged too, which could leave **two** installer versions on Gitea; those assets are now skipped in favor of CI-only builds.
### Private Gitea token for players
@@ -133,7 +139,7 @@ Do **not** embed a shared admin PAT in a shipped `launcher.json`. Prefer read-on
## Patch versions (same filenames, different bytes)
The launcher does **not** read Git commits. For **turn-key** updates when asset names stay fixed (`patch-Z.MPQ`, `Wow-patched.exe`, …):
The launcher does **not** read Git commits. For **turn-key** updates when asset names stay fixed (e.g. **`Wow-patched.exe`** — add more **`files`** entries for any extra MPQs you ship):
1. Ship **`patch-manifest.json`** next to those files on **every** release (Gitea/GitHub attachment). It lists a **`version`** label (any string you bump per release, e.g. `v0.9.0-client`) and a **`sha256`** per **`files[].source`** name.
2. With **`patch_manifest.enabled`**: true (default in **`default-launcher.json`**), **Download updates** first fetches the manifest from the same release channel. If the files already on disk match those checksums, the player sees **“already match build … (nothing to download)”** — no redundant downloads.
@@ -145,7 +151,7 @@ If **`patch-manifest.json`** is missing on a release, the launcher falls back to
```bash
cd /path/to/staging
node tools/fractured-launcher-electron/scripts/generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe > patch-manifest.json
node tools/fractured-launcher-electron/scripts/generate-patch-manifest.js v0.9.0-client Wow-patched.exe > patch-manifest.json
```
Attach **`patch-manifest.json`** together with the MPQ/exe to the GitHub release (CI sync copies it to Gitea with everything else).
@@ -158,7 +164,7 @@ Workflow **Fractured launcher CI** (`.github/workflows/fractured-launcher-ci.yml
## Config
Schema is defined by **`default-launcher.json`** (shipped in the app; first run copies to `launcher.json` beside the executable):
Schema is defined by **`default-launcher.json`** (shipped in the app; first run copies to **`launcher.json`** — beside the **Windows** exe, or under **`userData`** on **Linux/macOS** packaged builds):
- **`game_dir`**: WoW 3.3.5a root (contains `Wow.exe`).
- **`update_feed_url`**: optional generic HTTPS base for launcher auto-update.
@@ -166,4 +172,5 @@ Schema is defined by **`default-launcher.json`** (shipped in the app; first run
- **`gitea`**: **`base_url`**, **`owner`**, **`repo`**, **`release_tag`**, **`token_env`** — when **`base_url`** is set (and owner/repo set), **`from_release`** downloads and (with token if needed) the **generic** updater feed use **Gitea**. **Required** for players if your CI mirrors patches/launchers to Gitea only.
- **`github`**: used for **non-release** repo paths (`from_release`: false) and for **GitHub** **`from_release`** when **`gitea.base_url`** is empty.
- **`patch_manifest`**: **`enabled`**, **`source`** (default `patch-manifest.json`), **`from_release`** — checksum-based skip + verify (see above).
- **`files`**, **`realmlist`**, **`auth`**, **`launch`**.
- **`files`**: default **`[]`**. **Download updates** resolves what to pull in order: (**1**) non-empty **`files`** if you set explicit **`source`** → **`dest`** pairs; (**2**) else each key in **`patch-manifest.json`** on the release (recommended); (**3**) else release attachments except launcher artifacts (`Fractured-Launcher*`, `*.blockmap`, `latest*.yml`, `.AppImage`, `patch-manifest.json`): **`.MPQ`** → **`Data/enUS/<name>.MPQ`** (extension forced to **`.MPQ`** caps for client compatibility), one **`.exe`** → **`launch.exe`**. Multiple `.exe` attachments require a manifest. Legacy **`Wow-patched.exe`** entries are removed when merging **`launcher.json`**.
- **`realmlist`**, **`auth`**, **`launch`** (`**exe**`, **`args`**). Only **`Data/enUS/realmlist.wtf`** is written: any **`realmlist.paths`** entry that is not the enUS file is ignored (so **`enGB`** is never created). On **Linux**, **Play** never runs `Wow.exe` as a native process (that yields **EACCES**). Use **`launch.linux_wrapper`** (default **`["wine"]`**) so the launcher runs e.g. **`wine /path/Wow.exe``args`**, or set **`launch.linux_steam_uri`** to a Steam URI (e.g. **`steam://rungameid/…`** for a **non-Steam game** shortcut — the number is shown in Steams shortcut properties). Optional **`linux_steam_binary`** defaults to **`steam`**; for Flatpak Steam use **`linux_steam_spawn`**: **`["flatpak", "run", "com.valvesoftware.Steam"]`** (the URI is appended as the last argument). After a **successful** **Download updates** run, the launcher deletes prior **`*.bak-YYYYMMDD-HHmmss`** backup files it created under the WoW folder.
@@ -21,24 +21,11 @@
"source": "patch-manifest.json",
"from_release": true
},
"files": [
{
"source": "patch-Z.MPQ",
"dest": "Data/patch-Z.MPQ",
"backup": true,
"from_release": true
},
{
"source": "Wow-patched.exe",
"dest": "Wow.exe",
"backup": true,
"from_release": true
}
],
"files": [],
"realmlist": {
"enabled": true,
"line": "set realmlist fracturedwow.ddns.net:47497",
"paths": ["Data/enUS/realmlist.wtf", "Data/enGB/realmlist.wtf"]
"paths": ["Data/enUS/realmlist.wtf"]
},
"auth": {
"enabled": false,
@@ -50,6 +37,9 @@
"launch": {
"exe": "Wow.exe",
"args": [],
"linux_wrapper": ["wine"]
"linux_wrapper": ["wine"],
"linux_steam_uri": "",
"linux_steam_binary": "",
"linux_steam_spawn": []
}
}
@@ -6,8 +6,8 @@
* Token stays in env: GITEA_TOKEN or launcher.json gitea.token_env.
*/
module.exports = {
// Scheme optional — gitea-release normalizes to https:// if missing.
base_url: 'https://brassnet.ddns.net:33983',
// http:// kept as-is; bare host gets https in gitea-release.js
base_url: 'http://brassnet.ddns.net:33983',
owner: 'Dawnsorrow',
repo: 'Fractured-Distro',
release_tag: 'latest',
@@ -2,6 +2,26 @@
const path = require('path');
const fs = require('fs').promises;
const { normalizeWinGameDir } = require('./win-game-dir');
/** Sources no longer shipped; drop from merged files so old launcher.json does not keep fetching them. */
const DEPRECATED_FILE_SOURCES = new Set(['patch-Z.MPQ', 'Wow-patched.exe']);
function mergeFilesList(defaults, user) {
const dep = (e) => DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim());
if (Array.isArray(user.files) && user.files.length) {
const filtered = user.files.map((e) => ({ ...e })).filter((e) => !dep(e));
if (filtered.length) return filtered;
}
const defList = Array.isArray(defaults.files) ? defaults.files : [];
return defList.map((e) => ({ ...e })).filter((e) => !dep(e));
}
function userFilesContainDeprecated(user) {
const files = user && user.files;
if (!Array.isArray(files)) return false;
return files.some((e) => DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim()));
}
function mergeConfig(defaults, user) {
return {
@@ -21,7 +41,7 @@ function mergeConfig(defaults, user) {
launch: { ...defaults.launch, ...(user.launch || {}) },
auth: user.auth != null ? { ...defaults.auth, ...user.auth } : defaults.auth,
realmlist: user.realmlist != null ? { ...defaults.realmlist, ...user.realmlist } : defaults.realmlist,
files: Array.isArray(user.files) && user.files.length ? user.files : defaults.files,
files: mergeFilesList(defaults, user),
};
}
@@ -45,6 +65,10 @@ function applyBakedGitea(cfg) {
function getConfigPath(app) {
if (process.env.FRACTURED_LAUNCHER_CONFIG) return process.env.FRACTURED_LAUNCHER_CONFIG;
if (app && app.isPackaged) {
// AppImage (and macOS .app) run from a read-only mount — cannot write beside execPath.
if (process.platform === 'linux' || process.platform === 'darwin') {
return path.join(app.getPath('userData'), 'launcher.json');
}
return path.join(path.dirname(process.execPath), 'launcher.json');
}
return path.join(__dirname, '..', 'launcher.json');
@@ -56,7 +80,11 @@ async function loadConfig(app) {
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
try {
const user = JSON.parse(await fs.readFile(p, 'utf8'));
return { configPath: p, config: applyBakedGitea(mergeConfig(defaults, user)) };
const config = applyBakedGitea(mergeConfig(defaults, user));
if (userFilesContainDeprecated(user)) {
await fs.writeFile(p, JSON.stringify(config, null, 2), 'utf8');
}
return { configPath: p, config };
} catch (e) {
if (e.code === 'ENOENT') {
const initial = applyBakedGitea(mergeConfig(defaults, {}));
@@ -80,8 +108,9 @@ async function saveGameDir(configPath, gameDir) {
function resolveGameDir(cfg, configPath) {
const gd = cfg.game_dir;
if (!gd) return '';
if (path.isAbsolute(gd)) return path.normalize(gd);
return path.normalize(path.join(path.dirname(configPath), gd));
const abs = path.isAbsolute(gd) ? path.normalize(gd) : path.normalize(path.join(path.dirname(configPath), gd));
if (process.platform === 'win32') return normalizeWinGameDir(abs);
return abs;
}
module.exports = { getConfigPath, loadConfig, saveGameDir, resolveGameDir, mergeConfig, applyBakedGitea };
@@ -1,6 +1,6 @@
'use strict';
const { downloadBodyToFile } = require('./http-download');
const { downloadBodyToFile, fetchOrThrow } = require('./http-download');
function normalizeGiteaBaseUrl(raw) {
let b = String(raw || '').trim().replace(/\/+$/, '');
@@ -33,7 +33,7 @@ function useGiteaReleases(cfg) {
return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim());
}
async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
async function fetchGiteaReleaseRecord(cfg) {
const api = giteaApiBase(cfg);
const { owner, repo } = cfg.gitea;
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
@@ -46,7 +46,7 @@ async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
}
const res = await fetch(listUrl, { headers: giteaHeaders(token, true) });
const res = await fetchOrThrow(listUrl, { headers: giteaHeaders(token, true) });
const text = await res.text();
if (!res.ok) {
let hint = '';
@@ -54,7 +54,18 @@ async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
if (res.status === 401 || res.status === 403) hint = ' (set GITEA_TOKEN or gitea.token_env)';
throw new Error(`Gitea release ${res.status}${hint}: ${text.slice(0, 600)}`);
}
const rel = JSON.parse(text);
return JSON.parse(text);
}
async function listGiteaReleaseAttachmentNames(cfg) {
const rel = await fetchGiteaReleaseRecord(cfg);
const list = rel.attachments || rel.assets || [];
return list.map((x) => x.name).filter(Boolean);
}
async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
const token = giteaToken(cfg);
const rel = await fetchGiteaReleaseRecord(cfg);
const list = rel.attachments || rel.assets || [];
let downloadUrl = '';
for (const a of list) {
@@ -69,7 +80,7 @@ async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
const h = { Accept: 'application/octet-stream' };
if (token) h.Authorization = `token ${token}`;
const dl = await fetch(downloadUrl, { headers: h, redirect: 'follow' });
const dl = await fetchOrThrow(downloadUrl, { headers: h, redirect: 'follow' });
await downloadBodyToFile(dl, destPath);
}
@@ -89,7 +100,7 @@ async function getGiteaUpdaterFeedBase(cfg) {
} else {
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
}
const res = await fetch(listUrl, { headers: giteaHeaders(token, true) });
const res = await fetchOrThrow(listUrl, { headers: giteaHeaders(token, true) });
if (!res.ok) return null;
const rel = await res.json();
const tagName = rel.tag_name;
@@ -101,6 +112,8 @@ async function getGiteaUpdaterFeedBase(cfg) {
module.exports = {
downloadGiteaReleaseAsset,
fetchGiteaReleaseRecord,
listGiteaReleaseAttachmentNames,
giteaToken,
useGiteaReleases,
getGiteaUpdaterFeedBase,
+31 -11
View File
@@ -3,8 +3,8 @@
const path = require('path');
const fs = require('fs').promises;
const { githubToken } = require('./github-token');
const { downloadGiteaReleaseAsset, useGiteaReleases } = require('./gitea-release');
const { fetchToFile, downloadBodyToFile } = require('./http-download');
const { downloadGiteaReleaseAsset, useGiteaReleases, listGiteaReleaseAttachmentNames } = require('./gitea-release');
const { fetchToFile, downloadBodyToFile, fetchOrThrow } = require('./http-download');
function encodeRepoPath(repoPath) {
let p = String(repoPath || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
@@ -35,7 +35,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
}
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${enc}?ref=${encodeURIComponent(ref)}`;
const res = await fetch(apiUrl, { headers: ghHeaders(token, true) });
const res = await fetchOrThrow(apiUrl, { headers: ghHeaders(token, true) });
const body = await res.text();
if (!res.ok) {
throw new Error(`GitHub contents API ${res.status}: ${body.slice(0, 800)}`);
@@ -65,10 +65,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
throw new Error(`unexpected GitHub response for ${repoPath}`);
}
async function downloadReleaseAsset(cfg, assetName, destPath) {
if (useGiteaReleases(cfg)) {
return downloadGiteaReleaseAsset(cfg, assetName, destPath);
}
async function fetchGitHubReleaseJson(cfg) {
const token = githubToken(cfg);
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
const { owner, repo } = cfg.github;
@@ -78,7 +75,7 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
} else {
listUrl = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`;
}
const res = await fetch(listUrl, { headers: ghHeaders(token, true) });
const res = await fetchOrThrow(listUrl, { headers: ghHeaders(token, true) });
const text = await res.text();
if (!res.ok) {
let hint = '';
@@ -89,7 +86,24 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
if (res.status === 401 || res.status === 403) hint = ' (set GITHUB_TOKEN or token_env PAT)';
throw new Error(`releases list ${res.status}${hint}: ${text.slice(0, 600)}`);
}
const rel = JSON.parse(text);
return JSON.parse(text);
}
async function listReleaseAttachmentNames(cfg) {
if (useGiteaReleases(cfg)) {
return listGiteaReleaseAttachmentNames(cfg);
}
const rel = await fetchGitHubReleaseJson(cfg);
const assets = rel.assets || [];
return assets.map((a) => a.name).filter(Boolean);
}
async function downloadReleaseAsset(cfg, assetName, destPath) {
if (useGiteaReleases(cfg)) {
return downloadGiteaReleaseAsset(cfg, assetName, destPath);
}
const token = githubToken(cfg);
const rel = await fetchGitHubReleaseJson(cfg);
const assets = rel.assets || [];
let assetURL = '';
for (const a of assets) {
@@ -114,8 +128,14 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
h.Authorization = `Bearer ${token}`;
h['X-GitHub-Api-Version'] = '2022-11-28';
}
const dl = await fetch(assetURL, { headers: h, redirect: 'follow' });
const dl = await fetchOrThrow(assetURL, { headers: h, redirect: 'follow' });
await downloadBodyToFile(dl, destPath);
}
module.exports = { downloadGitHubRepoFile, downloadReleaseAsset, encodeRepoPath };
module.exports = {
downloadGitHubRepoFile,
downloadReleaseAsset,
encodeRepoPath,
fetchGitHubReleaseJson,
listReleaseAttachmentNames,
};
@@ -6,6 +6,42 @@ const { createWriteStream } = require('fs');
const { pipeline } = require('stream/promises');
const { Readable } = require('stream');
function safeUrlForLog(url) {
try {
const u = new URL(url);
return `${u.origin}${u.pathname}`;
} catch {
return String(url || '').split('?')[0].slice(0, 200);
}
}
function explainFetchFailure(err, url) {
const msg = err && err.message ? err.message : String(err);
const cause = err && err.cause;
const code = cause && cause.code ? cause.code : '';
const combined = `${msg} ${code}`;
const hints = [];
if (/CERT|TLS|SSL|UNABLE_TO_VERIFY|SELF_SIGNED|certificate|unknown ca|unable to verify/i.test(combined)) {
hints.push(
'TLS certificate not trusted — install a valid cert on Gitea, or trust your CA system-wide, or set NODE_EXTRA_CA_CERTS to a .pem bundle (self-signed mirrors)'
);
}
if (/ECONNREFUSED/.test(combined)) hints.push('connection refused (wrong host/port or server down)');
if (/ENOTFOUND|EAI_AGAIN/.test(combined)) hints.push('DNS lookup failed');
if (/ETIMEDOUT|TIMEOUT/i.test(combined)) hints.push('connection timed out');
const hintStr = hints.length ? ` ${hints.join(' ')}` : '';
return new Error(`${msg}${hintStr}${safeUrlForLog(url)}`);
}
/** Wrap global fetch with clearer errors for TLS/DNS/refused (Electron reports bare "fetch failed"). */
async function fetchOrThrow(url, init) {
try {
return await fetch(url, init);
} catch (e) {
throw explainFetchFailure(e, url);
}
}
async function downloadBodyToFile(res, destPath) {
if (!res.ok) {
const errText = await res.text().catch(() => '');
@@ -30,11 +66,11 @@ async function downloadBodyToFile(res, destPath) {
}
async function fetchToFile(url, headers, destPath) {
const res = await fetch(url, {
const res = await fetchOrThrow(url, {
headers,
redirect: 'follow',
});
await downloadBodyToFile(res, destPath);
}
module.exports = { fetchToFile, downloadBodyToFile };
module.exports = { fetchToFile, downloadBodyToFile, fetchOrThrow, safeUrlForLog };
@@ -75,8 +75,10 @@ async function loadManifest(cfg) {
*/
async function patchesMatchManifest(cfg, manifest, onStatus) {
if (!validateManifest(manifest)) return false;
const { buildResolvedReleaseFiles } = require('./release-sync');
const entries = await buildResolvedReleaseFiles(cfg, manifest);
const gameDir = cfg.game_dir;
for (const entry of cfg.files || []) {
for (const entry of entries) {
if (!entry.from_release) continue;
const spec = manifest.files[entry.source];
if (!spec || !spec.sha256) return false;
@@ -98,7 +100,9 @@ async function patchesMatchManifest(cfg, manifest, onStatus) {
async function verifyInstalledAgainstManifest(cfg, manifest) {
if (!validateManifest(manifest)) return;
for (const entry of cfg.files || []) {
const { buildResolvedReleaseFiles } = require('./release-sync');
const entries = await buildResolvedReleaseFiles(cfg, manifest);
for (const entry of entries) {
if (!entry.from_release) continue;
const spec = manifest.files[entry.source];
if (!spec || !spec.sha256) {
@@ -119,8 +123,10 @@ async function verifyInstalledAgainstManifest(cfg, manifest) {
async function recordPatchState(cfg, manifest) {
if (!validateManifest(manifest)) return;
const { buildResolvedReleaseFiles } = require('./release-sync');
const entries = await buildResolvedReleaseFiles(cfg, manifest);
const shas = {};
for (const entry of cfg.files || []) {
for (const entry of entries) {
if (!entry.from_release) continue;
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
const destAbs = path.join(cfg.game_dir, ...parts);
+155 -23
View File
@@ -2,7 +2,11 @@
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
const { normalizeWinGameDir } = require('./win-game-dir');
const { loadManifest } = require('./patch-manifest');
const { buildResolvedReleaseFiles } = require('./release-sync');
function pad2(n) {
return String(n).padStart(2, '0');
@@ -12,44 +16,159 @@ function backupSuffix() {
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/** Windows often returns EBUSY/EPERM when WoW or AV still has an MPQ open. */
function isRetryableFsLockError(e) {
const c = e && e.code;
if (!c) return false;
if (c === 'EBUSY' || c === 'EPERM' || c === 'EACCES') return true;
if (process.platform === 'win32' && (c === 'UNKNOWN' || c === 'EUNKNOWN')) return true;
return false;
}
async function retryFsLock(op, opts) {
const attempts = (opts && opts.attempts) || (process.platform === 'win32' ? 30 : 10);
const delayMs = (opts && opts.delayMs) || 500;
let last;
for (let i = 0; i < attempts; i++) {
try {
return await op();
} catch (e) {
last = e;
if (!isRetryableFsLockError(e)) throw e;
if (i === attempts - 1) break;
await sleep(delayMs);
}
}
const hint =
process.platform === 'win32'
? ' Close World of Warcraft and any launcher using this folder, then try again.'
: ' Close programs using this file, then try again.';
const err = new Error(String((last && last.message) || last) + hint);
err.code = last && last.code;
throw err;
}
function wowExePath(cfg) {
const gd = normalizeWinGameDir(cfg.game_dir || '');
const exe = (cfg.launch && cfg.launch.exe) || 'Wow.exe';
const parts = exe.replace(/\\/g, '/').split('/').filter(Boolean);
return path.join(cfg.game_dir, ...parts);
const primary = path.join(gd, ...parts);
if (process.platform === 'win32' && gd && fsSync.existsSync(primary)) return primary;
if (process.platform === 'win32' && gd) {
try {
const base = path.basename(primary);
const dir = path.dirname(primary);
const names = fsSync.readdirSync(dir);
const hit = names.find((n) => n.toLowerCase() === base.toLowerCase());
if (hit) {
const alt = path.join(dir, hit);
if (fsSync.statSync(alt).isFile()) return alt;
}
} catch (_) {
/* ignore */
}
}
return primary;
}
function wowInstallValid(cfg) {
if (!cfg.game_dir) return false;
return require('fs').existsSync(wowExePath(cfg));
const p = wowExePath(cfg);
return fsSync.existsSync(p) && fsSync.statSync(p).isFile();
}
/** WoW expects patch MPQ names with a literal .MPQ extension (case-sensitive clients). */
function normalizeMpqDestinationPath(absPath) {
const s = String(absPath || '');
return /\.mpq$/i.test(s) ? s.replace(/\.mpq$/i, '.MPQ') : s;
}
/** Matches backup names from installFile: `<orig>.bak-YYYYMMDD-HHmmss`. */
const LAUNCHER_BACKUP_BASENAME_RE = /\.bak-\d{8}-\d{6}$/;
async function removeLauncherBackupFiles(gameDir) {
const root = normalizeWinGameDir(gameDir || '');
if (!root) return;
const stack = [root];
while (stack.length) {
const dir = stack.pop();
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (_) {
continue;
}
for (const d of entries) {
const abs = path.join(dir, d.name);
if (d.isDirectory()) {
stack.push(abs);
} else if (d.isFile() && LAUNCHER_BACKUP_BASENAME_RE.test(d.name)) {
try {
await fs.unlink(abs);
} catch (e) {
if (e.code !== 'ENOENT') {
/* best effort: sync already succeeded */
}
}
}
}
}
}
function isEnUsRealmlistPath(rel) {
const n = String(rel || '')
.trim()
.replace(/\\/g, '/')
.toLowerCase();
return n.endsWith('/enus/realmlist.wtf') || n === 'enus/realmlist.wtf';
}
async function installFile(cfg, entry) {
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
const destAbs = path.join(cfg.game_dir, ...parts);
if (entry.backup) {
try {
const st = await fs.stat(destAbs);
if (st.isFile()) {
const bak = `${destAbs}.bak-${backupSuffix()}`;
await fs.rename(destAbs, bak);
}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
} else {
try {
await fs.unlink(destAbs);
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
}
const root = normalizeWinGameDir(cfg.game_dir || '');
const destAbs = normalizeMpqDestinationPath(path.join(root, ...parts));
const tmp = destAbs + '.new';
if (entry.from_release) {
await downloadReleaseAsset(cfg, entry.source, tmp);
} else {
await downloadGitHubRepoFile(cfg, entry.source, tmp);
}
await fs.rename(tmp, destAbs);
async function removeOrBackupExisting() {
if (entry.backup) {
try {
const st = await fs.stat(destAbs);
if (st.isFile()) {
const bak = `${destAbs}.bak-${backupSuffix()}`;
await fs.rename(destAbs, bak);
}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
} else {
try {
await fs.unlink(destAbs);
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
}
}
await retryFsLock(() => removeOrBackupExisting());
await retryFsLock(() => fs.rename(tmp, destAbs));
if (process.platform === 'linux' && /\.exe$/i.test(destAbs)) {
try {
await fs.chmod(destAbs, 0o755);
} catch (_) {
/* non-fatal */
}
}
}
async function applyRealmlist(cfg) {
@@ -62,18 +181,25 @@ async function applyRealmlist(cfg) {
const content = line + '\n';
let paths = cfg.realmlist.paths;
if (!paths || !paths.length) paths = ['Data/enUS/realmlist.wtf'];
paths = paths.filter(isEnUsRealmlistPath);
if (!paths.length) paths = ['Data/enUS/realmlist.wtf'];
for (const rel of paths) {
const r = String(rel).trim().replace(/\\/g, '/');
if (!r) continue;
const segs = r.split('/').filter(Boolean);
const abs = path.join(cfg.game_dir, ...segs);
const abs = path.join(normalizeWinGameDir(cfg.game_dir || ''), ...segs);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, content, 'utf8');
}
}
async function applyPatches(cfg, onStatus) {
for (const f of cfg.files || []) {
let manifest = null;
if (cfg.patch_manifest && cfg.patch_manifest.enabled) {
manifest = await loadManifest(cfg);
}
const entries = await buildResolvedReleaseFiles(cfg, manifest);
for (const f of entries) {
if (onStatus) onStatus(`Updating ${f.dest}`);
try {
await installFile(cfg, f);
@@ -85,6 +211,12 @@ async function applyPatches(cfg, onStatus) {
if (onStatus) onStatus('Applying realmlist …');
await applyRealmlist(cfg);
}
if (onStatus) onStatus('Removing old backup copies …');
try {
await removeLauncherBackupFiles(cfg.game_dir);
} catch (_) {
/* Patches and realmlist already applied; leave .bak files if cleanup cannot run. */
}
if (onStatus) onStatus('All patches applied.');
}
@@ -0,0 +1,128 @@
'use strict';
const path = require('path');
const { listReleaseAttachmentNames } = require('./github');
/** Legacy launcher.json rows — ignored when merging explicit files. */
const DEPRECATED_SOURCES = new Set(['patch-Z.MPQ', 'Wow-patched.exe']);
function filterExplicitFiles(files) {
if (!Array.isArray(files)) return [];
return files
.filter((e) => e && String(e.source || '').trim())
.filter((e) => !DEPRECATED_SOURCES.has(String(e.source).trim()))
.map((e) => ({
source: String(e.source).trim(),
dest: String(e.dest || '').trim(),
backup: e.backup !== false,
from_release: e.from_release !== false,
}))
.filter((e) => e.dest);
}
function manifestLooksUsable(m) {
return !!(m && m.files && typeof m.files === 'object' && Object.keys(m.files).length > 0);
}
/** Launcher / updater attachments — never copied into the WoW folder. */
function isExcludedFromGameSync(fileName) {
const n = String(fileName || '');
const lower = n.toLowerCase();
if (lower === 'patch-manifest.json') return true;
if (/^fractured-launcher/i.test(n)) return true;
if (/\.blockmap$/i.test(n)) return true;
if (/^latest.*\.ya?ml$/i.test(n) || lower === 'latest.yml') return true;
if (lower.includes('builder-debug')) return true;
if (/\.appimage$/i.test(n)) return true;
return false;
}
function mpqDestFromSource(source) {
const base = path.basename(String(source || ''));
const stem = base.replace(/\.mpq$/i, '');
return `Data/enUS/${stem}.MPQ`;
}
function destForReleaseSource(source, cfg) {
const base = path.basename(String(source || ''));
if (/\.mpq$/i.test(base)) return mpqDestFromSource(source);
if (/\.exe$/i.test(base)) return (cfg.launch && cfg.launch.exe) || 'Wow.exe';
return base;
}
/**
* Explicit `files` in config wins. Otherwise use patch-manifest keys if present,
* else discover attachments on the release (excluding launcher artifacts).
*/
async function buildResolvedReleaseFiles(cfg, manifestMaybeNull) {
const explicit = filterExplicitFiles(cfg.files);
if (explicit.length) return explicit;
const manifest = manifestMaybeNull;
if (manifestLooksUsable(manifest)) {
const keys = Object.keys(manifest.files).filter((k) => k && !isExcludedFromGameSync(k));
if (!keys.length) {
throw new Error('patch-manifest.json has no file entries — add files or attach assets to the release.');
}
return keys.map((source) => ({
source,
dest: destForReleaseSource(source, cfg),
backup: true,
from_release: true,
}));
}
const names = await listReleaseAttachmentNames(cfg);
const game = names.filter((n) => n && !isExcludedFromGameSync(n));
if (!game.length) {
throw new Error(
'No patch files on this release (after excluding launcher installers). ' +
'Attach MPQ/exe assets or ship patch-manifest.json listing filenames.'
);
}
const exes = game.filter((n) => /\.exe$/i.test(n));
const mpqs = game.filter((n) => /\.mpq$/i.test(n));
const rest = game.filter((n) => !/\.(exe|mpq)$/i.test(n));
if (exes.length > 1) {
throw new Error(
`Release has multiple .exe files (${exes.join(', ')}). ` +
'Remove extras or publish patch-manifest.json with the exact filenames to install.'
);
}
const out = [];
for (const n of mpqs) {
out.push({
source: n,
dest: mpqDestFromSource(n),
backup: true,
from_release: true,
});
}
if (exes.length === 1) {
out.push({
source: exes[0],
dest: (cfg.launch && cfg.launch.exe) || 'Wow.exe',
backup: true,
from_release: true,
});
}
for (const n of rest) {
out.push({
source: n,
dest: path.basename(n),
backup: true,
from_release: true,
});
}
return out;
}
module.exports = {
buildResolvedReleaseFiles,
filterExplicitFiles,
isExcludedFromGameSync,
DEPRECATED_SOURCES,
};
@@ -0,0 +1,21 @@
'use strict';
const path = require('path');
/**
* Under Wine, the folder picker often returns a Unix absolute path (/home/...).
* Windows Node does not resolve that to the WoW install; map to Wine's Z: drive
* (Z: == / on typical Wine prefixes).
*/
function normalizeWinGameDir(gameDir) {
if (process.platform !== 'win32') return String(gameDir || '').trim();
let s = String(gameDir || '').trim();
if (!s) return s;
s = s.replace(/\//g, path.win32.sep);
if (s.startsWith('\\\\')) return path.normalize(s);
if (/^[A-Za-z]:/.test(s)) return path.normalize(s);
if (s.startsWith(path.win32.sep)) return path.win32.normalize(`Z:${s}`);
return path.normalize(s);
}
module.exports = { normalizeWinGameDir };
+41 -4
View File
@@ -4,6 +4,7 @@ const { app, BrowserWindow, ipcMain, dialog, Menu } = require('electron');
const path = require('path');
const { spawn } = require('child_process');
const { loadConfig, saveGameDir, resolveGameDir } = require('./lib/config-store');
const { normalizeWinGameDir } = require('./lib/win-game-dir');
const { applyPatches, wowExePath, wowInstallValid, doAuth } = require('./lib/patch');
const { readPatchState } = require('./lib/patch-manifest');
const { setupAutoUpdater } = require('./lib/auto-update');
@@ -95,7 +96,8 @@ ipcMain.handle('launcher:saveGameDir', async (_e, dir) => {
const trimmed = String(dir || '').trim();
if (!trimmed) throw new Error('folder path is empty');
const { configPath } = await loadConfig(app);
const norm = path.normalize(trimmed);
const norm =
process.platform === 'win32' ? normalizeWinGameDir(path.normalize(trimmed)) : path.normalize(trimmed);
const probe = { ...(await readMergedConfig()).config, game_dir: norm };
if (!wowInstallValid(probe)) {
throw new Error(`That folder does not contain ${(probe.launch && probe.launch.exe) || 'Wow.exe'}`);
@@ -143,9 +145,44 @@ ipcMain.handle('launcher:checkUpdates', async () => {
ipcMain.handle('launcher:play', async () => {
const { config } = await readMergedConfig();
const exe = wowExePath(config);
const args = (config.launch && config.launch.args) || [];
const child = spawn(exe, args, {
cwd: config.game_dir,
const gameArgs = (config.launch && config.launch.args) || [];
const lc = config.launch || {};
const cwd = config.game_dir;
let cmd;
let spawnArgs;
if (process.platform === 'linux') {
const steamUri = String(lc.linux_steam_uri || '').trim();
const steamSpawn = Array.isArray(lc.linux_steam_spawn) ? lc.linux_steam_spawn.filter(Boolean) : [];
if (steamUri) {
if (steamSpawn.length) {
cmd = steamSpawn[0];
spawnArgs = [...steamSpawn.slice(1), steamUri];
} else {
const bin = String(lc.linux_steam_binary || 'steam').trim() || 'steam';
cmd = bin;
spawnArgs = [steamUri];
}
} else {
const wrap = Array.isArray(lc.linux_wrapper) ? lc.linux_wrapper.filter(Boolean) : [];
if (wrap.length) {
cmd = wrap[0];
spawnArgs = [...wrap.slice(1), exe, ...gameArgs];
} else {
throw new Error(
'On Linux, Wow.exe is a Windows program and cannot be run directly. ' +
'Set launch.linux_steam_uri (e.g. steam://rungameid/… for your Steam shortcut) ' +
'or launch.linux_wrapper (e.g. ["wine"]) in launcher.json.'
);
}
}
} else {
cmd = exe;
spawnArgs = gameArgs;
}
const child = spawn(cmd, spawnArgs, {
cwd,
detached: true,
stdio: 'ignore',
windowsHide: true,
@@ -1,6 +1,6 @@
{
"name": "fractured-launcher-electron",
"version": "1.0.2",
"version": "1.0.12",
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
"main": "main.js",
"repository": {
@@ -36,6 +36,7 @@
"renderer.js",
"styles.css",
"default-launcher.json",
"lib/win-game-dir.js",
"lib/baked-gitea-channel.js",
"lib/gitea-release.js",
"lib/patch-manifest.js",
@@ -2,11 +2,11 @@
/**
* Build patch-manifest.json for a release (same names as files[].source in launcher.json).
*
* Usage (from a folder containing the patch binaries):
* node generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe
* Usage (from a folder containing the patch binaries list every files[].source name):
* node generate-patch-manifest.js v0.9.0-client Wow-patched.exe
*
* Prints JSON to stdout redirect to file:
* node generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe > patch-manifest.json
* node generate-patch-manifest.js v0.9.0-client Wow-patched.exe > patch-manifest.json
*/
'use strict';
@@ -18,7 +18,7 @@ const version = process.argv[2];
const names = process.argv.slice(3);
if (!version || names.length === 0) {
console.error('Usage: generate-patch-manifest.js <version-label> <file1> [file2 ...]');
console.error(' Example: generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe');
console.error(' Example: generate-patch-manifest.js v0.9.0-client Wow-patched.exe');
process.exit(1);
}
@@ -3,7 +3,7 @@
#
# Usage (from repo root or this directory):
# export GH_TOKEN=ghp_... # PAT with repo/releases on the distro repo
# ./tools/fractured-launcher-electron/scripts/publish-to-distro.sh v1.0.0 patch-Z.MPQ Wow-patched.exe
# ./tools/fractured-launcher-electron/scripts/publish-to-distro.sh v1.0.0 Wow-patched.exe
#
# Optional:
# DISTRO_REPO=YourOrg/Fratured-Distro # if your GitHub slug differs
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Shared filters for GitHub → Gitea / distro release merges and Gitea uploads.
# shellcheck shell=bash
# Skip when copying assets from `gh release download` into combined/: CI-built launcher is authoritative.
should_skip_merge_from_github() {
local l
l=$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]')
case "$l" in
fractured-launcher*) return 0 ;;
*.blockmap) return 0 ;;
builder-debug.yml|builder-debug.yaml) return 0 ;;
esac
return 1
}
# Skip when POSTing attachments to Gitea (belt-and-suspenders if something slips into combined/).
should_skip_gitea_upload() {
local l
l=$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]')
case "$l" in
*.blockmap) return 0 ;;
builder-debug.yml|builder-debug.yaml) return 0 ;;
esac
return 1
}
@@ -11,6 +11,10 @@
#
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=release-sync-filters.sh
. "$SCRIPT_DIR/release-sync-filters.sh"
COMBINED_DIR="${1:?first arg: directory of files to attach}"
TAG="${2:?second arg: release tag (e.g. v1.0.0)}"
@@ -67,12 +71,6 @@ if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then
exit 1
fi
while read -r aid; do
[ -z "$aid" ] || [ "$aid" = "null" ] && continue
curl -fsS -X DELETE "${AUTH_H[@]}" \
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets/${aid}" || true
done < <(jq -r '(.attachments // .assets // [])[] | .id' "$REL_JSON")
shopt -s nullglob
files=("$COMBINED_DIR"/*)
if [ "${#files[@]}" -eq 0 ]; then
@@ -80,12 +78,39 @@ if [ "${#files[@]}" -eq 0 ]; then
exit 1
fi
uploadable=0
for f in "${files[@]}"; do
[ -f "$f" ] || continue
echo "Uploading $(basename "$f")"
bn=$(basename "$f")
if should_skip_gitea_upload "$bn"; then
continue
fi
uploadable=$((uploadable + 1))
done
if [ "$uploadable" -eq 0 ]; then
echo "No files to upload after exclusions (check $COMBINED_DIR) — not clearing Gitea attachments." >&2
exit 1
fi
while read -r aid; do
[ -z "$aid" ] || [ "$aid" = "null" ] && continue
curl -fsS -X DELETE "${AUTH_H[@]}" \
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets/${aid}" || true
done < <(jq -r '(.attachments // .assets // [])[] | .id' "$REL_JSON")
uploaded=0
for f in "${files[@]}"; do
[ -f "$f" ] || continue
bn=$(basename "$f")
if should_skip_gitea_upload "$bn"; then
echo "Skipping upload (excluded): $bn"
continue
fi
echo "Uploading $bn"
curl -fsS -X POST "${AUTH_H[@]}" \
-F "attachment=@${f}" \
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets"
uploaded=$((uploaded + 1))
done
echo "Gitea release $TAG (id=$rel_id) updated with ${#files[@]} file(s)."
echo "Gitea release $TAG (id=$rel_id) updated with $uploaded file(s)."