Compare commits

...

31 Commits

Author SHA1 Message Date
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
Docker Build 656cf2d07d Paragon panel: keep cascade passives, strip free actives; DK passive DBC fix
- PanelLearnSpellChain: record every non-chain passive as panel_spell_child;
  only revoke non-passive (Blood Presence, Death Coil, Death Grip, etc.).
- RevokeUnwantedCascadeSpellsForPlayer: skip passive rewards on login sweep.
- RevokeBlockedSpellsForPlayer: migrate legacy passive revoke rows to
  children; walk (parent, revoked) pairs from DB.
- PruneSkillLineCascadeChildrenFromDb: only strip actives wrongly stored as
  children; never strip passives.
- SpellInfoCorrections: set SPELL_ATTR0_PASSIVE on Forceful Deflection (49410)
  and Runic Focus (61455) so IsPassive() matches spellbook behavior.
- PanelUnlearnTalentPurchase: mirror resetTalents (_removeTalentAurasAndSpells,
  _removeTalent, SendTalentsInfoData) so Beast Mastery loss triggers pet reset.
- OnPlayerLogin: run legacy passive attach before scoped cascade sweep.
- Add .paragon recalibrate GM command (RBAC modify): full panel reset + AE/TE
  reconciliation for selected player or self.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 22:20:13 -04:00
Docker Build bfe51f6ad4 docs(launcher): note manual Windows pack for local test
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 20:50:16 -05:00
Docker Build 2a3107a78d feat(launcher): Linux AppImage 1.0.2, Gitea sync + CI, manual pack script
- Add pack:linux (AppImage x64), linux/appImage artifact names in package.json.
- Gitea sync: parallel build-electron-linux, merge Windows+Linux into Gitea upload;
  rename Windows artifact to electron-dist-windows.
- Fractured launcher CI: electron-launcher-windows + electron-launcher-linux jobs.
- scripts/manual-pack-linux.sh for local test builds from current tree.
- Normalize Gitea base_url (prepend https if missing); baked channel uses full URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 20:50:06 -05:00
Docker Build 48826e21d6 refactor(launcher): hardcode Gitea channel in lib/baked-gitea-channel.js
- Merge baked base_url/owner/repo/release_tag at load time (no inject script,
  no fractured-release-channel.json, no CI env for pack).
- Fix mergeConfig deep-merge for gitea, patch_manifest, launcher_updates_from_github.
- Remove inject-release-channel.js and fractured-release-channel.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 20:15:38 -05:00
Docker Build 15c476c12d ci(gitea-sync): overlay launcher from default branch before pack
Release tags can point at commits older than launcher lib additions; building
only from the tag omitted gitea-release.js etc. Fetch default branch and
checkout tools/fractured-launcher-electron from it before npm ci/pack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 19:15:27 -05:00
Docker Build 6c4d7244c3 fix(launcher): add missing gitea-release and patch-manifest to repo
These modules were required by main.js / auto-update.js / github.js but never
committed, so packaged builds lacked them and crashed at startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 19:08:55 -05:00
Docker Build 9fb80102c8 Paragon: spell unlearn queue + AE/TE reconciliation
Two related additions to mod-paragon:

  * HandleCommit gains a third payload section, " u:<id>,...", carrying
    spell IDs the player wants to refund + unlearn in the same commit
    that learns / talents through. The protocol stays backward-compat
    (older clients omit the section). PanelUnlearnSpellPurchase mirrors
    the per-spell branch of HandleParagonResetAbilities: tracked passive
    children are removed first, then the chain head, then panel_spells /
    panel_spell_children / panel_spell_revoked rows for that purchase
    are dropped, then LookupSpellAECost(head) is refunded into the
    cache. Unlearns are applied before learns inside the commit so the
    refund covers the same-commit spends. Allow-list for the silence
    window now includes chain ranks + panel_spell_children for the
    intentional unlearns so "You have unlearned X" toasts stay visible
    for the targeted spell while cascade dependents stay silenced.

  * ReconcileEssenceForPlayer reads panel_spells + panel_talents and
    sets the cache to ComputeStartingAE/TE(level) - sum-of-spends.
    Self-heals drift in either direction: clamps the cache down when
    the player has more essence than their level + spends allow
    (cheese clamp), and tops up when they have less (admin-tweak /
    crash recovery). Wired into OnPlayerLogin (after LoadCurrencyFromDb,
    before PushCurrency so the first balance the client sees is the
    reconciled one) and OnPlayerLevelChanged (replaces the old
    GrantLevelUpEssence delta -- Reconcile sets the absolute correct
    balance from level + spend, so it subsumes the per-level grant and
    the cheese clamp in one call). Costs come from the same
    paragon_spell_ae_cost / config keys HandleCommit uses so the math
    stays in lockstep across any future cost rebalance.

Both features ship in patch-enUS-6.MPQ v0.9.16: right-click a learned
spell row to queue an unlearn (header shows +N AE refund preview) and
hit Learn All to apply. The icon picker also got two fixes -- the
leading INV_Misc_QuestionMark is no longer duplicated, and the
selection ring is now a tooltip-border Frame anchored to the cell
bounds (the prior UI-ActionButton-Border texture rendered nearly
invisible at non-native sizes).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 19:57:47 -04:00
Docker Build 7028258084 feat(launcher): bake Gitea base_url/owner/repo into pack from env or channel file
- inject-release-channel.js merges GITEA_* (or fractured-release-channel.json) into
  default-launcher.json before electron-builder.
- CI passes existing GITEA_BASE_URL/OWNER/REPO secrets into the Windows pack job.
- npm run pack:win/publish:win run the injector; workflows use npm run pack:win.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 17:33:28 -05:00
Docker Build 5966eb0ffc scripts: document default mysql acore/acore for FRACTURED_MYSQL example
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 16:24:37 -05:00
Docker Build 90c8db0b04 scripts: tee vps-paragon-diagnostics output to var/vps-paragon-diagnostics-last.txt
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:57:54 -05:00
Docker Build 9240bf1243 scripts: clarify empty spell_dbc samples; add version + rune override probes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:53:57 -05:00
Docker Build 88f8dcb0e7 scripts: extend vps-paragon-diagnostics for rune/RP DBC and binary parity
- Binary sha256 + revision-like strings for dev vs VPS compare
- worldserver.conf Rate.RunicPower and mod_paragon.conf Paragon.* keys
- MySQL: chrclasses_dbc 6/12, spell_dbc sample, spellrunecost join
- FRACTURED_SPELL_IDS override for custom spell spot-checks

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:46:35 -05:00
Docker Build 9cb3c79dbe fix(launcher): opt-in GitHub auto-update; clarify Gitea for from_release
- Gate electron-updater GitHub provider on launcher_updates_from_github (default false)
  so GITHUB_TOKEN no longer targets the source repo without latest.yml.
- Improve GitHub releases 404 hint when assets are on Gitea.
- Document in README and default-launcher.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:38:07 -05:00
Docker Build 75e3b59442 chore(gitea): add bootstrap-gitea-repo.sh for initial README commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:29:07 -05:00
Docker Build 030c2307c2 scripts: add vps-paragon-diagnostics.sh for native VPS triage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:19:59 -05:00
Docker Build 27d54f15a2 fix(gitea): document and explain HTTP 422 repo is empty on release create
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 14:37:02 -05:00
Docker Build 5e18c2b766 docs(ci): explain Re-run vs Run workflow for Gitea sync (GH_TOKEN error)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 14:29:21 -05:00
Docker Build 1c85341b1f ci: disable electron-builder GitHub publish; add Gitea sync workflow
- Use --publish never in pack/CI so tagged builds do not require GH_TOKEN.
- Set build.publish to null and align publish:win with local-only packaging.
- Add Gitea release sync workflow and upload script; fetch script from default
  branch so reruns work for tags that predate the script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 14:24:42 -05:00
