feat(pipeline): WPRC spell proc rules catalog (138th open format)

Novel replacement for the implicit proc-on-event spell triggers
vanilla WoW carried in SpellProcEvents (later rows of
Spell.dbc) + the per-spell procFlags + procChance fields
scattered across multiple DBC tables. Each WPRC entry binds
one proc rule to its source spell (the aura/buff that has the
proc), trigger event (OnHit/OnCrit/OnCast/OnTakeDamage/OnHeal/
OnDodge/OnParry/OnBlock/OnKill), proc chance in basis points
(0..10000 = 0..100%), internal cooldown ms, the spell to
trigger on proc, max-stacks-on-target cap, and optional
condition flags (RequireMeleeWeapon / RequireSpellSchool /
ExcludeAutoAttack / OnlyFromBehind / OnlyVsPvPTarget).

Three presets covering common vanilla proc archetypes:
  --gen-prc-weapon  3 weapon-enchant procs (Crusader 2%
                    OnHit / Lifestealing 5% / Fiery Weapon
                    7% with 1.5s ICD to handle dual-wield)
  --gen-prc-ret     4 Retribution Paladin procs (Vengeance
                    OnCrit 5-stack / Seal of Justice OnHit
                    25% with 60s per-target ICD / Reckoning
                    OnBlock 10% extra-attack / Sanctity Aura
                    OnCast bookkeeping)
  --gen-prc-rage    3 Warrior Rage-generation procs
                    (Bloodrage OnCast / Berserker Rage
                    OnCast immunity aura / Anger Mgmt OnDodge
                    passive)

Validator catches: id+name+sourceSpellId+procEffectSpellId
required, triggerEvent 0..8, procChancePct in 1..10000
(0 = never fires; > 10000 = > 100% basis points). CRITICAL:
sourceSpellId == procEffectSpellId on OnCast trigger errors
(infinite proc loop — the effect re-casts itself which fires
the proc again). Warns on 100% + 0ms ICD on high-frequency
events (OnHit/OnCrit/OnTakeDamage) — would spam every swing
without rate limiting.

Validator immediately caught a real preset bug during smoke-
test: Berserker Rage initially had sourceSpellId=18499 ==
procEffectSpellId=18499 with OnCast trigger. Validator
correctly errored with "infinite proc loop". Fixed preset to
use procEffectSpellId=23691 (the immunity-aura effect — a
distinct spell from the cast trigger).

Format count 137 -> 138. CLI flag count 1436 -> 1443.
This commit is contained in:
Kelsi 2026-05-10 05:09:13 -07:00
parent c60e779e67
commit 73d66a04d0
10 changed files with 785 additions and 0 deletions

View file