43 changed files with 3486 additions and 219 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
+30 -2
View File
@@ -22,7 +22,7 @@ concurrency:
cancel-in-progress: true
jobs:
electron-launcher:
electron-launcher-windows:
runs-on: windows-latest
timeout-minutes: 45
defaults:
@@ -49,4 +49,32 @@ 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
timeout-minutes: 45
defaults:
run:
working-directory: tools/fractured-launcher-electron
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: tools/fractured-launcher-electron/package-lock.json
- name: Install and pack (AppImage)
run: |
npm ci
npm run pack:linux
- uses: actions/upload-artifact@v4
with:
name: fractured-launcher-electron-linux-${{ github.run_id }}
if-no-files-found: warn
path: |
tools/fractured-launcher-electron/dist/*.AppImage
tools/fractured-launcher-electron/dist/latest.yml
tools/fractured-launcher-electron/dist/latest-linux.yml
+248
View File
@@ -0,0 +1,248 @@
# Primary path for player-facing binaries: every *published* GitHub Release on this repo
# is mirrored to your self-hosted Gitea (same tag). No public GitHub distro repo.
#
# Triggers:
# - release: published / released → GitHub “Release” (not a raw git tag alone).
# - workflow_dispatch → Actions → this workflow → “Run workflow” (enter tag).
#
# Troubleshooting: “Re-run failed jobs” on an OLD run replays the *original* workflow
# YAML (e.g. still runs `npm run pack:win` without --publish never). After changing this
# file on default branch, start a *new* run via “Run workflow”, not Re-run on a pre-fix run.
#
# Important: pushing only a git tag does NOT run this — you must create/publish a
# Release on github.com (Releases → Draft/new release → Publish). The workflow
# definition must exist on the repo DEFAULT branch (GitHub runs it from there).
#
# Steps: Windows (NSIS+portable) + Linux (AppImage) in parallel, launcher from DEFAULT BRANCH
# overlay on tag checkout → merge with GitHub release assets → upload all to Gitea.
#
# Secrets: GITEA_BASE_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO
# Optional variable: GITEA_TARGET_REF (see tools/fractured-launcher-electron/README.md)
#
# Job guard: edit `if:` if github.repository is not Dawnforger/Fractured.
name: Sync release to Gitea
on:
release:
types: [published, released]
workflow_dispatch:
inputs:
tag:
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
permissions:
contents: read
concurrency:
group: gitea-release-sync-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.event.release.tag_name }}
cancel-in-progress: false
jobs:
meta:
runs-on: ubuntu-latest
if: github.repository == 'Dawnforger/Fractured'
outputs:
tag: ${{ steps.t.outputs.tag }}
steps:
- name: Resolve tag
id: t
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
RAW="${{ github.event.inputs.tag }}"
else
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
if: github.repository == 'Dawnforger/Fractured'
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.meta.outputs.tag }}
# Release tags often point at server/game commits that predate launcher lib fixes.
# Always pack the launcher from default branch so app.asar includes the full tree.
- name: Overlay launcher from default branch
shell: bash
run: |
set -euo pipefail
DB="${{ github.event.repository.default_branch }}"
git fetch --no-tags --depth=1 origin "+refs/heads/${DB}:refs/remotes/origin/${DB}"
git checkout "origin/${DB}" -- tools/fractured-launcher-electron
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: tools/fractured-launcher-electron/package-lock.json
- name: Install and pack (NSIS + portable)
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
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path launcher-publish | Out-Null
Copy-Item tools/fractured-launcher-electron/dist/*.exe launcher-publish/
if (Test-Path tools/fractured-launcher-electron/dist/latest.yml) {
Copy-Item tools/fractured-launcher-electron/dist/latest.yml launcher-publish/
}
- uses: actions/upload-artifact@v4
with:
name: electron-dist-windows
path: launcher-publish/
build-electron-linux:
needs: meta
if: github.repository == 'Dawnforger/Fractured'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.meta.outputs.tag }}
- name: Overlay launcher from default branch
shell: bash
run: |
set -euo pipefail
DB="${{ github.event.repository.default_branch }}"
git fetch --no-tags --depth=1 origin "+refs/heads/${DB}:refs/remotes/origin/${DB}"
git checkout "origin/${DB}" -- tools/fractured-launcher-electron
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: tools/fractured-launcher-electron/package-lock.json
- name: Install and pack (AppImage)
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
shell: bash
run: |
set -euo pipefail
mkdir -p launcher-linux-publish
shopt -s nullglob
cp -f tools/fractured-launcher-electron/dist/*.AppImage 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
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: electron-dist-linux
path: launcher-linux-publish/
sync-gitea:
needs: [meta, build-electron, build-electron-linux]
if: github.repository == 'Dawnforger/Fractured'
runs-on: ubuntu-latest
env:
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_OWNER: ${{ secrets.GITEA_OWNER }}
GITEA_REPO: ${{ secrets.GITEA_REPO }}
GITEA_TARGET_REF: ${{ vars.GITEA_TARGET_REF }}
steps:
- uses: actions/checkout@v4
with:
# Script may not exist on older release tags; always use default branch.
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-windows
path: /tmp/electron-win
- uses: actions/download-artifact@v4
with:
name: electron-dist-linux
path: /tmp/electron-linux
- name: Merge GitHub release assets + Electron build
env:
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
if gh release download "$TAG" -R "${{ github.repository }}" -D /tmp/from-main 2>/tmp/dl.err; then
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
echo "Merged assets from ${{ github.repository }} release $TAG"
else
echo "GitHub release download note (continuing with launcher only):"
cat /tmp/dl.err || true
fi
shopt -s nullglob
for f in /tmp/electron-win/* /tmp/electron-linux/*; do
if [ -f "$f" ]; then
cp -f "$f" combined/
fi
done
ls -la combined/
- name: Upload to Gitea
run: |
set -euo pipefail
for v in GITEA_BASE_URL GITEA_TOKEN GITEA_OWNER GITEA_REPO; do
if [ -z "${!v:-}" ]; then
echo "Missing secret $v — add it under repo Settings → Secrets and variables → Actions." >&2
exit 1
fi
done
bash tools/fractured-launcher-electron/scripts/upload-release-to-gitea.sh combined "${{ needs.meta.outputs.tag }}"
@@ -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);
File diff suppressed because it is too large Load Diff
+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"
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env bash
# Collect VPS evidence for Paragon / DBUpdater / binary staleness triage.
# Run ON the VPS (Linux). Safe: read-only; does not restart services.
#
# Usage (from clone):
# bash scripts/vps-paragon-diagnostics.sh
#
# Optional environment:
# FRACTURED_REPO — absolute path to Fractured git root (default: parent of scripts/)
# FRACTURED_WS_BIN — path to worldserver binary (default: auto-detect)
# FRACTURED_WORLDSERVER_CONF — path to worldserver.conf (default: guess from BIN + common layouts)
# FRACTURED_SYSTEMD_UNITS — space-separated units to try (default: "fractured-world worldserver ac-worldserver")
# FRACTURED_MYSQL — prefix to invoke mysql, e.g. 'mysql -uacore -pacore -h127.0.0.1'
# (default Fractured local DB user/password are often both "acore"; use ~/.my.cnf if you prefer not to pass -p on the command line)
# If unset, SQL blocks are printed for manual copy-paste only.
# FRACTURED_SPELL_IDS — space-separated spell IDs for spell_dbc spot-check (defaults to common DK rune spenders)
# FRACTURED_DIAG_OUTPUT — full log file path (default: <repo>/var/vps-paragon-diagnostics-last.txt)
#
# All output is mirrored to the log file (tee) while still printing to the terminal.
# Default path lives under var/ (gitignored in this repo). Open that file in Cursor,
# scp it down, or: git add -f var/vps-paragon-diagnostics-last.txt if you intend to commit it.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="${FRACTURED_REPO:-$(cd "$SCRIPT_DIR/.." && pwd)}"
DIAG_OUT="${FRACTURED_DIAG_OUTPUT:-$REPO/var/vps-paragon-diagnostics-last.txt}"
mkdir -p "$(dirname "$DIAG_OUT")"
exec > >(tee "$DIAG_OUT") 2>&1
echo "Logging to: $DIAG_OUT"
hr() { printf '\n%s\n' "================================================================================"; }
sub() { printf '\n-- %s\n' "$1"; }
detect_worldserver_bin() {
local bin="" es path u units
if [[ -n "${FRACTURED_WS_BIN:-}" ]]; then
readlink -f "$FRACTURED_WS_BIN" 2>/dev/null && return
echo "$FRACTURED_WS_BIN"
return
fi
units="${FRACTURED_SYSTEMD_UNITS:-fractured-world worldserver ac-worldserver}"
for u in $units; do
if systemctl is-active --quiet "$u" 2>/dev/null || systemctl is-enabled --quiet "$u" 2>/dev/null; then
es=$(systemctl show "$u" -p ExecStart --value 2>/dev/null || true)
if [[ -n "$es" ]]; then
if [[ "$es" == \{*path=* ]]; then
path=$(printf '%s' "$es" | sed -n 's/.*path=\([^;]*\).*/\1/p')
else
path=$(printf '%s' "$es" | awk '{print $1}' | sed 's/^path=//')
fi
if [[ -n "$path" && -x "$path" ]]; then
readlink -f "$path" 2>/dev/null && return
fi
fi
fi
done
local pid
pid=$(pgrep -xo worldserver 2>/dev/null || true)
if [[ -n "$pid" ]]; then
readlink -f "/proc/$pid/exe" 2>/dev/null && return
fi
if command -v worldserver >/dev/null 2>&1; then
readlink -f "$(command -v worldserver)" 2>/dev/null && return
fi
echo ""
}
guess_worldserver_conf() {
local bin="$1"
local d cands=()
[[ -z "$bin" ]] && return
d=$(dirname "$bin")
cands+=("$d/../etc/worldserver.conf")
cands+=("$d/../../etc/worldserver.conf")
cands+=("$HOME/azeroth-server/etc/worldserver.conf")
cands+=("$HOME/env/dist/etc/worldserver.conf")
for f in "${cands[@]}"; do
f=$(readlink -f "$f" 2>/dev/null || true)
if [[ -n "$f" && -f "$f" ]]; then
echo "$f"
return
fi
done
echo ""
}
binary_strings_paths() {
local ws="$1"
[[ -z "$ws" || ! -f "$ws" ]] && return
strings "$ws" 2>/dev/null | grep -iE '/(home|root|opt|srv|var)[^[:space:]]*/(Fractured|fractured|azeroth|AzerothCore|acore)' | sort -u | head -40
}
hr
echo "Fractured Paragon / native VPS diagnostics"
echo "Date (UTC): $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "Repo (expected): $REPO"
sub "1A — worldserver binary"
WS=$(detect_worldserver_bin || true)
if [[ -z "$WS" ]]; then
echo "ERROR: Could not find worldserver. Set FRACTURED_WS_BIN=/full/path/to/worldserver and re-run."
else
echo "Binary: $WS"
if stat -c 'binary mtime: %y' "$WS" 2>/dev/null; then
:
else
stat -f 'binary mtime: %Sm' -t '%Y-%m-%d %H:%M:%S %z' "$WS" 2>/dev/null || stat "$WS"
fi
fi
sub "1B — repo HEAD + Paragon_Essence.cpp mtime"
if [[ -d "$REPO/.git" ]]; then
(cd "$REPO" && git log -1 --format='HEAD commit: %h %ci %s')
else
echo "WARN: not a git repo: $REPO (set FRACTURED_REPO)"
fi
PE="$REPO/modules/mod-paragon/src/Paragon_Essence.cpp"
if [[ -f "$PE" ]]; then
if stat -c 'Paragon_Essence.cpp mtime: %y' "$PE" 2>/dev/null; then
:
else
stat -f 'Paragon_Essence.cpp mtime: %Sm' -t '%Y-%m-%d %H:%M:%S %z' "$PE" 2>/dev/null || stat "$PE"
fi
else
echo "WARN: missing $PE"
fi
sub "1C — strings heuristics (0 can mean stripped binary — use 1A+1B)"
if [[ -n "$WS" && -f "$WS" ]]; then
c1=$(strings "$WS" 2>/dev/null | grep -c 'CLASS_PARAGON' || true)
c2=$(strings "$WS" 2>/dev/null | grep -c 'C BUILD SAVE_CURRENT' || true)
c3=$(strings "$WS" 2>/dev/null | grep -c 'character_paragon_build_share_archive' || true)
echo "CLASS_PARAGON count: $c1"
echo "C BUILD SAVE_CURRENT count: $c2"
echo "character_paragon_build_share_archive count: $c3"
else
echo "(skipped — no binary)"
fi
sub "1D — binary fingerprint (compare sha256 across dev vs VPS)"
if [[ -n "$WS" && -f "$WS" ]]; then
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$WS"
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$WS"
else
echo "(no sha256sum — install coreutils)"
fi
echo "Embedded revision / version strings (first matches):"
strings "$WS" 2>/dev/null | grep -iE 'azerothcore|revision|git|commit|build.*20[0-9]{2}' | head -25 || echo "(none matched)"
else
echo "(skipped — no binary)"
fi
CONF="${FRACTURED_WORLDSERVER_CONF:-}"
if [[ -z "$CONF" && -n "$WS" ]]; then
CONF=$(guess_worldserver_conf "$WS")
fi
sub "2B — worldserver.conf (updater / source / rates / paragon)"
if [[ -n "$CONF" && -f "$CONF" ]]; then
echo "Using conf: $CONF"
grep -E '^SourceDirectory|^Updates\.EnableDatabases|^Updates\.AutoSetup|^[[:space:]]*SourceDirectory|^[[:space:]]*Updates\.EnableDatabases|^[[:space:]]*Updates\.AutoSetup' "$CONF" 2>/dev/null || echo "(no matching lines or unreadable)"
echo "--- Rate.RunicPower (if set) ---"
grep -iE '^Rate\.RunicPower|^[[:space:]]*Rate\.RunicPower' "$CONF" 2>/dev/null || echo "(not set — server uses default)"
echo "--- Paragon.* module options (if any) ---"
grep -iE '^Paragon\.|^[[:space:]]*Paragon\.' "$CONF" 2>/dev/null || echo "(no Paragon.* keys in worldserver.conf — check etc/modules/mod_paragon.conf)"
else
echo "WARN: worldserver.conf not found. Set FRACTURED_WORLDSERVER_CONF=/path/to/worldserver.conf"
fi
if [[ -n "$WS" && -f "$WS" ]]; then
ETCGuess=$(readlink -f "$(dirname "$WS")/../etc" 2>/dev/null || true)
MPC="$ETCGuess/modules/mod_paragon.conf"
if [[ -f "$MPC" ]]; then
sub "2B2 — mod_paragon.conf Paragon.* toggles (non-comment)"
grep -E '^Paragon\.' "$MPC" 2>/dev/null | head -40 || echo "(no uncommented Paragon.* lines)"
fi
fi
sub "2A — path-like strings from binary (candidate source roots)"
if [[ -n "$WS" && -f "$WS" ]]; then
binary_strings_paths "$WS" || true
else
echo "(skipped)"
fi
sub "Resolved source root for 2D"
RESOLVED=""
if [[ -n "$CONF" && -f "$CONF" ]]; then
sd=$(awk -F= '/^[[:space:]]*SourceDirectory[[:space:]]*=/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2);
gsub(/^["'\'']|["'\'']$/, "", $2);
print $2; exit }' "$CONF" 2>/dev/null || true)
if [[ -n "${sd:-}" ]]; then
RESOLVED="$sd"
fi
fi
if [[ -z "$RESOLVED" ]]; then
RESOLVED="$REPO"
fi
echo "Using RESOLVED=$RESOLVED (from SourceDirectory if set in conf, else FRACTURED_REPO)"
sub "2D — Paragon SQL dirs under RESOLVED"
for subdir in \
"$RESOLVED/modules/mod-paragon/data/sql/db-world/updates/" \
"$RESOLVED/modules/mod-paragon/data/sql/db-characters/updates/"; do
if [[ -d "$subdir" ]]; then
echo "Listing: $subdir"
ls -la "$subdir" 2>/dev/null | tail -15
else
echo "MISSING: $subdir"
fi
done
sub "CMake build dir hints (common Fractured layouts)"
for cand in "$REPO/var/build/obj" "$REPO/build" "$REPO/../build"; do
if [[ -f "$cand/CMakeCache.txt" ]]; then
echo "Found CMakeCache: $cand/CMakeCache.txt"
grep -E '^CMAKE_HOME_DIRECTORY:|^MODULES:|^CMAKE_INSTALL_PREFIX:' "$cand/CMakeCache.txt" 2>/dev/null | head -5
fi
done
sub "DATABASE — updates rows (2026_05_10 / paragon)"
SQL_WORLD=$(cat <<'EOS'
SELECT name, hash, speed FROM updates
WHERE name LIKE '2026_05_10%' OR name LIKE '%paragon%'
ORDER BY name DESC LIMIT 30;
EOS
)
SQL_CHAR="$SQL_WORLD"
if [[ -n "${FRACTURED_MYSQL:-}" ]]; then
echo "--- acore_world ---"
$FRACTURED_MYSQL acore_world -e "$SQL_WORLD" || echo "(mysql failed for acore_world)"
echo "--- acore_characters ---"
$FRACTURED_MYSQL acore_characters -e "$SQL_CHAR" || echo "(mysql failed for acore_characters)"
sub "DATABASE — DBC parity for runes / Paragon (acore_world)"
# Common DK rune spenders (WotLK). Override: export FRACTURED_SPELL_IDS='45477 45462'
SPELL_IDS="${FRACTURED_SPELL_IDS:-45477 45462 49923 55050 56815}"
IDS_CSV=$(echo "$SPELL_IDS" | tr ' ' ',')
echo "--- spell_dbc table size (world DB overrides; 0 rows = all spells from disk DBC only) ---"
$FRACTURED_MYSQL acore_world -e "SELECT COUNT(*) AS spell_dbc_rows FROM spell_dbc;" 2>/dev/null || echo "(spell_dbc missing or no access)"
echo "--- acore_world.version (last core revision written by worldserver) ---"
$FRACTURED_MYSQL acore_world -e "SELECT * FROM version LIMIT 5;" 2>/dev/null || echo "(version table missing?)"
echo "--- chrclasses_dbc class 6 + 12 (DisplayPower: 0=mana, 5=POWER_RUNE in AC) ---"
$FRACTURED_MYSQL acore_world -e "
SELECT ID, DisplayPower, Name_Lang_enUS FROM chrclasses_dbc WHERE ID IN (6,12);
" 2>/dev/null || echo "(query failed — chrclasses_dbc missing?)"
echo "Note: If only ID=12 appears, class 6 (DK) is not overridden in DB — loaded from disk DBC (normal)."
echo "--- spell_dbc: are sample DK spells overridden in DB? ---"
spell_sample_n=$($FRACTURED_MYSQL acore_world -N -B -e \
"SELECT COUNT(*) FROM spell_dbc WHERE ID IN ($IDS_CSV);" 2>/dev/null || echo 0)
echo "Row count in spell_dbc for sample IDs ($SPELL_IDS): ${spell_sample_n:-0}"
if [[ "${spell_sample_n:-0}" == "0" ]]; then
echo "=> 0 means those spells use on-disk Spell.dbc only; the sample block below will be empty (not an error)."
fi
echo "--- spell_dbc sample (PowerType 5 = POWER_RUNE in AC) ---"
$FRACTURED_MYSQL acore_world -e "
SELECT ID, PowerType, ManaCost, RuneCostID FROM spell_dbc WHERE ID IN ($IDS_CSV);
" 2>/dev/null || echo "(query failed — spell_dbc missing or wrong schema)"
echo "--- spellrunecost join for sample IDs (empty if no spell_dbc rows above) ---"
$FRACTURED_MYSQL acore_world -e "
SELECT s.ID AS spell_id, s.PowerType, s.RuneCostID, r.Blood, r.Unholy, r.Frost, r.RunicPower
FROM spell_dbc s
LEFT JOIN spellrunecost_dbc r ON r.ID = s.RuneCostID
WHERE s.ID IN ($IDS_CSV);
" 2>/dev/null || echo "(join failed — check spellrunecost_dbc)"
echo "--- spell_dbc suspicious overrides: RuneCostID>0 but PowerType!=5 (can break rune checks) ---"
$FRACTURED_MYSQL acore_world -e "
SELECT ID, PowerType, ManaCost, RuneCostID FROM spell_dbc
WHERE RuneCostID > 0 AND PowerType <> 5
ORDER BY ID LIMIT 40;
" 2>/dev/null || echo "(query failed)"
echo "Compare counts/IDs to dev: unexpected rows here warrant a DB diff."
echo "--- spell_dbc POWER_RUNE (5) spells with RuneCostID (sample) ---"
$FRACTURED_MYSQL acore_world -e "
SELECT ID, PowerType, RuneCostID FROM spell_dbc
WHERE PowerType = 5 AND RuneCostID > 0
ORDER BY ID LIMIT 15;
" 2>/dev/null || echo "(query failed)"
else
echo "FRACTURED_MYSQL not set — run manually (example: export FRACTURED_MYSQL='mysql -uUSER -hHOST')"
echo "acore_world:"
echo "$SQL_WORLD"
echo "acore_characters:"
echo "$SQL_CHAR"
echo ""
echo "Optional DBC parity (acore_world) — run after connecting:"
echo " SELECT ID, DisplayPower, Name_Lang_enUS FROM chrclasses_dbc WHERE ID IN (6,12);"
echo " SELECT ID, PowerType, ManaCost, RuneCostID FROM spell_dbc WHERE ID IN (45477,45462,49923,55050,56815);"
echo " SELECT s.ID, s.RuneCostID, r.Blood, r.Unholy, r.Frost, r.RunicPower FROM spell_dbc s"
echo " LEFT JOIN spellrunecost_dbc r ON r.ID = s.RuneCostID WHERE s.ID IN (45477,45462,49923,55050,56815);"
fi
sub "mod_paragon.conf vs .dist (install etc)"
ETC=""
if [[ -n "$WS" ]]; then
ETC=$(readlink -f "$(dirname "$WS")/../etc" 2>/dev/null || true)
fi
if [[ -z "$ETC" || ! -d "$ETC" ]]; then
ETC=$(readlink -f "$HOME/azeroth-server/etc" 2>/dev/null || true)
fi
if [[ -n "$ETC" && -d "$ETC/modules" ]]; then
MP="$ETC/modules/mod_paragon.conf"
MPD="$ETC/modules/mod_paragon.conf.dist"
if [[ -f "$MP" && -f "$MPD" ]]; then
diff -u "$MP" "$MPD" 2>/dev/null | head -80 || true
else
echo "ETC=$ETC — mod_paragon.conf or .dist missing (MP=$MP MPD=$MPD)"
fi
else
echo "Could not find install etc/modules (set paths manually for diff)."
fi
hr
echo "DELIVERABLE for maintainer:"
echo "1) Paste 1A1D (binary mtime, git HEAD, strings, sha256 + revision strings)."
echo "2) Paste DATABASE blocks: updates + DBC parity (chrclasses 12, spell_dbc, spellrunecost join)."
echo "3) Paste 2A path strings + 2D listings (or MISSING lines)."
echo "4) From dev: same 1D sha256 of worldserver OR same SQL block — proves binary/data parity."
echo "5) ONE sentence: exact in-game symptom."
echo "Done."
echo ""
echo "Full transcript: $DIAG_OUT"
@@ -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
#
###################################################################################################
+27 -1
View File
@@ -9773,7 +9773,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 +12021,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());
+16 -2
View File
@@ -72,11 +72,25 @@
#include "Util.h"
#include "Vehicle.h"
#include "World.h"
#include "WorldConfig.h"
#include "WorldPacket.h"
#include <algorithm>
#include <cmath>
#include <limits>
// 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 (listener && listener->getClass() == CLASS_PARAGON
&& sWorld->getBoolConfig(CONFIG_PARAGON_WILDCARD_FAMILY))
return true;
return expectedFamily == actualFamily;
}
float baseMoveSpeed[MAX_MOVE_TYPE] =
{
2.5f, // MOVE_WALK
@@ -9702,7 +9716,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 +10435,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)
+9
View File
@@ -2268,6 +2268,15 @@ private:
ValuesUpdateCache _valuesUpdateCache;
};
// 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
+3 -1
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)
+26
View File
@@ -3540,6 +3540,32 @@ SpellCastResult Spell::prepare(SpellCastTargets const* targets, AuraEffect const
if (m_caster->ToPlayer()->GetCommandStatus(CHEAT_CASTTIME))
m_casttime = 0;
// Fractured / Paragon: cross-class Predator's Swiftness (69369).
// Stock 3.3.5 only ADD_PCT_MODIFIER's the cast time of Druid-family
// Nature spells via class mask, so a Paragon with the buff cannot
// instant-cast Shaman Chain Lightning / Lightning Bolt or any other
// non-Druid Nature spell. The tooltip ("next Nature spell with a
// base cast time below 10 sec becomes instant") expects all-Nature
// behavior; honor that here for CLASS_PARAGON. We deliberately do
// not touch the stock SpellMod path -- real Druids continue to hit
// the existing class-mask code path unchanged.
if (Player* paragonCaster = m_caster->ToPlayer())
{
constexpr uint32 SPELL_PARAGON_PREDATORY_SWIFTNESS = 69369;
if (m_casttime > 0
&& paragonCaster->getClass() == CLASS_PARAGON
&& (m_spellInfo->SchoolMask & SPELL_SCHOOL_MASK_NATURE)
&& m_spellInfo->CastTimeEntry
&& !m_spellInfo->IsChanneled()
&& !HasTriggeredCastFlag(TRIGGERED_FULL_MASK)
&& m_spellInfo->CalcCastTime() < 10 * IN_MILLISECONDS
&& paragonCaster->HasAura(SPELL_PARAGON_PREDATORY_SWIFTNESS))
{
m_casttime = 0;
paragonCaster->RemoveAurasDueToSpell(SPELL_PARAGON_PREDATORY_SWIFTNESS);
}
}
// 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())
+24 -2
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,7 @@ bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod) const
if (!sScriptMgr->OnIsAffectedBySpellModCheck(affectSpell, this, mod))
return true;
return IsAffected(affectSpell->SpellFamilyName, mod->mask);
return IsAffected(affectSpell->SpellFamilyName, mod->mask, listenerOwner);
}
bool SpellInfo::CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const
+12
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;
@@ -5368,6 +5368,18 @@ void SpellMgr::LoadSpellInfoCorrections()
LockEntry* key = const_cast<LockEntry*>(sLockStore.LookupEntry(36)); // 3366 Opening, allows to open without proper key
key->Type[2] = LOCK_KEY_NONE;
// Fractured / Paragon: DK weapon-line "passives" Forceful Deflection and
// Runic Focus ship in 3.3.5a Spell.dbc without SPELL_ATTR0_PASSIVE set.
// SpellInfo::IsPassive() is therefore false, and mod-paragon's panel-learn
// diff treats them as castable actives and revokes them — while true
// actives (Blood Presence, Death Coil, Death Grip, ...) must stay
// stripped. Mark these two passive in-memory so the panel policy matches
// the spellbook UX for every class (stock DK benefits too).
ApplySpellFix({ 49410, 61455 }, [](SpellInfo* spellInfo)
{
spellInfo->Attributes |= SPELL_ATTR0_PASSIVE;
});
// Fractured: strip reagent requirements from every player-class spell at
// load time. Filtered by SpellFamilyName != 0 so that profession spells
// (cooking, alchemy, enchanting, blacksmithing, jewelcrafting, leatherworking,
+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
};
+114 -30
View File
@@ -1,6 +1,6 @@
# Fractured Launcher (Electron)
Windows launcher with **no extra console window**, **native Browse folder** dialog, GitHub **release assets** + repo file sync, **realmlist**, optional **auth**, **Play**, and **auto-update** (via `electron-updater`). This is the **only** supported client launcher in this repo.
**Windows** and **Linux (AppImage)** launcher with **no extra console window**, **native Browse folder** dialog, **Gitea or GitHub** release assets + GitHub repo file sync, **realmlist**, optional **auth**, **Play**, and **auto-update** (via `electron-updater`). This is the **only** supported client launcher in this repo.
## Requirements
@@ -14,7 +14,12 @@ npm install
npm start
```
On first run, `launcher.json` is created next to the app (dev: in this folder). By default **`github.repo`** is **`Fractured-Distro`** (public release assets); no token is required for patches. Set **GITHUB_TOKEN** only if you override `github` to a **private** repo.
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
- **Recommended (self-hosted Gitea):** set **`gitea.base_url`**, **`gitea.owner`**, **`gitea.repo`** in `launcher.json` (see **`default-launcher.json`**). Players need **`GITEA_TOKEN`** (or the env name in **`gitea.token_env`**) if the Gitea repo is **private** — same trade-off as any private host (per-player token, SSO proxy, or a read-only deploy token you accept distributing).
- **Fallback:** if **`gitea.base_url`** is empty, **`from_release`** uses the **GitHub** Releases API against **`github.owner` / `github.repo`** (defaults to this **`Fractured`** repo for non-release paths), with optional **`GITHUB_TOKEN`** for private assets.
## Build Windows installers
@@ -30,63 +35,142 @@ Produces under **`dist/`**:
| `Fractured-Launcher-${version}-Setup.exe` (NSIS) | **Recommended for players** — supports seamless **auto-update** and restart. |
| `Fractured-Launcher-${version}-Windows-Portable.exe` | No installer; players replace the file manually. Auto-update is **less reliable** than NSIS. |
## Build Linux AppImage
```bash
cd tools/fractured-launcher-electron
npm install
npm run pack:linux
```
Produces **`dist/Fractured-Launcher-${version}-Linux-x86_64.AppImage`**. Same **`lib/baked-gitea-channel.js`** and **`default-launcher.json`** as Windows; run on **Linux** (or use **Fractured launcher CI** / **Sync release to Gitea**, which upload this file to Gitea with the Windows installers).
**Quick local test (avoids tag snapshot / CI):**
- **Linux:** from repo root, **`bash tools/fractured-launcher-electron/scripts/manual-pack-linux.sh`** → **`dist/*.AppImage`**.
- **Windows:** on a Windows machine, **`cd tools/fractured-launcher-electron`**, **`npm ci`**, **`npm run pack:win`** → **`dist/*.exe`**.
### Hardcoded Gitea channel (non-token)
**`lib/baked-gitea-channel.js`** exports **`base_url`**, **`owner`**, **`repo`**, **`release_tag`**. Set those strings once in the repo (same values you use for CI upload — not secret). At runtime **`config-store`** merges them into **`gitea.*`** so **`launcher.json`** does not need those fields; **`GITEA_TOKEN`** (or **`gitea.token_env`**) is still only for **private** Gitea. Leave a field **`''`** in the baked file to fall back to **`default-launcher.json`** / user **`launcher.json`** for that key.
**`npm run pack:win`** is plain **electron-builder** — no inject step, no extra JSON beside the app.
## Auto-update behaviour
- **Packaged** builds only (`npm run pack:win` output). In `npm start` dev mode, update checks are skipped (button still explains that).
- **~5 seconds** after launch, then **every 6 hours**, the app checks for a newer version.
- **No implicit GitHub feed:** the app does **not** guess `package.json``repository` anymore. Without configuration you get a clear “skipped” message instead of a **404** on a private repo.
- **Configured feeds** (first match wins): **`update_feed_url` / `LAUNCHER_UPDATE_URL`** (generic `latest.yml`); or **`gitea`** block filled in + **`GITEA_TOKEN`** when the instance is private (resolves `…/releases/download/{tag}/`); or **`GITHUB_TOKEN`** + **`github.owner` / `github.repo`** for **private** GitHub releases only.
- **~5 seconds** after launch, then **every 6 hours**, the app checks when a feed is configured.
- When a download finishes, a dialog offers **Restart now** (calls `quitAndInstall`) or **Later**.
- **Manual check:** button **Check launcher updates** in the UI.
### Where updates are hosted
### Where launcher updates are hosted
**`package.json`** → `build.publish` targets **`Dawnforger/Fractured-Distro`** (public). `electron-updater` reads **`latest.yml`** from the **latest** release there; players do not need a GitHub token for launcher updates.
**`npm run publish:win`** runs **`electron-builder` with `--publish never`** — artifacts stay in **`dist/`**; CI uploads them to Gitea when you **publish a GitHub release**. For ad-hoc uploads, use **`scripts/upload-release-to-gitea.sh`**. For launcher auto-update, prefer:
**Private GitHub** (if you change `build.publish` or `github` back to a private repo): set **`GH_TOKEN`** / **`GITHUB_TOKEN`** / **`github.token_env`** before starting the launcher so the updater can authenticate.
- Set **`update_feed_url`** (or **`LAUNCHER_UPDATE_URL`**) to a **generic** HTTPS base URL where **`latest.yml`** and the installer files are hosted (often the same Gitea release attachment URLs pattern your reverse proxy exposes), **or**
- Keep publishing to a GitHub release only for **`latest.yml`** + installers if you accept that small metadata/binary channel there.
**Public generic feed** (optional CDN): set **`update_feed_url`** or **`LAUNCHER_UPDATE_URL`** to a folder that hosts `latest.yml` + installers; optional Bearer token if the host requires it.
**Private GitHub** updater: set **`GH_TOKEN`** / **`GITHUB_TOKEN`** / **`github.token_env`** as documented in `lib/auto-update.js` behaviour.
**Generic feed:** optional Bearer token via the same token envs if your static host checks `Authorization`.
### Publishing a new launcher version
1. Bump **`version`** in `package.json` (semver, e.g. `1.0.1`).
2. Create a **GitHub personal access token** with `repo` (or `public_repo` for public repos).
3. From this directory:
1. Bump **`version`** in `package.json` on `main` (or your release branch) and merge.
2. Create a **GitHub release** (tag + attach patches / `Wow.exe` if needed) and click **Publish****Sync release to Gitea** builds **Windows + Linux** launcher artifacts and mirrors everything to Gitea.
3. Local check: **`npm run pack:win`** (on Windows) or **`npm run pack:linux`** / **`scripts/manual-pack-linux.sh`**, then **`scripts/upload-release-to-gitea.sh`** with the same **`GITEA_*`** env vars as CI if you need a manual upload.
```bash
set GH_TOKEN=ghp_your_token_here
npm run publish:win
## Sync to Gitea (patches + launcher binaries)
CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml`) runs on **every published GitHub release** on this repo:
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 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):
| Secret | Example |
|--------|---------|
| **`GITEA_BASE_URL`** | `https://git.yourdomain.com` (no trailing slash) |
| **`GITEA_TOKEN`** | Gitea personal access token with permission to manage releases and attachments on the target repo |
| **`GITEA_OWNER`** | Organization or username on Gitea |
| **`GITEA_REPO`** | Repository name — must already have **at least one commit** (Gitea returns HTTP 422 “repo is empty” for zero-commit repos; push e.g. a README on **`main`** or set **`GITEA_TARGET_REF`** to your default branch) |
**Optional variable** (Settings → Variables): **`GITEA_TARGET_REF`** — default branch/commitish used **only when the workflow must create a new Gitea release** and Gitea needs `target_commitish` (defaults to **`main`** in the upload script if unset).
**Player `launcher.json`:** packaged builds should already include **`gitea.base_url` / `owner` / `repo`** from the bake step above. Players only need to set **`GITEA_TOKEN`** (or your **`token_env`**) if the Gitea repo is **private**. To point at another instance, edit **`gitea`** in **`launcher.json`**:
```json
"gitea": {
"base_url": "https://git.yourdomain.com",
"owner": "myorg",
"repo": "fractured-patches",
"release_tag": "latest",
"token_env": "GITEA_TOKEN"
}
```
That builds NSIS + portable and **uploads** update metadata and installers to the configured GitHub repos **releases** (see [electron-builder publish](https://www.electron.build/configuration/publish)).
**Manual upload:** `bash scripts/upload-release-to-gitea.sh /path/to/files v1.0.0` with the same env vars as CI.
Players on an older NSIS install will pick up the next version automatically on the next check.
### Sync did not run / Gitea unchanged — checklist
## Public distro repo (patches + launcher binaries)
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. **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.
Default **`default-launcher.json`** uses **`github.repo`: `Fractured-Distro`** so **`from_release`** assets (`patch-Z.MPQ`, `Wow-patched.exe`, …) download from **[Dawnforger/Fractured-Distro releases](https://github.com/Dawnforger/Fractured-Distro/releases)** — **no player token**.
### Private Gitea token for players
Publish assets there by:
Do **not** embed a shared admin PAT in a shipped `launcher.json`. Prefer read-only tokens scoped to one repo, short-lived tokens, or a small auth service that redirects to signed URLs.
- **GitHub Actions** — workflow **Sync release to Fractured-Distro** (`.github/workflows/distro-release-sync.yml`): on **release published** on `Dawnforger/Fractured`, it **builds the Electron launcher** from that tag on Windows (`npm run pack:win`), **downloads every asset** attached to that release (patches, `Wow-patched.exe`, …), merges them (launcher files overwrite on duplicate names), and creates or updates the **same tag** on **`Fractured-Distro`**. Requires repository secret **`DISTRO_SYNC_TOKEN`**. **Manual:** Actions → run workflow with an existing tag.
- **Locally:** `scripts/publish-to-distro.sh` (see script header) if you need to upload without a full release cycle.
**Release asset names** must match **`files[].source`** when **`from_release`**: true. Use **`release_tag`**: `"latest"` or a pinned tag matching both GitHub and Gitea.
If your public repo slug is not `Fractured-Distro`, edit **`DISTRO_REPO`** in the workflow / script and **`github.repo`** in `launcher.json`.
## Patch versions (same filenames, different bytes)
### Private `github.repo` (optional)
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):
For a **private** release source, set `GITHUB_TOKEN` (or `github.token_env`) with read access — **do not** embed a shared PAT in shipped `launcher.json` for all players.
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.
3. After a real download, the launcher **re-hashes** installed files and compares to the manifest; mismatch → clear error. It also writes **`.fractured/patch-state.json`** under the WoW folder so the UI can show **“Installed client files: …”**.
**Release asset names** must match **`files[].source`** exactly when **`from_release`**: true. Use **`release_tag`: `"latest"`** for the newest release, or pin a tag.
If **`patch-manifest.json`** is missing on a release, the launcher falls back to **always downloading** all configured files (same as before).
**Generate the manifest** when you cut a release (paths are your local patch binaries):
```bash
cd /path/to/staging
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).
## CI
Workflow **Fractured launcher CI** (`.github/workflows/fractured-launcher-ci.yml`) runs on pushes/PRs under `tools/fractured-launcher-electron/`: Windows **`npm run pack:win`** and **artifacts** (`*.exe`, `latest.yml`, blockmaps). **Actions → Fractured launcher CI → Run workflow** runs it manually.
Workflow **Fractured launcher CI** (`.github/workflows/fractured-launcher-ci.yml`) runs on pushes/PRs under `tools/fractured-launcher-electron/`: **Windows** (`npm run pack:win`) and **Linux** (`npm run pack:linux`) jobs, each **`electron-builder … --publish never`**. **Actions → Fractured launcher CI → Run workflow** runs it manually.
**Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml`) uses the same pack commands. If you see `GH_TOKEN` / `GitHubPublisher` errors in logs, the job is almost certainly an old **Re-run failed jobs** — open **Actions → Sync release to Gitea → Run workflow**, enter the tag, and start a **new** run instead.
## 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 (overrides default GitHub feed when set).
- **`github`**: `owner`, `repo`, `ref` (repo file paths), **`release_tag`** (`latest` or e.g. `v1.0.0`), **`token_env`** (env var name for a PAT when using private sources).
- **`files`**: `source`, `dest`, `backup`, **`from_release`** (asset name on GitHub release vs repo path).
- **`realmlist`**, **`auth`**, **`launch`**.
- **`update_feed_url`**: optional generic HTTPS base for launcher auto-update.
- **`launcher_updates_from_github`**: default **`false`**. Only when **`true`** will a **`GITHUB_TOKEN`** (or **`github.token_env`**) enable **electron-updater**s GitHub provider against **`github.owner` / `github.repo`**. Leave **`false`** when launcher binaries and **`latest.yml`** live on **Gitea** (use **`gitea`** + token instead) so a stray GitHub token does not produce “No published versions on GitHub”.
- **`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`**: 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.
@@ -1,31 +1,31 @@
{
"game_dir": "",
"update_feed_url": "",
"launcher_updates_from_github": false,
"gitea": {
"base_url": "",
"owner": "",
"repo": "",
"release_tag": "latest",
"token_env": "GITEA_TOKEN"
},
"github": {
"owner": "Dawnforger",
"repo": "Fractured-Distro",
"repo": "Fractured",
"ref": "main",
"release_tag": "latest",
"token_env": "GITHUB_TOKEN"
},
"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
}
],
"patch_manifest": {
"enabled": true,
"source": "patch-manifest.json",
"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,
@@ -37,6 +37,9 @@
"launch": {
"exe": "Wow.exe",
"args": [],
"linux_wrapper": ["wine"]
"linux_wrapper": ["wine"],
"linux_steam_uri": "",
"linux_steam_binary": "",
"linux_steam_spawn": []
}
}
@@ -2,28 +2,51 @@
const { dialog } = require('electron');
const { autoUpdater } = require('electron-updater');
const { useGiteaReleases, getGiteaUpdaterFeedBase } = require('./gitea-release');
/**
* @param {import('electron').App} app
* @param {() => import('electron').BrowserWindow | null} getMainWindow
* @param {{ updateFeedUrl?: string, githubOwner?: string, githubRepo?: string, token?: string }} opts
* @param {{
* updateFeedUrl?: string,
* githubOwner?: string,
* githubRepo?: string,
* githubToken?: string,
* giteaToken?: string,
* allowGithubLauncherUpdates?: boolean,
* config?: object,
* }} opts
*/
function setupAutoUpdater(app, getMainWindow, opts = {}) {
async function setupAutoUpdater(app, getMainWindow, opts = {}) {
if (!app.isPackaged) {
return {
checkNow: async () => ({ skipped: true, reason: 'development build' }),
};
}
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
const token = String(opts.token || '').trim();
const ghToken = String(opts.githubToken || '').trim();
const giteaTok = String(opts.giteaToken || '').trim();
const envGeneric = String(process.env.LAUNCHER_UPDATE_URL || '').trim();
const configGeneric = String(opts.updateFeedUrl || '').trim();
const genericUrl = envGeneric || configGeneric;
const owner = String(opts.githubOwner || 'Dawnforger').trim();
const repo = String(opts.githubRepo || 'Fractured').trim();
let genericUrl = envGeneric || configGeneric;
let genericAuthHeader = '';
if (!genericUrl && opts.config && useGiteaReleases(opts.config)) {
const gfb = await getGiteaUpdaterFeedBase(opts.config);
if (gfb && gfb.url) {
genericUrl = gfb.url;
const t = String(gfb.token || giteaTok || '').trim();
if (t) genericAuthHeader = `token ${t}`;
}
} else if (genericUrl) {
if (giteaTok) genericAuthHeader = `token ${giteaTok}`;
else if (ghToken) genericAuthHeader = `Bearer ${ghToken}`;
}
const owner = String(opts.githubOwner || '').trim();
const repo = String(opts.githubRepo || '').trim();
let feedConfigured = false;
if (genericUrl) {
const base = genericUrl.replace(/\/?$/, '/');
@@ -31,22 +54,37 @@ function setupAutoUpdater(app, getMainWindow, opts = {}) {
provider: 'generic',
url: base,
});
if (token) {
if (genericAuthHeader) {
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
Authorization: `Bearer ${token}`,
Authorization: genericAuthHeader,
};
}
} else if (token && owner && repo) {
feedConfigured = true;
} else if (opts.allowGithubLauncherUpdates && ghToken && owner && repo) {
autoUpdater.setFeedURL({
provider: 'github',
owner,
repo,
private: true,
token,
token: ghToken,
});
feedConfigured = true;
}
if (!feedConfigured) {
const reason =
'No update channel configured. Set launcher.json → update_feed_url (HTTPS folder with latest.yml), ' +
'or fill gitea.base_url/owner/repo (+ GITEA_TOKEN for private), ' +
'or set launcher_updates_from_github to true with GITHUB_TOKEN for private GitHub release feeds.';
return {
checkNow: async () => ({ skipped: true, reason }),
};
}
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
const send = (msg) => {
const w = getMainWindow();
if (w && !w.isDestroyed()) {
@@ -63,9 +101,8 @@ function setupAutoUpdater(app, getMainWindow, opts = {}) {
const m = (err && (err.message || String(err))) || '';
if (/404|releases\.atom|HttpError:\s*404/i.test(m)) {
send(
'Launcher update: could not read GitHub releases (404). ' +
'If the repo is private, set GITHUB_TOKEN (or your token_env) so the launcher can authenticate, ' +
'or set update_feed_url in launcher.json to a public HTTPS folder that contains latest.yml.'
'Launcher update: 404 (no latest.yml or wrong URL). For Gitea use gitea.* + token, or set update_feed_url. ' +
'For private GitHub set GITHUB_TOKEN.'
);
return;
}
@@ -0,0 +1,14 @@
'use strict';
/**
* Production Gitea mirror (non-secret). Edit here and ship no inject script,
* no fractured-release-channel.json, no CI env needed for these fields.
* Token stays in env: GITEA_TOKEN or launcher.json gitea.token_env.
*/
module.exports = {
// 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 {
@@ -11,17 +31,44 @@ function mergeConfig(defaults, user) {
user.update_feed_url != null && user.update_feed_url !== ''
? user.update_feed_url
: defaults.update_feed_url,
launcher_updates_from_github:
user.launcher_updates_from_github != null
? user.launcher_updates_from_github
: defaults.launcher_updates_from_github,
github: { ...defaults.github, ...(user.github || {}) },
gitea: { ...defaults.gitea, ...(user.gitea || {}) },
patch_manifest: { ...defaults.patch_manifest, ...(user.patch_manifest || {}) },
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),
};
}
/** Hardcoded Gitea host/repo (see lib/baked-gitea-channel.js). Non-empty baked values win. */
function applyBakedGitea(cfg) {
let baked;
try {
baked = require('./baked-gitea-channel');
} catch {
return cfg;
}
if (!baked || typeof baked !== 'object') return cfg;
cfg.gitea = { ...(cfg.gitea || {}) };
for (const k of ['base_url', 'owner', 'repo', 'release_tag']) {
const v = baked[k];
if (v != null && String(v).trim() !== '') cfg.gitea[k] = String(v).trim();
}
return 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');
@@ -33,11 +80,16 @@ 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: 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') {
await fs.writeFile(p, JSON.stringify(defaults, null, 2), 'utf8');
return { configPath: p, config: JSON.parse(JSON.stringify(defaults)) };
const initial = applyBakedGitea(mergeConfig(defaults, {}));
await fs.writeFile(p, JSON.stringify(initial, null, 2), 'utf8');
return { configPath: p, config: JSON.parse(JSON.stringify(initial)) };
}
throw e;
}
@@ -48,7 +100,7 @@ async function saveGameDir(configPath, gameDir) {
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
const user = JSON.parse(await fs.readFile(configPath, 'utf8'));
user.game_dir = gameDir;
const merged = mergeConfig(defaults, user);
const merged = applyBakedGitea(mergeConfig(defaults, user));
await fs.writeFile(configPath, JSON.stringify(merged, null, 2), 'utf8');
return merged;
}
@@ -56,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 };
module.exports = { getConfigPath, loadConfig, saveGameDir, resolveGameDir, mergeConfig, applyBakedGitea };
@@ -0,0 +1,120 @@
'use strict';
const { downloadBodyToFile, fetchOrThrow } = require('./http-download');
function normalizeGiteaBaseUrl(raw) {
let b = String(raw || '').trim().replace(/\/+$/, '');
if (!b) return '';
if (!/^https?:\/\//i.test(b)) b = `https://${b}`;
return b;
}
function giteaApiBase(cfg) {
const base = normalizeGiteaBaseUrl(cfg.gitea.base_url);
return `${base}/api/v1`;
}
function giteaToken(cfg) {
const name = cfg.gitea && cfg.gitea.token_env;
if (name && process.env[name]) return String(process.env[name]).trim();
return String(process.env.GITEA_TOKEN || '').trim();
}
function giteaHeaders(token, json = false) {
const h = { 'User-Agent': 'Fractured-Launcher-Electron' };
if (json) h.Accept = 'application/json';
if (token) h.Authorization = `token ${token}`;
return h;
}
function useGiteaReleases(cfg) {
const g = cfg.gitea;
if (!g) return false;
return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim());
}
async function fetchGiteaReleaseRecord(cfg) {
const api = giteaApiBase(cfg);
const { owner, repo } = cfg.gitea;
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
const token = giteaToken(cfg);
let listUrl;
if (tag.toLowerCase() === 'latest') {
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`;
} else {
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
}
const res = await fetchOrThrow(listUrl, { headers: giteaHeaders(token, true) });
const text = await res.text();
if (!res.ok) {
let hint = '';
if (res.status === 404) hint = ' (wrong tag / no release / check base_url owner repo)';
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)}`);
}
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) {
if (a.name !== assetName) continue;
downloadUrl = a.browser_download_url || a.download_url || '';
break;
}
if (!downloadUrl) {
const names = list.map((x) => x.name).filter(Boolean);
throw new Error(`Gitea release asset "${assetName}" not found; attachments: ${names.join(', ') || '(none)'}`);
}
const h = { Accept: 'application/octet-stream' };
if (token) h.Authorization = `token ${token}`;
const dl = await fetchOrThrow(downloadUrl, { headers: h, redirect: 'follow' });
await downloadBodyToFile(dl, destPath);
}
/**
* Base URL for electron-updater generic provider (expects latest.yml under this path).
* Matches Giteas pattern: /owner/repo/releases/download/{tag}/latest.yml
*/
async function getGiteaUpdaterFeedBase(cfg) {
if (!useGiteaReleases(cfg)) return null;
const api = giteaApiBase(cfg);
const { owner, repo } = cfg.gitea;
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
const token = giteaToken(cfg);
let listUrl;
if (tag.toLowerCase() === 'latest') {
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`;
} else {
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
}
const res = await fetchOrThrow(listUrl, { headers: giteaHeaders(token, true) });
if (!res.ok) return null;
const rel = await res.json();
const tagName = rel.tag_name;
if (!tagName || typeof tagName !== 'string') return null;
const root = normalizeGiteaBaseUrl(cfg.gitea.base_url);
const url = `${root}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/download/${encodeURIComponent(tagName)}/`;
return { url, token };
}
module.exports = {
downloadGiteaReleaseAsset,
fetchGiteaReleaseRecord,
listGiteaReleaseAttachmentNames,
giteaToken,
useGiteaReleases,
getGiteaUpdaterFeedBase,
};
@@ -3,7 +3,8 @@
const path = require('path');
const fs = require('fs').promises;
const { githubToken } = require('./github-token');
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, '');
@@ -34,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)}`);
@@ -64,7 +65,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
throw new Error(`unexpected GitHub response for ${repoPath}`);
}
async function downloadReleaseAsset(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;
@@ -74,15 +75,35 @@ 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 = '';
if (res.status === 404) hint = ' (wrong tag or private repo without token?)';
if (res.status === 404) {
hint =
' (wrong tag, private repo without token, or releases live on Gitea — set gitea.base_url, gitea.owner, gitea.repo in launcher.json)';
}
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) {
@@ -107,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 };
@@ -0,0 +1,150 @@
'use strict';
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
const { createHash } = require('node:crypto');
const { downloadReleaseAsset, downloadGitHubRepoFile } = require('./github');
async function sha256File(absPath) {
const buf = await fs.readFile(absPath);
return createHash('sha256').update(buf).digest('hex');
}
function stateDir(gameDir) {
return path.join(gameDir, '.fractured');
}
function statePath(gameDir) {
return path.join(stateDir(gameDir), 'patch-state.json');
}
async function readPatchState(gameDir) {
if (!gameDir) return null;
try {
const t = await fs.readFile(statePath(gameDir), 'utf8');
return JSON.parse(t);
} catch {
return null;
}
}
async function writePatchState(gameDir, manifestVersion, fileShas) {
const p = statePath(gameDir);
await fs.mkdir(path.dirname(p), { recursive: true });
const body = {
client_build: manifestVersion,
updated_at: new Date().toISOString(),
files: fileShas,
};
const tmp = p + '.tmp';
await fs.writeFile(tmp, JSON.stringify(body, null, 2), 'utf8');
await fs.rename(tmp, p);
}
function validateManifest(m) {
if (!m || m.version == null || String(m.version).trim() === '') return false;
if (!m.files || typeof m.files !== 'object') return false;
return true;
}
/**
* Download and parse patch-manifest.json (or custom name). Returns null on any failure.
*/
async function loadManifest(cfg) {
const pm = cfg.patch_manifest;
if (!pm || !pm.enabled || !String(pm.source || '').trim()) return null;
const tmp = path.join(os.tmpdir(), `fr-patch-manifest-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
try {
if (pm.from_release) {
await downloadReleaseAsset(cfg, String(pm.source).trim(), tmp);
} else {
await downloadGitHubRepoFile(cfg, String(pm.source).trim(), tmp);
}
const raw = await fs.readFile(tmp, 'utf8');
await fs.unlink(tmp).catch(() => {});
return JSON.parse(raw);
} catch {
await fs.unlink(tmp).catch(() => {});
return null;
}
}
/**
* True if every from_release file on disk matches manifest sha256.
*/
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 entries) {
if (!entry.from_release) continue;
const spec = manifest.files[entry.source];
if (!spec || !spec.sha256) return false;
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
const destAbs = path.join(gameDir, ...parts);
let disk;
try {
disk = await sha256File(destAbs);
} catch {
return false;
}
if (disk.toLowerCase() !== String(spec.sha256).trim().toLowerCase()) return false;
}
if (onStatus) {
onStatus(`Client files already match build ${manifest.version} (nothing to download).`);
}
return true;
}
async function verifyInstalledAgainstManifest(cfg, manifest) {
if (!validateManifest(manifest)) return;
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) {
throw new Error(
`patch-manifest.json is missing a sha256 for "${entry.source}" — regenerate the manifest for this release.`
);
}
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
const destAbs = path.join(cfg.game_dir, ...parts);
const disk = await sha256File(destAbs);
if (disk.toLowerCase() !== String(spec.sha256).trim().toLowerCase()) {
throw new Error(
`${entry.source}: checksum mismatch after install (expected ${spec.sha256.slice(0, 12)}…, got ${disk.slice(0, 12)}…). Try syncing again.`
);
}
}
}
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 entries) {
if (!entry.from_release) continue;
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
const destAbs = path.join(cfg.game_dir, ...parts);
try {
shas[entry.source] = await sha256File(destAbs);
} catch {
/* skip */
}
}
await writePatchState(cfg.game_dir, String(manifest.version), shas);
}
module.exports = {
loadManifest,
validateManifest,
patchesMatchManifest,
verifyInstalledAgainstManifest,
recordPatchState,
readPatchState,
statePath,
};
+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 };
+60 -9
View File
@@ -4,7 +4,9 @@ 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');
let mainWindow;
@@ -46,16 +48,23 @@ async function readMergedConfig() {
app.whenReady().then(async () => {
createWindow();
const { config } = await loadConfig(app);
const tokenEnv = config.github && config.github.token_env;
const token =
(tokenEnv && String(process.env[tokenEnv] || '').trim()) ||
const ghEnv = config.github && config.github.token_env;
const githubToken =
(ghEnv && String(process.env[ghEnv] || '').trim()) ||
String(process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '').trim();
const giteaEnv = config.gitea && config.gitea.token_env;
const giteaToken =
(giteaEnv && String(process.env[giteaEnv] || '').trim()) ||
String(process.env.GITEA_TOKEN || '').trim();
const updateFeedUrl = String(process.env.LAUNCHER_UPDATE_URL || config.update_feed_url || '').trim();
autoUpdateApi = setupAutoUpdater(app, () => mainWindow, {
autoUpdateApi = await setupAutoUpdater(app, () => mainWindow, {
updateFeedUrl,
config,
githubOwner: config.github && config.github.owner,
githubRepo: config.github && config.github.repo,
token,
githubToken,
giteaToken,
allowGithubLauncherUpdates: config.launcher_updates_from_github === true,
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
@@ -68,12 +77,18 @@ app.on('window-all-closed', () => {
ipcMain.handle('launcher:load', async () => {
const { configPath, config } = await readMergedConfig();
let clientBuild = '';
if (wowInstallValid(config)) {
const st = await readPatchState(config.game_dir);
if (st && st.client_build) clientBuild = String(st.client_build);
}
return {
configPath,
gameDir: config.game_dir || '',
authEnabled: !!(config.auth && config.auth.enabled),
wowExe: (config.launch && config.launch.exe) || 'Wow.exe',
wowOk: wowInstallValid(config),
clientBuild,
};
});
@@ -81,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'}`);
@@ -129,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,
+21 -10
View File
@@ -1,6 +1,6 @@
{
"name": "fractured-launcher-electron",
"version": "1.0.1",
"version": "1.0.12",
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
"main": "main.js",
"repository": {
@@ -9,8 +9,9 @@
},
"scripts": {
"start": "electron .",
"pack:win": "electron-builder --win nsis portable --x64",
"publish:win": "electron-builder --win nsis portable --x64 --publish always"
"pack:win": "electron-builder --win nsis portable --x64 --publish never",
"pack:linux": "electron-builder --linux AppImage --x64 --publish never",
"publish:win": "electron-builder --win nsis portable --x64 --publish never"
},
"author": "",
"license": "GPL-3.0",
@@ -27,13 +28,7 @@
"directories": {
"output": "dist"
},
"publish": [
{
"provider": "github",
"owner": "Dawnforger",
"repo": "Fractured-Distro"
}
],
"publish": null,
"files": [
"main.js",
"preload.cjs",
@@ -41,6 +36,10 @@
"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",
"lib/**/*"
],
"win": {
@@ -62,6 +61,18 @@
},
"portable": {
"artifactName": "Fractured-Launcher-${version}-Windows-Portable.${ext}"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64"]
}
],
"category": "Game"
},
"appImage": {
"artifactName": "Fractured-Launcher-${version}-Linux-x86_64.${ext}"
}
}
}
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Push a one-file README so the Gitea repo is non-empty (fixes HTTP 422 "repo is empty"
# when CI creates a release). Safe to re-run only if the repo still has no commits;
# if it already has history, skip or use the Gitea web UI instead.
#
# Usage:
# export GITEA_BASE_URL=https://git.example.com
# export GITEA_OWNER=myorg
# export GITEA_REPO=fractured-patches
# ./bootstrap-gitea-repo.sh
#
# Or pass an explicit clone URL (HTTPS or SSH):
# ./bootstrap-gitea-repo.sh https://git.example.com/myorg/fractured-patches.git
#
set -euo pipefail
BRANCH="${GITEA_TARGET_REF:-main}"
if [ "${1:-}" != "" ]; then
URL="$1"
else
: "${GITEA_BASE_URL:?Set GITEA_BASE_URL or pass clone URL as first argument}"
: "${GITEA_OWNER:?Set GITEA_OWNER or pass clone URL as first argument}"
: "${GITEA_REPO:?Set GITEA_REPO or pass clone URL as first argument}"
BASE="${GITEA_BASE_URL%/}"
URL="${BASE}/${GITEA_OWNER}/${GITEA_REPO}.git"
fi
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
cd "$TMP"
git init -q
git checkout -q -b "$BRANCH"
cat >README.md <<'EOF'
# Fractured release mirror
Release assets (launcher builds, patches, `patch-manifest.json`, etc.) are uploaded here by **GitHub Actions** (“Sync release to Gitea”) from the main Fractured repository.
This initial commit exists because **Gitea requires at least one commit** in the repository before releases can be created.
EOF
git add README.md
git commit -q -m "chore: initial commit (required for Gitea releases)"
git remote add origin "$URL"
git push -u origin "$BRANCH"
echo "Pushed initial README to $URL (branch $BRANCH)."
@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* Build patch-manifest.json for a release (same names as files[].source in launcher.json).
*
* 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 Wow-patched.exe > patch-manifest.json
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
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 Wow-patched.exe');
process.exit(1);
}
const out = { version, files: {} };
for (const f of names) {
const base = path.basename(f);
const buf = fs.readFileSync(f);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
out.files[base] = { sha256 };
}
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Local Linux AppImage build (uses current tree — no tag snapshot). Run from repo root or this dir.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "==> npm ci"
npm ci
echo "==> npm run pack:linux (AppImage x64)"
npm run pack:linux
echo "==> dist/:"
ls -la dist/
@@ -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
}
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Upload all files in a directory as attachments on a Gitea release (create release if missing).
#
# Usage:
# export GITEA_BASE_URL=https://git.example.com
# export GITEA_TOKEN=gta_...
# export GITEA_OWNER=myorg
# export GITEA_REPO=fractured-patches
# export GITEA_TARGET_REF=main # optional, used when creating a new release (tag must not exist yet)
# ./upload-release-to-gitea.sh /path/to/combined v1.0.0
#
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)}"
: "${GITEA_BASE_URL:?Set GITEA_BASE_URL (no trailing slash required)}"
: "${GITEA_TOKEN:?Set GITEA_TOKEN}"
: "${GITEA_OWNER:?Set GITEA_OWNER}"
: "${GITEA_REPO:?Set GITEA_REPO}"
BASE="${GITEA_BASE_URL%/}"
API="$BASE/api/v1"
TARGET="${GITEA_TARGET_REF:-main}"
AUTH_H=(-H "Authorization: token ${GITEA_TOKEN}" -H "Accept: application/json")
TAG_ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$TAG")
REL_JSON=$(mktemp)
trap 'rm -f "$REL_JSON"' EXIT
code=$(curl -sS -o "$REL_JSON" -w "%{http_code}" "${AUTH_H[@]}" \
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${TAG_ENC}")
if [ "$code" = "200" ]; then
rel_id=$(jq -r '.id' "$REL_JSON")
elif [ "$code" = "404" ]; then
body=$(jq -n \
--arg tag "$TAG" \
--arg name "Fractured $TAG" \
--arg body "Synced from GitHub Actions (Fractured)." \
--arg target "$TARGET" \
'{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false,target_commitish:$target}')
code=$(curl -sS -o "$REL_JSON" -w "%{http_code}" -X POST "${AUTH_H[@]}" \
-H "Content-Type: application/json" \
-d "$body" \
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases")
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
echo "Gitea create release failed HTTP $code:" >&2
cat "$REL_JSON" >&2
if [ "$code" = "422" ] && jq -e '.message == "repo is empty"' "$REL_JSON" >/dev/null 2>&1; then
echo >&2
echo "Gitea does not allow releases on a repo with zero commits. Fix: push at least one commit" >&2
echo "to ${GITEA_OWNER}/${GITEA_REPO} (e.g. add README.md on branch ${TARGET} via web UI or git push)," >&2
echo "or set Actions variable GITEA_TARGET_REF to an existing default branch name." >&2
fi
exit 1
fi
rel_id=$(jq -r '.id' "$REL_JSON")
else
echo "Gitea GET release by tag failed HTTP $code:" >&2
cat "$REL_JSON" >&2
exit 1
fi
if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then
echo "Could not resolve Gitea release id" >&2
exit 1
fi
shopt -s nullglob
files=("$COMBINED_DIR"/*)
if [ "${#files[@]}" -eq 0 ]; then
echo "No files in $COMBINED_DIR" >&2
exit 1
fi
uploadable=0
for f in "${files[@]}"; do
[ -f "$f" ] || continue
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 $uploaded file(s)."