@ -0,0 +1,308 @@
#include "pipeline/wowee_spell_proc_rules.hpp"
#include <cstdio>
#include <cstring>
#include <fstream>
namespace wowee {
namespace pipeline {
namespace {
constexpr char kMagic[4] = {'W', 'P', 'R', 'C'};
constexpr uint32_t kVersion = 1;
template <typename T>
void writePOD(std::ofstream& os, const T& v) {
os.write(reinterpret_cast<const char*>(&v), sizeof(T));
}
template <typename T>
bool readPOD(std::ifstream& is, T& v) {
is.read(reinterpret_cast<char*>(&v), sizeof(T));
return is.gcount() == static_cast<std::streamsize>(sizeof(T));
}
void writeStr(std::ofstream& os, const std::string& s) {
uint32_t n = static_cast<uint32_t>(s.size());
writePOD(os, n);
if (n > 0) os.write(s.data(), n);
}
bool readStr(std::ifstream& is, std::string& s) {
uint32_t n = 0;
if (!readPOD(is, n)) return false;
if (n > (1u << 20)) return false;
s.resize(n);
if (n > 0) {
is.read(s.data(), n);
if (is.gcount() != static_cast<std::streamsize>(n)) {
s.clear();
return false;
}
}
return true;
}
std::string normalizePath(std::string base) {
if (base.size() < 5 || base.substr(base.size() - 5) != ".wprc") {
base += ".wprc";
}
return base;
}
} // namespace
const WoweeSpellProcRules::Entry*
WoweeSpellProcRules::findById(uint32_t procRuleId) const {
for (const auto& e : entries)
if (e.procRuleId == procRuleId) return &e;
return nullptr;
}
std::vector<const WoweeSpellProcRules::Entry*>
WoweeSpellProcRules::findBySourceSpell(uint32_t spellId) const {
std::vector<const Entry*> out;
for (const auto& e : entries)
if (e.sourceSpellId == spellId) out.push_back(&e);
return out;
}
std::vector<const WoweeSpellProcRules::Entry*>
WoweeSpellProcRules::findByEvent(uint8_t triggerEvent) const {
std::vector<const Entry*> out;
for (const auto& e : entries)
if (e.triggerEvent == triggerEvent) out.push_back(&e);
return out;
}
bool WoweeSpellProcRulesLoader::save(
const WoweeSpellProcRules& cat,
const std::string& basePath) {
std::ofstream os(normalizePath(basePath), std::ios::binary);
if (!os) return false;
os.write(kMagic, 4);
writePOD(os, kVersion);
writeStr(os, cat.name);
uint32_t entryCount = static_cast<uint32_t>(cat.entries.size());
writePOD(os, entryCount);
for (const auto& e : cat.entries) {
writePOD(os, e.procRuleId);
writeStr(os, e.name);
writePOD(os, e.sourceSpellId);
writePOD(os, e.procEffectSpellId);
writePOD(os, e.triggerEvent);
writePOD(os, e.maxStacksOnTarget);
writePOD(os, e.procChancePct);
writePOD(os, e.internalCooldownMs);
writePOD(os, e.procFlagsMask);
writePOD(os, e.pad0);
}
return os.good();
}
WoweeSpellProcRules WoweeSpellProcRulesLoader::load(
const std::string& basePath) {
WoweeSpellProcRules out;
std::ifstream is(normalizePath(basePath), std::ios::binary);
if (!is) return out;
char magic[4];
is.read(magic, 4);
if (std::memcmp(magic, kMagic, 4) != 0) return out;
uint32_t version = 0;
if (!readPOD(is, version) || version != kVersion) return out;
if (!readStr(is, out.name)) return out;
uint32_t entryCount = 0;
if (!readPOD(is, entryCount)) return out;
if (entryCount > (1u << 20)) return out;
out.entries.resize(entryCount);
for (auto& e : out.entries) {
if (!readPOD(is, e.procRuleId)) {
out.entries.clear(); return out;
}
if (!readStr(is, e.name)) {
out.entries.clear(); return out;
}
if (!readPOD(is, e.sourceSpellId) ||
!readPOD(is, e.procEffectSpellId) ||
!readPOD(is, e.triggerEvent) ||
!readPOD(is, e.maxStacksOnTarget) ||
!readPOD(is, e.procChancePct) ||
!readPOD(is, e.internalCooldownMs) ||
!readPOD(is, e.procFlagsMask) ||
!readPOD(is, e.pad0)) {
out.entries.clear(); return out;
}
}
return out;
}
bool WoweeSpellProcRulesLoader::exists(
const std::string& basePath) {
std::ifstream is(normalizePath(basePath), std::ios::binary);
return is.good();
}
namespace {
WoweeSpellProcRules::Entry makeRule(
uint32_t procRuleId, const char* name,
uint32_t sourceSpellId, uint32_t procEffectSpellId,
uint8_t triggerEvent,
uint16_t procChancePct,
uint32_t internalCooldownMs,
uint16_t procFlagsMask,
uint8_t maxStacksOnTarget = 0) {
WoweeSpellProcRules::Entry e;
e.procRuleId = procRuleId; e.name = name;
e.sourceSpellId = sourceSpellId;
e.procEffectSpellId = procEffectSpellId;
e.triggerEvent = triggerEvent;
e.procChancePct = procChancePct;
e.internalCooldownMs = internalCooldownMs;
e.procFlagsMask = procFlagsMask;
e.maxStacksOnTarget = maxStacksOnTarget;
return e;
}
} // namespace
WoweeSpellProcRules WoweeSpellProcRulesLoader::makeWeaponProcs(
const std::string& catalogName) {
using P = WoweeSpellProcRules;
WoweeSpellProcRules c;
c.name = catalogName;
// Crusader (Enchant Weapon — Crusader, spellId
// 20007) procs spell 20007 buff OnHit at ~2%
// chance, no ICD, requires melee weapon.
c.entries.push_back(makeRule(
1, "Crusader Weapon Enchant",
20007 /* Enchant: Crusader */,
20007 /* same spell triggers the buff */,
P::OnHit,
200 /* 2% basis points */,
0,
P::RequireMeleeWeapon | P::ExcludeAutoAttack));
// Lifesteal: spellId 20004 (Enchant Weapon —
// Lifestealing) procs heal-on-hit. 5% chance,
// no ICD.
c.entries.push_back(makeRule(
2, "Lifestealing Weapon Enchant",
20004 /* Enchant: Lifesteal */,
20004,
P::OnHit,
500 /* 5% */,
0,
P::RequireMeleeWeapon));
// Fiery Weapon (spellId 13898): 7% chance fire
// damage proc, 1.5s ICD to prevent
// double-procs on dual-wield.
c.entries.push_back(makeRule(
3, "Fiery Weapon Enchant",
13898 /* Enchant: Fiery Weapon */,
13898,
P::OnHit,
700 /* 7% */,
1500 /* 1.5s ICD */,
P::RequireMeleeWeapon));
return c;
}
WoweeSpellProcRules WoweeSpellProcRulesLoader::makeRetPaladin(
const std::string& catalogName) {
using P = WoweeSpellProcRules;
WoweeSpellProcRules c;
c.name = catalogName;
// Vengeance: each crit grants 5-stack Vengeance
// buff (spellId 9452 procs effect 9452 OnCrit).
// 100% chance, no ICD, max 5 stacks.
c.entries.push_back(makeRule(
10, "Vengeance Paladin Crit Stack",
9452 /* Vengeance talent */,
9452 /* stacking buff */,
P::OnCrit,
10000 /* 100% — every crit */,
0,
0,
5 /* max 5 stacks */));
// Seal of Justice: 25% chance to stun on hit
// (spellId 20164 sourceAura procs spell 20170
// stun effect). 60s ICD per target to prevent
// perma-stun.
c.entries.push_back(makeRule(
11, "Seal of Justice Stun",
20164 /* Seal of Justice aura */,
20170 /* Justice stun effect */,
P::OnHit,
2500 /* 25% */,
60000 /* 1min ICD per target */,
P::RequireMeleeWeapon));
// Reckoning: 10% chance on block to gain extra
// attack. Ret talent.
c.entries.push_back(makeRule(
12, "Reckoning Block-to-Attack",
20176 /* Reckoning talent */,
20178 /* extra-attack effect */,
P::OnBlock,
1000 /* 10% */,
0,
0));
// Sanctity Aura: 100% on cast, amplifies holy
// damage by 10%. Aura is permanent so trigger is
// just OnCast bookkeeping.
c.entries.push_back(makeRule(
13, "Sanctity Aura Holy Amp",
20218 /* Sanctity Aura */,
20221 /* Holy-amp passive */,
P::OnCast,
10000 /* 100% — bookkeeping */,
0,
P::RequireSpellSchool));
return c;
}
WoweeSpellProcRules WoweeSpellProcRulesLoader::makeRageGen(
const std::string& catalogName) {
using P = WoweeSpellProcRules;
WoweeSpellProcRules c;
c.name = catalogName;
// Bloodrage: instant 10 Rage on cast, costs
// health. Always procs (100%, no ICD — has
// its own 60s shared cooldown).
c.entries.push_back(makeRule(
20, "Bloodrage Instant Rage",
2687 /* Bloodrage spell */,
14201 /* Rage gain effect */,
P::OnCast,
10000 /* 100% */,
0,
0));
// Berserker Rage: 100% on cast, immunity to
// fear/sap/incapacitate. Uses the OnCast event
// for bookkeeping.
c.entries.push_back(makeRule(
21, "Berserker Rage CC Immune",
18499 /* Berserker Rage spell */,
23691 /* Berserker Rage CC-immunity aura
effect distinct spellId so the
OnCast trigger does NOT recurse
into the source spell */,
P::OnCast,
10000 /* 100% */,
0,
0));
// Anger Management: passive talent. OnDodge
// generates 2 Rage. 100%, no ICD.
c.entries.push_back(makeRule(
22, "Anger Management Dodge Rage",
12296 /* Anger Mgmt talent */,
14201 /* Rage gain effect */,
P::OnDodge,
10000 /* 100% */,
0,
0));
return c;
}
} // namespace pipeline
} // namespace wowee