From 6a1b0440cda98b31a4220118623450d50901904f Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 15:26:02 +0100 Subject: [PATCH 01/12] Add Divine Oracle prestige class (Complete Divine) Pure data-mod implementation: reuses the unused-but-declared stat_level_red_avenger enum slot so no C++/binary changes are needed. Class registered at slot 35 in class_specs.tab and stat_ext.mes. Class features per the PDF: L1 Oracle Domain (+2 caster level on Divination) L1 Scry Bonus (+1 sacred DC on Divination[Scrying]) L2 Prescient Sense (granted via feat_evasion) L3 Divination Enhancement (marker; no engine reroll support) L4 Uncanny Dodge L6 Improved Uncanny Dodge L10 Immune to Surprise (marker; no surprise-round override hook) Spellcasting extends an existing divine class at +1 CL per level (pattern matches Stormlord / Mystic Theurge). Trap Sense is omitted because the engine's existing trap-sense callbacks are no-ops. Adds a CI step to invoke tpdatasrc/PackFiles.bat before PackRelease.ps1 so the build artifact contains the regenerated tpgamefiles.dat (previously the CI shipped artifacts missing all tpdatasrc/ changes). DIVINE_ORACLE_INSTALL.txt documents the two-file install for end users and lists the known limitations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 8 + DIVINE_ORACLE_INSTALL.txt | 88 +++++++++ tpdata/tprules/class_specs.tab | 2 +- .../mes/help/divine_oracle_help.tab | 11 ++ tpdatasrc/tpgamefiles/mes/stat_ext.mes | 9 +- .../char_class/class035_divine_oracle.py | 134 ++++++++++++++ .../divine oracle divination enhancement.txt | 5 + .../divine oracle immune to surprise.txt | 5 + .../feats/divine oracle oracle domain.txt | 5 + .../feats/divine oracle prescient sense.txt | 5 + .../rules/feats/divine oracle scry bonus.txt | 5 + tpdatasrc/tpgamefiles/rules/stat_ext.mes | 2 +- .../scr/tpModifiers/divine_oracle.py | 172 ++++++++++++++++++ 13 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 DIVINE_ORACLE_INSTALL.txt create mode 100644 tpdatasrc/tpgamefiles/mes/help/divine_oracle_help.tab create mode 100644 tpdatasrc/tpgamefiles/rules/char_class/class035_divine_oracle.py create mode 100644 tpdatasrc/tpgamefiles/rules/feats/divine oracle divination enhancement.txt create mode 100644 tpdatasrc/tpgamefiles/rules/feats/divine oracle immune to surprise.txt create mode 100644 tpdatasrc/tpgamefiles/rules/feats/divine oracle oracle domain.txt create mode 100644 tpdatasrc/tpgamefiles/rules/feats/divine oracle prescient sense.txt create mode 100644 tpdatasrc/tpgamefiles/rules/feats/divine oracle scry bonus.txt create mode 100644 tpdatasrc/tpgamefiles/scr/tpModifiers/divine_oracle.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbe1b66ff..398d6dd1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,14 @@ jobs: env: TEMPLEPLUS_VERSION: ${{ steps.get_version.outputs.version }} COMMIT_ID: ${GITHUB_SHA::8} + - name: Pack tpdatasrc into .dat files + # PackFiles.bat regenerates tpgamefiles.dat (and the other .dat files) + # from tpdatasrc/, so the release artifact reflects any data-file mod + # changes. datpack.exe comes from the dependencies tarball above. + run: | + cd tpdatasrc + call PackFiles.bat + shell: cmd - name: Pack Release run: .\PackRelease.ps1 shell: pwsh diff --git a/DIVINE_ORACLE_INSTALL.txt b/DIVINE_ORACLE_INSTALL.txt new file mode 100644 index 000000000..94ae5df4d --- /dev/null +++ b/DIVINE_ORACLE_INSTALL.txt @@ -0,0 +1,88 @@ +Divine Oracle Prestige Class — Install Instructions +===================================================== + +This mod adds the Divine Oracle prestige class (from D&D 3.5e Complete Divine) +to TemplePlus. It is a pure data mod — no recompile of TemplePlus.exe required. + + +Files to copy into your TemplePlus install +------------------------------------------ + +From this download, copy these two files into your existing TemplePlus +installation, overwriting the originals: + + tpdata/tpgamefiles.dat -> /tpdata/tpgamefiles.dat + tpdata/tprules/class_specs.tab -> /tpdata/tprules/class_specs.tab + +That's it. Launch TemplePlus normally. + +(Recommended: back up the originals first by renaming them to .bak.) + + +Picking up the class in-game +---------------------------- + +Divine Oracle is a prestige class. To take it, your character needs to meet +the requirements at level-up: + + * Knowledge (religion): 8 ranks + * Feat: Skill Focus (Knowledge) <- ToEE only has the generic + "Skill Focus (Knowledge)" feat, + not per-knowledge variants + * Spellcasting: able to cast at least 2 divination spells + (clerics and druids qualify automatically once they + have 1st-level divine spells) + +Typical build: cleric 5 -> divine oracle 1+ on level up. + + +Class summary +------------- + +Hit Die: d6 Skill points: 2 + Int +BAB: poor (wizard) Saves: Will favored; Fort/Ref poor + +Spellcasting: each Divine Oracle level adds +1 caster level to your existing +divine class's spellcasting (like Mystic Theurge / Stormlord). + +Level features: + 1 Oracle Domain (+2 caster level on Divination spells) + 1 Scry Bonus (+1 sacred to DC of Divination [Scrying] spells) + 2 Prescient Sense (granted as engine "Evasion") + 3 Divination Enhancement (marker — see caveats) + 4 Uncanny Dodge + 6 Improved Uncanny Dodge + 10 Immune to Surprise (marker — see caveats) + + +Caveats / known limitations +--------------------------- + +1. Trap Sense (the PDF gives +1 at lvl 2, +2 at lvl 5, +3 at lvl 8) is NOT + implemented. The engine's existing Trap Sense callbacks are no-ops. + +2. Divination Enhancement (lvl 3) and Immune to Surprise (lvl 10) are + "marker" features — they show up on the character sheet but have no + mechanical effect. The TemplePlus engine has no hooks for spell-result + re-rolls or surprise-round overrides. + +3. Prescient Sense uses the engine's standard Evasion. The PDF says it + works in any armor; the engine may apply the same armor restriction + it uses for the monk/rogue Evasion ability. + +4. The Oracle Domain feature grants the +2 caster level on Divination + spells, but does NOT grant access to choose Oracle-domain spells + for daily prep — no Oracle domain enum exists in the engine. + +5. Internal stat slot: this mod re-uses an unused-but-declared class enum + slot (originally labeled "Red Avenger") to avoid C++ changes. The + user-facing name is "Divine Oracle" everywhere; you might see + "Red Avenger" only in debug logs or save-file internals. + + +Uninstalling +------------ + +Restore your backups of tpgamefiles.dat and class_specs.tab. Any character +that already took Divine Oracle levels will likely not load cleanly after +uninstall — keep a save from before you took the class. diff --git a/tpdata/tprules/class_specs.tab b/tpdata/tprules/class_specs.tab index 5a0bc82c6..2cf1d6149 100644 --- a/tpdata/tprules/class_specs.tab +++ b/tpdata/tprules/class_specs.tab @@ -29,7 +29,7 @@ Thaumaturgist 32 // other 3rd ed stuff Warlock 33 Favored Soul 34 -PLACEHOLDER 35 +Divine Oracle 35 PLACEHOLDER 36 PLACEHOLDER 37 PLACEHOLDER 38 diff --git a/tpdatasrc/tpgamefiles/mes/help/divine_oracle_help.tab b/tpdatasrc/tpgamefiles/mes/help/divine_oracle_help.tab new file mode 100644 index 000000000..9668f8e16 --- /dev/null +++ b/tpdatasrc/tpgamefiles/mes/help/divine_oracle_help.tab @@ -0,0 +1,11 @@ +TAG_DIVINE_ORACLES TAG_PRESTIGE_CLASSES Divine Oracles All divine oracles are spellcasters, and most were clerics or druids before adopting the divine oracle prestige class. Whatever their other classes, all divine oracles share a particular devotion to the Divination school of magic, having mastered all available means to catch glimpses of the future.Hit Die: d6Requirements:To qualify to become a divine oracle, a character must fulfill all the following criteria. Skill: ~Knowledge (religion)~[TAG_KNOWLEDGE] 8 ranks. Feat: ~Skill Focus~[TAG_SKILL_FOCUS] (Knowledge [religion]). Spells: Able to cast at least 2 divination spells.Base Attack and Base Save Bonuses: see ~table~[TAG_CLASS_FEATURES_DIVINE_ORACLE_TABLES]Class Skills: ~Concentration(Con)~[TAG_CONCENTRATION], Craft(Int), ~Heal(Wis)~[TAG_HEAL], ~Intimidate(Cha)~[TAG_INTIMIDATE], Knowledge (arcana)(Int), Knowledge (religion)(Int), Profession(Wis), ~Spellcraft(Int)~[TAG_SPELLCRAFT]Skill Points at Each Level: 2 + Int modifierClass Features:[CMD_CHILDREN] +TAG_CLASS_FEATURES_DIVINE_ORACLE_TABLES TAG_DIVINE_ORACLES Divine Oracle Base Attack & Base Save Bonuses Level ~BAB~[TAG_LEVEL_BONUSES] ~Fort.~[TAG_FORTITUDE] ~Reflex~[TAG_REFLEX] ~Will~[TAG_WILL] 1 +0 +0 +0 +2 2 +1 +0 +0 +3 3 +1 +1 +1 +3 4 +2 +1 +1 +4 5 +2 +1 +1 +4 6 +3 +2 +2 +5 7 +3 +2 +2 +5 8 +4 +2 +2 +6 9 +4 +3 +3 +6 10 +5 +3 +3 +7 +TAG_CLASS_FEATURES_DIVINE_ORACLE_SPELLS_PER_DAY TAG_DIVINE_ORACLES Divine Oracle Spells per Day/Spells Known A divine oracle continues advancing in spellcasting ability as well as gaining the abilities of her new class. Thus, when a new divine oracle level is gained, the character gains new spells per day (and spells known, if applicable) as if she had also gained a level in whatever spellcasting class she belonged to before she added the prestige class level. She does not, however, gain any other benefit a character of that class would have gained. +TAG_CLASS_FEATURES_DIVINE_ORACLE_GRANTED_PROFICIENCIES TAG_DIVINE_ORACLES Divine Oracle Weapon and Armor Proficiency Divine oracles gain no weapon or armor proficiencies. +TAG_CLASS_FEATURES_DIVINE_ORACLE_ORACLE_DOMAIN TAG_DIVINE_ORACLES Divine Oracle Oracle Domain Upon adopting the divine oracle class, the character gains access to the Oracle domain. The character gains the granted power associated with the domain (cast divination spells at +2 caster level), and can choose the spells in that domain as her daily domain spells. +TAG_CLASS_FEATURES_DIVINE_ORACLE_SCRY_BONUS TAG_DIVINE_ORACLES Divine Oracle Scry Bonus A divine oracle adds a +1 sacred bonus to the save DC of all her divination (scrying) spells. +TAG_CLASS_FEATURES_DIVINE_ORACLE_PRESCIENT_SENSE TAG_DIVINE_ORACLES Divine Oracle Prescient Sense Beginning at 2nd level, if a divine oracle makes a successful Reflex saving throw against an attack that normally inflicts half damage on a successful save, she instead takes no damage. This form of evasion works no matter what armor the divine oracle wears. +TAG_CLASS_FEATURES_DIVINE_ORACLE_DIVINATION_ENHANCEMENT TAG_DIVINE_ORACLES Divine Oracle Divination Enhancement Beginning at 3rd level, a divine oracle may roll twice and take the better result when using divination spells such as augury or divination. +TAG_CLASS_FEATURES_DIVINE_ORACLE_UNCANNY_DODGE TAG_DIVINE_ORACLES Divine Oracle Uncanny Dodge Starting at 4th level, a divine oracle gains the ability to react to danger before her senses would normally allow her to do so. +TAG_CLASS_FEATURES_DIVINE_ORACLE_IMPROVED_UNCANNY_DODGE TAG_DIVINE_ORACLES Divine Oracle Improved Uncanny Dodge At 6th level and higher, the divine oracle can no longer be flanked. This defense denies a rogue the ability to use flank attacks to sneak attack the divine oracle. +TAG_CLASS_FEATURES_DIVINE_ORACLE_IMMUNE_TO_SURPRISE TAG_DIVINE_ORACLES Divine Oracle Immune to Surprise At 10th level, the divine oracle's sensitivity to danger is so great that she is never surprised. She can always take a standard action during a surprise round, unless she is physically restrained from doing so. diff --git a/tpdatasrc/tpgamefiles/mes/stat_ext.mes b/tpdatasrc/tpgamefiles/mes/stat_ext.mes index 3794500c6..cef9f640b 100644 --- a/tpdatasrc/tpgamefiles/mes/stat_ext.mes +++ b/tpdatasrc/tpgamefiles/mes/stat_ext.mes @@ -19,7 +19,7 @@ // Other supplemental stuff for 3.5 ed {33}{Warlock} {34}{Favored Soul} -{35}{Red Avenger} +{35}{Divine Oracle} {36}{Iaijutsu Master} {37}{Sacred Fist} {38}{Stormlord} @@ -90,7 +90,7 @@ {1033}{Wrlk} {1034}{Fvrd} -{1035}{} +{1035}{DvOr} {1036}{} {1037}{} {1038}{} @@ -301,6 +301,11 @@ Spells: Able to cast lesser planar ally.} {13027}Favored Soul{Favored souls cast divine spells by means of an innate connection rather than through laborious training and prayer, so their divine connection is natural rather than learned.} +{13028}Divine Oracle{All divine oracles are spellcasters sharing a particular devotion to the Divination school of magic, having mastered all available means to catch glimpses of the future. + +Requirements: Knowledge (religion) 8 ranks, Skill Focus (Knowledge), able to cast at least 2 divination spells. +} + {13031}Stormlord{Stormlords wield thunder and lightning like a warrior wields his sword. They serve their deity through random, spectacular acts of violence. Requirements: CE/CN/NE, Fort Save +4, can cast 3rd-level divine spells. diff --git a/tpdatasrc/tpgamefiles/rules/char_class/class035_divine_oracle.py b/tpdatasrc/tpgamefiles/rules/char_class/class035_divine_oracle.py new file mode 100644 index 000000000..6d7b80b7e --- /dev/null +++ b/tpdatasrc/tpgamefiles/rules/char_class/class035_divine_oracle.py @@ -0,0 +1,134 @@ +from toee import * +import tpdp +import char_class_utils +import char_editor +################################################### + +def GetConditionName(): + return "Divine Oracle" + +def GetSpellCasterConditionName(): + return "Divine Oracle Spellcasting" + +def GetCategory(): + return "Complete Divine Prestige Classes" + +def GetClassDefinitionFlags(): + return CDF_None + +def GetClassHelpTopic(): + return "TAG_DIVINE_ORACLES" + +classEnum = stat_level_red_avenger # reusing this unused-but-declared enum slot to avoid C++ changes + +################################################### + + +class_feats = { +1: ("Divine Oracle Oracle Domain", "Divine Oracle Scry Bonus",), +2: ("Divine Oracle Prescient Sense", feat_evasion,), +3: ("Divine Oracle Divination Enhancement",), +4: (feat_uncanny_dodge,), +6: (feat_improved_uncanny_dodge,), +10: ("Divine Oracle Immune to Surprise",), +} + +class_skills = (skill_concentration, skill_craft, skill_heal, skill_intimidate, skill_knowledge_arcana, skill_knowledge_religion, skill_profession, skill_spellcraft) + + +def IsEnabled(): + return 1 + +def GetHitDieType(): + return 6 + +def GetSkillPtsPerLevel(): + return 2 + +def GetBabProgression(): + return base_attack_bonus_type_non_martial + +def IsFortSaveFavored(): + return 0 + +def IsRefSaveFavored(): + return 0 + +def IsWillSaveFavored(): + return 1 + +# Spell casting +def GetSpellListType(): + return spell_list_type_extender + +def GetSpellSourceType(): + return spell_source_type_divine + +def IsClassSkill(skillEnum): + return char_class_utils.IsClassSkill(class_skills, skillEnum) + +def IsClassFeat(featEnum): + return char_class_utils.IsClassFeat(class_feats, featEnum) + +def GetClassFeats(): + return class_feats + +def IsAlignmentCompatible(alignment): + return 1 + +def ObjMeetsPrereqs(obj): + # Knowledge (religion) 8 ranks + if obj.skill_ranks_get(skill_knowledge_religion) < 8: + return 0 + # Skill Focus (Knowledge) - ToEE only has the generic Skill Focus (Knowledge) feat + if not obj.has_feat(feat_skill_focus_knowledge): + return 0 + # Able to cast at least 2 divination spells (divine or arcane) + if obj.divine_spell_level_can_cast() < 1 and obj.arcane_spell_level_can_cast() < 1: + return 0 + divination_spells_known = 0 + for knSp in obj.spells_known: + if knSp.spell_level > 0: + spell_entry = tpdp.SpellEntry(knSp.spell_enum) + if spell_entry.spell_school_enum == Divination: + divination_spells_known += 1 + if divination_spells_known >= 2: + break + # Vancian divine casters (cleric/druid) prepare from full list, so granting + # them the qualification once they have access to 1st level divine spells. + if divination_spells_known < 2 and obj.divine_spell_level_can_cast() < 1: + return 0 + return 1 + + +# Levelup + +def IsSelectingSpellsOnLevelup(obj, class_extended_1=0): + if class_extended_1 <= 0: + class_extended_1 = char_class_utils.GetHighestDivineClass(obj) + if char_editor.is_selecting_spells(obj, class_extended_1): + return 1 + return 0 + + +def LevelupCheckSpells(obj, class_extended_1=0): + if class_extended_1 <= 0: + class_extended_1 = char_class_utils.GetHighestDivineClass(obj) + if not char_editor.spells_check_complete(obj, class_extended_1): + return 0 + return 1 + + +def InitSpellSelection(obj, class_extended_1=0): + newLvl = obj.stat_level_get(classEnum) + 1 + if newLvl == 1 or class_extended_1 <= 0: + class_extended_1 = char_class_utils.GetHighestDivineClass(obj) + char_editor.init_spell_selection(obj, class_extended_1) + return 0 + + +def LevelupSpellsFinalize(obj, class_extended_1=0): + if class_extended_1 <= 0: + class_extended_1 = char_class_utils.GetHighestDivineClass(obj) + char_editor.spells_finalize(obj, class_extended_1) + return 0 diff --git a/tpdatasrc/tpgamefiles/rules/feats/divine oracle divination enhancement.txt b/tpdatasrc/tpgamefiles/rules/feats/divine oracle divination enhancement.txt new file mode 100644 index 000000000..f995879cd --- /dev/null +++ b/tpdatasrc/tpgamefiles/rules/feats/divine oracle divination enhancement.txt @@ -0,0 +1,5 @@ +name: Divine Oracle Divination Enhancement +flags: 8 +prereqs: +description: Beginning at 3rd level, a divine oracle may roll twice and take the better result when using divination spells such as augury or divination. +prereq descr: Divine Oracle level 3. diff --git a/tpdatasrc/tpgamefiles/rules/feats/divine oracle immune to surprise.txt b/tpdatasrc/tpgamefiles/rules/feats/divine oracle immune to surprise.txt new file mode 100644 index 000000000..756983dbb --- /dev/null +++ b/tpdatasrc/tpgamefiles/rules/feats/divine oracle immune to surprise.txt @@ -0,0 +1,5 @@ +name: Divine Oracle Immune to Surprise +flags: 8 +prereqs: +description: At 10th level, the divine oracle's sensitivity to danger is so great that she is never surprised. She can always take a standard action during a surprise round, unless she is physically restrained from doing so. +prereq descr: Divine Oracle level 10. diff --git a/tpdatasrc/tpgamefiles/rules/feats/divine oracle oracle domain.txt b/tpdatasrc/tpgamefiles/rules/feats/divine oracle oracle domain.txt new file mode 100644 index 000000000..3a4ef3321 --- /dev/null +++ b/tpdatasrc/tpgamefiles/rules/feats/divine oracle oracle domain.txt @@ -0,0 +1,5 @@ +name: Divine Oracle Oracle Domain +flags: 8 +prereqs: +description: Upon adopting the divine oracle class, the character gains access to the Oracle domain. The character gains the granted power associated with the domain (cast divination spells at +2 caster level), and can choose the spells in that domain as her daily domain spells. +prereq descr: Divine Oracle level 1. diff --git a/tpdatasrc/tpgamefiles/rules/feats/divine oracle prescient sense.txt b/tpdatasrc/tpgamefiles/rules/feats/divine oracle prescient sense.txt new file mode 100644 index 000000000..bfa57bc11 --- /dev/null +++ b/tpdatasrc/tpgamefiles/rules/feats/divine oracle prescient sense.txt @@ -0,0 +1,5 @@ +name: Divine Oracle Prescient Sense +flags: 8 +prereqs: +description: Beginning at 2nd level, if a divine oracle makes a successful Reflex saving throw against an attack that normally inflicts half damage on a successful save (such as a red dragon's fiery breath or a fireball spell), she instead takes no damage. This form of evasion works no matter what armor the divine oracle wears. +prereq descr: Divine Oracle level 2. diff --git a/tpdatasrc/tpgamefiles/rules/feats/divine oracle scry bonus.txt b/tpdatasrc/tpgamefiles/rules/feats/divine oracle scry bonus.txt new file mode 100644 index 000000000..c3305aeee --- /dev/null +++ b/tpdatasrc/tpgamefiles/rules/feats/divine oracle scry bonus.txt @@ -0,0 +1,5 @@ +name: Divine Oracle Scry Bonus +flags: 8 +prereqs: +description: A divine oracle adds a +1 sacred bonus to the save DC of all her divination (scrying) spells. +prereq descr: Divine Oracle level 1. diff --git a/tpdatasrc/tpgamefiles/rules/stat_ext.mes b/tpdatasrc/tpgamefiles/rules/stat_ext.mes index c121dd1fd..3b87cf788 100644 --- a/tpdatasrc/tpgamefiles/rules/stat_ext.mes +++ b/tpdatasrc/tpgamefiles/rules/stat_ext.mes @@ -18,7 +18,7 @@ {33}{Warlock} {34}{Favored Soul} -{35}{Red Avenger} +{35}{Divine Oracle} {36}{Iaijutsu Master} {37}{Sacred Fist} {38}{Stormlord} diff --git a/tpdatasrc/tpgamefiles/scr/tpModifiers/divine_oracle.py b/tpdatasrc/tpgamefiles/scr/tpModifiers/divine_oracle.py new file mode 100644 index 000000000..15c03c106 --- /dev/null +++ b/tpdatasrc/tpgamefiles/scr/tpModifiers/divine_oracle.py @@ -0,0 +1,172 @@ +from templeplus.pymod import PythonModifier +from toee import * +import tpdp +import char_class_utils + +################################################### + +def GetConditionName(): + return "Divine Oracle" + +def GetSpellCasterConditionName(): + return "Divine Oracle Spellcasting" + +print "Registering " + GetConditionName() + +classEnum = stat_level_red_avenger # reusing this unused-but-declared enum slot to avoid C++ changes +classSpecModule = __import__('class035_divine_oracle') + +################################################### + + +#### standard callbacks - BAB and Save values +def OnGetToHitBonusBase(attachee, args, evt_obj): + classLvl = attachee.stat_level_get(classEnum) + babvalue = game.get_bab_for_class(classEnum, classLvl) + evt_obj.bonus_list.add(babvalue, 0, 137) # untyped, description: "Class" + return 0 + +def OnGetSaveThrowFort(attachee, args, evt_obj): + value = char_class_utils.SavingThrowLevel(classEnum, attachee, D20_Save_Fortitude) + evt_obj.bonus_list.add(value, 0, 137) + return 0 + +def OnGetSaveThrowReflex(attachee, args, evt_obj): + value = char_class_utils.SavingThrowLevel(classEnum, attachee, D20_Save_Reflex) + evt_obj.bonus_list.add(value, 0, 137) + return 0 + +def OnGetSaveThrowWill(attachee, args, evt_obj): + value = char_class_utils.SavingThrowLevel(classEnum, attachee, D20_Save_Will) + evt_obj.bonus_list.add(value, 0, 137) + return 0 + +classSpecObj = PythonModifier(GetConditionName(), 0) +classSpecObj.AddHook(ET_OnToHitBonusBase, EK_NONE, OnGetToHitBonusBase, ()) +classSpecObj.AddHook(ET_OnSaveThrowLevel, EK_SAVE_FORTITUDE, OnGetSaveThrowFort, ()) +classSpecObj.AddHook(ET_OnSaveThrowLevel, EK_SAVE_REFLEX, OnGetSaveThrowReflex, ()) +classSpecObj.AddHook(ET_OnSaveThrowLevel, EK_SAVE_WILL, OnGetSaveThrowWill, ()) + + +##### Spell casting +# Divine Oracle raises the caster level for the divine base class specified in Modifier arg 0 + +def OnAddSpellCasting(attachee, args, evt_obj): + if args.get_arg(0) == 0: + args.set_arg(0, char_class_utils.GetHighestDivineClass(attachee)) + return 0 + +def OnGetBaseCasterLevel(attachee, args, evt_obj): + class_extended_1 = args.get_arg(0) + class_code = evt_obj.arg0 + if class_code != class_extended_1: + if evt_obj.arg1 == 0: + return 0 + classLvl = attachee.stat_level_get(classEnum) + if classLvl == 0: + return 0 + evt_obj.bonus_list.add(classLvl, 0, 137) + return 0 + +def OnSpellListExtensionGet(attachee, args, evt_obj): + class_extended_1 = args.get_arg(0) + class_code = evt_obj.arg0 + if class_code != class_extended_1: + if evt_obj.arg1 == 0: + return 0 + classLvl = attachee.stat_level_get(classEnum) + if classLvl == 0: + return 0 + evt_obj.bonus_list.add(classLvl, 0, 137) + return 0 + +def OnInitLevelupSpellSelection(attachee, args, evt_obj): + if evt_obj.arg0 != classEnum: + return 0 + class_extended_1 = args.get_arg(0) + classSpecModule.InitSpellSelection(attachee, class_extended_1) + return 0 + +def OnLevelupSpellsCheckComplete(attachee, args, evt_obj): + if evt_obj.arg0 != classEnum: + return 0 + class_extended_1 = args.get_arg(0) + if not classSpecModule.LevelupCheckSpells(attachee, class_extended_1): + evt_obj.bonus_list.add(-1, 0, 137) + return 1 + +def OnLevelupSpellsFinalize(attachee, args, evt_obj): + if evt_obj.arg0 != classEnum: + return 0 + class_extended_1 = args.get_arg(0) + classSpecModule.LevelupSpellsFinalize(attachee, class_extended_1) + return + +spellCasterSpecObj = PythonModifier(GetSpellCasterConditionName(), 8) +spellCasterSpecObj.AddHook(ET_OnConditionAdd, EK_NONE, OnAddSpellCasting, ()) +spellCasterSpecObj.AddHook(ET_OnGetBaseCasterLevel, EK_NONE, OnGetBaseCasterLevel, ()) +spellCasterSpecObj.AddHook(ET_OnSpellListExtensionGet, EK_NONE, OnSpellListExtensionGet, ()) +spellCasterSpecObj.AddHook(ET_OnLevelupSystemEvent, EK_LVL_Spells_Activate, OnInitLevelupSpellSelection, ()) +spellCasterSpecObj.AddHook(ET_OnLevelupSystemEvent, EK_LVL_Spells_Check_Complete, OnLevelupSpellsCheckComplete, ()) +spellCasterSpecObj.AddHook(ET_OnLevelupSystemEvent, EK_LVL_Spells_Finalize, OnLevelupSpellsFinalize, ()) + + +#### Divine Oracle Class Features #### + +## Scry Bonus (Su) ## +# +1 sacred bonus to the save DC of all Divination (Scrying) spells. +def ScryBonusOnGetSpellDcMod(attachee, args, evt_obj): + spell_entry = evt_obj.spell_entry + if spell_entry.spell_school_enum != Divination: + return 0 + if spell_entry.spell_subschool_enum != Scrying: + return 0 + evt_obj.bonus_list.add(1, 17, "Divine Oracle Scry Bonus") # Sacred bonus + return 0 + +scryBonus = PythonModifier("Divine Oracle Scry Bonus", 0) +scryBonus.MapToFeat("Divine Oracle Scry Bonus") +scryBonus.AddHook(ET_OnGetSpellDcMod, EK_NONE, ScryBonusOnGetSpellDcMod, ()) + + +## Oracle Domain ## +# Grants the Oracle domain granted power: cast Divination spells at +2 caster level. +def OracleDomainCasterLevelMod(attachee, args, evt_obj): + spellPkt = evt_obj.get_spell_packet() + if spellPkt is None: + return 0 + spell_enum = spellPkt.spell_enum + if spell_enum <= 0: + return 0 + spell_entry = tpdp.SpellEntry(spell_enum) + if spell_entry.spell_school_enum != Divination: + return 0 + evt_obj.return_val += 2 + return 0 + +oracleDomain = PythonModifier("Divine Oracle Oracle Domain", 0) +oracleDomain.MapToFeat("Divine Oracle Oracle Domain") +oracleDomain.AddHook(ET_OnGetCasterLevelMod, EK_NONE, OracleDomainCasterLevelMod, ()) + + +## Prescient Sense (Ex) ## +# Marker feat. Mechanical effect is granted via feat_evasion in the class spec +# class_feats dict at level 2. Listed here so the feature appears on the sheet. +prescientSense = PythonModifier("Divine Oracle Prescient Sense", 0) +prescientSense.MapToFeat("Divine Oracle Prescient Sense") + + +## Divination Enhancement (Ex) ## +# Roll twice and take the better result when casting divination spells such as +# augury or divination. ToEE has no engine support for re-rolling such spell +# outcomes, so the feature is registered as a marker. +divinationEnhancement = PythonModifier("Divine Oracle Divination Enhancement", 0) +divinationEnhancement.MapToFeat("Divine Oracle Divination Enhancement") + + +## Immune to Surprise (Ex) ## +# At 10th level, the divine oracle can always take a standard action during a +# surprise round. Registered as a marker; full effect would require turn/initiative +# system hooks. +immuneSurprise = PythonModifier("Divine Oracle Immune to Surprise", 0) +immuneSurprise.MapToFeat("Divine Oracle Immune to Surprise") From ecbaa2ae7f3887a6e634d7494a4ebe1a1eb9248b Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 16:09:01 +0100 Subject: [PATCH 02/12] ci: discover Visual Studio path via vswhere The hardcoded `C:\Program Files\Microsoft Visual Studio\2022\Enterprise\` path no longer exists on the current windows-latest runner image (`vcvars32.bat` not found, build fails before msbuild runs). Use vswhere -- which ships with every VS install on Microsoft-hosted runners -- to locate the installation dynamically. Same change applied to the Pack Symbols step that needed the DIA SDK path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 398d6dd1a..c6f6f47ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,10 +50,18 @@ jobs: nuget install Squirrel.Windows -OutputDirectory Squirrel -Verbosity Detailed nuget restore - name: Build + # Discover the VS install path via vswhere instead of hardcoding the + # Enterprise edition path; the windows-latest runner image's VS edition + # and path can change without warning. run: | - call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars32.bat" + for /f "usebackq tokens=*" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath`) do set VSPATH=%%i + if not defined VSPATH ( + echo Failed to locate Visual Studio installation via vswhere + exit /b 1 + ) + echo Using VS install: %VSPATH% + call "%VSPATH%\VC\Auxiliary\Build\vcvars32.bat" msbuild TemplePlus.sln /p:Configuration=Release /p:Platform=Win32 /p:MSBuildDefines="_TP_VERSION=%TEMPLEPLUS_VERSION%;_TP_COMMIT=%COMMIT_ID%;TP_RELEASE_BUILD" /verbosity:minimal - # Need to use cmd because of the vcvars32 batch file shell: cmd env: TEMPLEPLUS_VERSION: ${{ steps.get_version.outputs.version }} @@ -75,7 +83,8 @@ jobs: shell: cmd # Package the symbol files, dump_syms needs the dev tools to be on the PATH run: | - set PATH=%PATH%;C:\Program Files\Microsoft Visual Studio\2022\Enterprise\DIA SDK\bin + for /f "usebackq tokens=*" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath`) do set VSPATH=%%i + set PATH=%PATH%;%VSPATH%\DIA SDK\bin dependencies\bin\dump_syms.exe Release\TemplePlus.pdb > TemplePlus.sym 7z a TemplePlusSymbols-${{ steps.get_version.outputs.version }}.zip TemplePlus.sym Release\TemplePlus.pdb - name: Pack Tools From e6f14ad3a09c66c971fe8f43a99d6a75e3ffcea8 Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 16:24:00 +0100 Subject: [PATCH 03/12] ci: pin to windows-2022 runner windows-latest has migrated to a VS 2026-based image that ships without ATL (causes `atlcomcli.h: No such file or directory` across every file that includes platform/d3d.h) and without the .NET Framework 4.5.2 reference assemblies that ParticleEditorTests, ParticleModel, and Configurator target. windows-2022 still has both -- this matches what upstream's last successful build (Feb 2026) ran on. Applied to both windows runners in the workflow (build and package-release jobs). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6f6f47ca..d285fd32d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,10 @@ on: jobs: build: name: Build TemplePlus - runs-on: windows-latest + # Pin to windows-2022: windows-latest has migrated to a VS 2026 image + # that ships without ATL (atlcomcli.h missing) and without the .NET 4.5.2 + # reference assemblies the C# projects target. + runs-on: windows-2022 outputs: version: ${{ steps.get_version.outputs.version }} release: ${{ steps.get_version.outputs.release }} @@ -147,7 +150,7 @@ jobs: package-release: name: Package Release - runs-on: windows-latest + runs-on: windows-2022 needs: build steps: - uses: actions/checkout@v4 From 7966076bb05bcb2db610b775727abfb5ee0f9b6f Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 17:21:32 +0100 Subject: [PATCH 04/12] divine oracle: proper enum slot, oracle domain, immune to surprise Engine-side improvements that lift several caveats from the original data-only version of the mod: * stat_level_divine_oracle = 50 in temple_enums.h + d20stats.cpp + constants.py. Class file moves back to class050_divine_oracle.py. The data-only workaround that reused stat_level_red_avenger is reverted (slot 35 is the unused Red Avenger label again). * Domain_Oracle = 24, Domain_Count = 25. Spell-by-domain-level registration for the 9 Oracle domain spells (Identify, Augury, Divination, Scrying, Commune, Legend Lore, Greater Scrying, Discern Location, Foresight) is injected post-SpellEntriesInit via the new LegacySpellSystem::AddOracleDomainSpells(). Domain name "Oracle" registered at spell_ext.mes line 4024 (per GetDomainName() convention of 4000 + domain enum). The +2 caster level granted power continues to be applied via the existing Python feat hook on the Divine Oracle class feature. A cleric who picks Oracle as a regular domain gets the spell list but not the granted power -- documented in the install notes. * Immune to Surprise (L10) implemented as an in-place check in TurnBasedSys::AddToInitiative: a Divine Oracle 10+ character skips the engine "Surprised" condition entirely when entering combat during a surprise round. Slightly more generous than RAW (full normal turn vs strictly a standard action) but consistent with how ToEE translates capstones. Skipped (documented in DIVINE_ORACLE_INSTALL.txt): - Trap Sense -- engine doesn't distinguish trap-vs-non-trap reflex saves; clean implementation would require save-system refactor. - Divination Enhancement -- augury/divination spells are stubs in the engine; nothing to re-roll. - Prescient Sense armor-bypass -- Evasion lives in vanilla ToEE.exe code that isn't currently hooked. Also adds .claude/ to .gitignore. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + DIVINE_ORACLE_INSTALL.txt | 94 +- TemplePlus/gamesystems/d20/d20stats.cpp | 1 + TemplePlus/spell.cpp | 885 +++++++++--------- TemplePlus/spell.h | 5 + TemplePlus/temple_enums.h | 4 +- TemplePlus/turn_based.cpp | 10 +- tpdata/templeplus/lib/templeplus/constants.py | 1 + tpdata/tprules/class_specs.tab | 4 +- tpdatasrc/tpgamefiles/mes/spell_ext.mes | 3 + tpdatasrc/tpgamefiles/mes/stat_ext.mes | 18 +- ...ne_oracle.py => class050_divine_oracle.py} | 2 +- tpdatasrc/tpgamefiles/rules/stat_ext.mes | 3 +- .../scr/tpModifiers/divine_oracle.py | 10 +- 14 files changed, 569 insertions(+), 472 deletions(-) rename tpdatasrc/tpgamefiles/rules/char_class/{class035_divine_oracle.py => class050_divine_oracle.py} (97%) diff --git a/.gitignore b/.gitignore index 727f81bc4..e2ed93109 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ TemplePlus.ini +.claude/ # C++ objects and libs diff --git a/DIVINE_ORACLE_INSTALL.txt b/DIVINE_ORACLE_INSTALL.txt index 94ae5df4d..383547855 100644 --- a/DIVINE_ORACLE_INSTALL.txt +++ b/DIVINE_ORACLE_INSTALL.txt @@ -2,21 +2,28 @@ Divine Oracle Prestige Class — Install Instructions ===================================================== This mod adds the Divine Oracle prestige class (from D&D 3.5e Complete Divine) -to TemplePlus. It is a pure data mod — no recompile of TemplePlus.exe required. +to TemplePlus. This version includes C++ engine changes for the Oracle domain +and Immune to Surprise, so it ships as a custom TemplePlus build — your +friend will replace TemplePlus.exe along with the data files. Files to copy into your TemplePlus install ------------------------------------------ -From this download, copy these two files into your existing TemplePlus -installation, overwriting the originals: +This ZIP is a full TemplePlus distribution built from the Divine Oracle fork. +Easiest install: back up your existing TemplePlus folder, then extract this +ZIP over the top so it overwrites: - tpdata/tpgamefiles.dat -> /tpdata/tpgamefiles.dat - tpdata/tprules/class_specs.tab -> /tpdata/tprules/class_specs.tab + TemplePlus.exe + tpdata/tpgamefiles.dat + tpdata/tprules/class_specs.tab + (plus the usual TemplePlus DLLs and supporting files) -That's it. Launch TemplePlus normally. +Launch TemplePlus normally. Your existing saves should continue to work -- +the new class enum slot is appended at the end and doesn't shift any +existing class IDs. -(Recommended: back up the originals first by renaming them to .bak.) +(Strongly recommended: back up your old TemplePlus folder before overwriting.) Picking up the class in-game @@ -46,43 +53,68 @@ Spellcasting: each Divine Oracle level adds +1 caster level to your existing divine class's spellcasting (like Mystic Theurge / Stormlord). Level features: - 1 Oracle Domain (+2 caster level on Divination spells) + 1 Oracle Domain (+2 caster level on all Divination spells) 1 Scry Bonus (+1 sacred to DC of Divination [Scrying] spells) 2 Prescient Sense (granted as engine "Evasion") - 3 Divination Enhancement (marker — see caveats) + 3 Divination Enhancement (marker -- see limitations) 4 Uncanny Dodge 6 Improved Uncanny Dodge - 10 Immune to Surprise (marker — see caveats) + 10 Immune to Surprise (Divine Oracle 10+ characters skip the Surprised + condition entirely on entering combat during a surprise round) -Caveats / known limitations ---------------------------- +Oracle domain +------------- + +The Oracle domain is now a real domain enum in the engine. Its spell list, +matching the Complete Divine source: + + 1st: Identify 6th: Legend Lore + 2nd: Augury 7th: Greater Scrying + 3rd: Divination 8th: Discern Location + 4th: Scrying 9th: Foresight + 5th: Commune + +These spell-to-domain registrations are added at engine startup. Some of +these spells (Augury, Commune, etc.) are stubs in the vanilla ToEE engine +-- they exist as spell enums but don't do much mechanically. The Oracle +domain registers access to them per the rules; what each spell actually +does is a separate concern. + + +Known limitations +----------------- -1. Trap Sense (the PDF gives +1 at lvl 2, +2 at lvl 5, +3 at lvl 8) is NOT - implemented. The engine's existing Trap Sense callbacks are no-ops. +1. Trap Sense (PDF L2/+1, L5/+2, L8/+3) is NOT implemented. The engine + doesn't distinguish trap-vs-non-trap reflex saves, so adding it would + either over-apply (universal reflex bonus) or require non-trivial + refactor of the save system. Skipped to avoid silent overage. -2. Divination Enhancement (lvl 3) and Immune to Surprise (lvl 10) are - "marker" features — they show up on the character sheet but have no - mechanical effect. The TemplePlus engine has no hooks for spell-result - re-rolls or surprise-round overrides. +2. Divination Enhancement (L3 -- roll twice, take better on divinations + like augury/divination) is a marker feat only. The relevant spells + don't have functioning result-mechanics to re-roll in the first place. -3. Prescient Sense uses the engine's standard Evasion. The PDF says it - works in any armor; the engine may apply the same armor restriction - it uses for the monk/rogue Evasion ability. +3. Prescient Sense (L2) uses the engine's standard Evasion. The PDF says + it works in any armor; the engine inherits whatever armor restriction + it applies to monk/rogue Evasion. Fixing this would require finding + and patching the vanilla evasion implementation, which lives in + ToEE.exe and isn't currently hooked by TemplePlus. -4. The Oracle Domain feature grants the +2 caster level on Divination - spells, but does NOT grant access to choose Oracle-domain spells - for daily prep — no Oracle domain enum exists in the engine. +4. A plain cleric who picks Oracle as a regular domain at level 1 will + get the Oracle spell list in their domain slots but NOT the +2 caster + level granted power -- that bonus is wired to the Divine Oracle class + feature, not to the domain itself. If your friend wants the granted + power, take the Divine Oracle prestige class. -5. Internal stat slot: this mod re-uses an unused-but-declared class enum - slot (originally labeled "Red Avenger") to avoid C++ changes. The - user-facing name is "Divine Oracle" everywhere; you might see - "Red Avenger" only in debug logs or save-file internals. +5. Immune to Surprise gives the Divine Oracle 10+ character full normal + actions during a surprise round (instead of strictly "a standard + action" per the PDF). This is more generous than RAW but consistent + with how other capstones translate to ToEE's combat model. Uninstalling ------------ -Restore your backups of tpgamefiles.dat and class_specs.tab. Any character -that already took Divine Oracle levels will likely not load cleanly after -uninstall — keep a save from before you took the class. +Restore your backed-up TemplePlus folder. Characters that already took +Divine Oracle levels won't load cleanly with stock TemplePlus -- keep a +save from before you took the class. diff --git a/TemplePlus/gamesystems/d20/d20stats.cpp b/TemplePlus/gamesystems/d20/d20stats.cpp index 05aecb403..913aa5bba 100644 --- a/TemplePlus/gamesystems/d20/d20stats.cpp +++ b/TemplePlus/gamesystems/d20/d20stats.cpp @@ -548,6 +548,7 @@ StatType D20StatsSystem::GetType(Stat stat) { case stat_level_warmage: case stat_level_beguilers: case stat_level_swashbuckler: + case stat_level_divine_oracle: case stat_level_psion: case stat_level_psychic_warrior: diff --git a/TemplePlus/spell.cpp b/TemplePlus/spell.cpp index b3f17aa55..04fce9d9b 100644 --- a/TemplePlus/spell.cpp +++ b/TemplePlus/spell.cpp @@ -267,7 +267,11 @@ class SpellFuncReplacements : public TempleFix { // SpellEntriesInit replaceFunction(0x1007B5B0, [](const char* spellRulesFolder)->BOOL { - return spellSys.SpellEntriesInit(spellRulesFolder); + BOOL result = spellSys.SpellEntriesInit(spellRulesFolder); + if (result) { + spellSys.AddOracleDomainSpells(); + } + return result; }); @@ -463,40 +467,40 @@ SpellPacketBody::SpellPacketBody(objHndl spellCaster, D20SpellData& spellData) D20SpellDataExtractInfo(&spellData, &spellEnum, &spellEnumOrg, &spellClassCode, &spellSlotLevel, &itemSpellData, &spellMetaMagicData); //From ::TargetCheck - spellEnum = spellEnum; - spellEnumOriginal = spellEnumOrg; - caster = spellCaster; - spellClass = spellClassCode; - spellKnownSlotLevel = spellSlotLevel; - metaMagicData = spellMetaMagicData; + spellEnum = spellEnum; + spellEnumOriginal = spellEnumOrg; + caster = spellCaster; + spellClass = spellClassCode; + spellKnownSlotLevel = spellSlotLevel; + metaMagicData = spellMetaMagicData; invIdx = itemSpellData; SpellEntry spellEntry; - if (!spellSys.spellRegistryCopy(spellEnum, &spellEntry)) + if (!spellSys.spellRegistryCopy(spellEnum, &spellEntry)) { - // set caster level - if (itemSpellData == INV_IDX_INVALID) { - spellSys.SpellPacketSetCasterLevel(this); - } - else { // item spell - casterLevel = max(1, 2 * static_cast(spellSlotLevel) - 1); // todo special handling for Magic domain + // set caster level + if (itemSpellData == INV_IDX_INVALID) { + spellSys.SpellPacketSetCasterLevel(this); + } + else { // item spell + casterLevel = max(1, 2 * static_cast(spellSlotLevel) - 1); // todo special handling for Magic domain } spellRange = spellSys.GetSpellRange(&spellEntry, casterLevel, caster); bool noTargets = false; - if ((spellEntry.modeTargetSemiBitmask & 0xFF) != static_cast(UiPickerType::Personal) - || spellEntry.radiusTarget < 0 - || (spellEntry.flagsTargetBitmask & UiPickerFlagsTarget::Radius)) - { - noTargets = true; - } - orgTargetCount = 1; - targetCount = 1; - targetListHandles[0] = caster; - aoeCenter.location = objects.GetLocationFull(caster); - aoeCenter.off_z = objects.GetOffsetZ(caster); + if ((spellEntry.modeTargetSemiBitmask & 0xFF) != static_cast(UiPickerType::Personal) + || spellEntry.radiusTarget < 0 + || (spellEntry.flagsTargetBitmask & UiPickerFlagsTarget::Radius)) + { + noTargets = true; + } + orgTargetCount = 1; + targetCount = 1; + targetListHandles[0] = caster; + aoeCenter.location = objects.GetLocationFull(caster); + aoeCenter.off_z = objects.GetOffsetZ(caster); if (spellEntry.radiusTarget > 0) { spellRange = spellEntry.radiusTarget; } @@ -636,11 +640,11 @@ bool SpellPacketBody::IsArcane() { bool SpellPacketBody::IsItemSpell(){ return invIdx != INV_IDX_INVALID; -} - -bool SpellPacketBody::IsPermanent() const -{ - return false; // stub +} + +bool SpellPacketBody::IsPermanent() const +{ + return false; // stub } int SpellPacketBody::GetSpellSchool() @@ -1084,11 +1088,11 @@ const char* LegacySpellSystem::GetSpellMesline(uint32_t lineNumber) const{ mesFuncs.GetLine_Safe(*spellMes, &mesLine); return mesLine.value; -} - -const char* LegacySpellSystem::GetDomainName(int domainEnum) const -{ - return GetSpellMesline(4000 + domainEnum); +} + +const char* LegacySpellSystem::GetDomainName(int domainEnum) const +{ + return GetSpellMesline(4000 + domainEnum); } const char * LegacySpellSystem::GetSpellDescription(uint32_t spellEnum) const @@ -1579,148 +1583,148 @@ void LegacySpellSystem::SpellSavePruneInactive() const auto spellName = GetSpellName(it.spellEnum); logger->debug("{}, targetCount {}", spellName, it.targetCount); } -} - - -void LegacySpellSystem::SaveDebugRecords() const -{ - - //auto tioFile = tio_fopen("Save\\Current\\spell_debug_records.bin", "wb"); - VfsOutputStream tioFile("Save\\Current\\spell_debug_records.bin", "wb"); - { - - for (auto& it : spellDebugRecords) { - MemoryOutputStream buf; - auto spellId = it.first; - auto &spell = *it.second.get(); - - auto spellName = GetSpellName(spell.spellEnum); // for debug - - // Write Debugdata - { - spell.Write(buf); - } - auto &memBuf = buf.GetBuffer(); - - tioFile.WriteUInt32(memBuf.size()); - tioFile.WriteBytes(&memBuf[0], memBuf.size()); - } - - } - -} - -void LegacySpellSystem::LoadDebugRecords() { - spellDebugRecords.clear(); - - try { - auto data = vfs->ReadAsBinary("Save\\Current\\spell_debug_records.bin"); - MemoryInputStream file(data); - - - while (file.GetPos() < data.size()) { - - auto newDebugRecord = make_unique(file); - spellDebugRecords.emplace(newDebugRecord.get()->spellId, std::move(newDebugRecord)); - } - - } - catch (TempleException e){ - logger->info(e.what()); - } - -} - -void LegacySpellSystem::ResetDebugRecords() { - spellDebugRecords.clear(); -} - -struct JammedSpellData { - int durationRemaining; - int duration; -}; -std::map jsmap; -void LegacySpellSystem::JammedSpellsCreateRef() -{ - jsmap.clear(); +} + + +void LegacySpellSystem::SaveDebugRecords() const +{ + + //auto tioFile = tio_fopen("Save\\Current\\spell_debug_records.bin", "wb"); + VfsOutputStream tioFile("Save\\Current\\spell_debug_records.bin", "wb"); + { + + for (auto& it : spellDebugRecords) { + MemoryOutputStream buf; + auto spellId = it.first; + auto &spell = *it.second.get(); + + auto spellName = GetSpellName(spell.spellEnum); // for debug + + // Write Debugdata + { + spell.Write(buf); + } + auto &memBuf = buf.GetBuffer(); + + tioFile.WriteUInt32(memBuf.size()); + tioFile.WriteBytes(&memBuf[0], memBuf.size()); + } + + } + +} + +void LegacySpellSystem::LoadDebugRecords() { + spellDebugRecords.clear(); + + try { + auto data = vfs->ReadAsBinary("Save\\Current\\spell_debug_records.bin"); + MemoryInputStream file(data); + + + while (file.GetPos() < data.size()) { + + auto newDebugRecord = make_unique(file); + spellDebugRecords.emplace(newDebugRecord.get()->spellId, std::move(newDebugRecord)); + } + + } + catch (TempleException e){ + logger->info(e.what()); + } + +} + +void LegacySpellSystem::ResetDebugRecords() { + spellDebugRecords.clear(); +} + +struct JammedSpellData { + int durationRemaining; + int duration; +}; +std::map jsmap; +void LegacySpellSystem::JammedSpellsCreateRef() +{ + jsmap.clear(); for (auto it = spellsCastRegistry.begin(); it != spellsCastRegistry.end(); ++it ) { auto& node = *it; bool shouldPrune = false; - SpellPacketBody & pkt = node.data->spellPktBody; - if (node.data->isActive == 0) - continue; - if (!pkt.spellId || !pkt.spellEnum) - continue; - if (pkt.IsPermanent()) - continue; - if (pkt.duration < 0 || pkt.durationRemaining < 0) - continue; - jsmap[pkt.spellId] = {pkt.durationRemaining,pkt.duration }; - } -} - -void LegacySpellSystem::JammedSpellsPrune(int roundsAdvanced) -{ + SpellPacketBody & pkt = node.data->spellPktBody; + if (node.data->isActive == 0) + continue; + if (!pkt.spellId || !pkt.spellEnum) + continue; + if (pkt.IsPermanent()) + continue; + if (pkt.duration < 0 || pkt.durationRemaining < 0) + continue; + jsmap[pkt.spellId] = {pkt.durationRemaining,pkt.duration }; + } +} + +void LegacySpellSystem::JammedSpellsPrune(int roundsAdvanced) +{ for (auto it = spellsCastRegistry.begin(); it != spellsCastRegistry.end(); ++it ) - { + { auto& node = *it; bool shouldPrune = false; - auto& pkt = node.data->spellPktBody; - if (node.data->isActive == 0) - continue; - if (!pkt.spellId || !pkt.spellEnum) - continue; - if (jsmap.find(pkt.spellId) == jsmap.end()) - continue; - auto &js = jsmap[pkt.spellId]; - auto expectedDur = js.durationRemaining - roundsAdvanced; - if (expectedDur < 0 && pkt.durationRemaining >= 0) { - shouldPrune = true; - } - - if (shouldPrune) { - JammedSpellEnd(pkt.spellId); - } - } -} - -void LegacySpellSystem::JammedSpellEnd(int spellId) -{ - - SpellPacketBody pkt(spellId); - logger->info("Detected jammed spell ID {} ({} {}), removing", spellId, pkt.spellEnum, GetSpellName(pkt.spellEnum)); - - if (objSystem->IsValidHandle(pkt.caster)) { - d20Sys.d20SendSignal(pkt.caster, DK_SIG_Spell_End, spellId, 0); - - pkt.EndPartsysForTgtObj(pkt.caster); - - //pySpellIntegration.SpellSoundPlay(&pkt, SpellEvent::EndSpellCast); - pkt.RemoveObjFromTargetList(pkt.caster); - } - - - for (auto i = 0; i < pkt.targetCount; ++i) { - auto tgt = pkt.targetListHandles[i]; - if (!objSystem->IsValidHandle(tgt)) - continue; - d20Sys.d20SendSignal(tgt, DK_SIG_Spell_End, spellId, 0); - pkt.EndPartsysForTgtObj(tgt); - pkt.RemoveObjFromTargetList(tgt); - } - - - //SpellEnd(spellId, 1); - /* - Do not use SpellEnd - it could be referencing invalid handles and cause crashes (e.g. playing a sound on the caster, which could be invalid). - All it does in practice is invoke spell trigger for spell_end event(which is usually not very important... some Co8 scripts maybe) - and then calls SpellMarkInactive... so we'll do just that instead. - */ - SpellMarkInactive(spellId); - + auto& pkt = node.data->spellPktBody; + if (node.data->isActive == 0) + continue; + if (!pkt.spellId || !pkt.spellEnum) + continue; + if (jsmap.find(pkt.spellId) == jsmap.end()) + continue; + auto &js = jsmap[pkt.spellId]; + auto expectedDur = js.durationRemaining - roundsAdvanced; + if (expectedDur < 0 && pkt.durationRemaining >= 0) { + shouldPrune = true; + } + + if (shouldPrune) { + JammedSpellEnd(pkt.spellId); + } + } +} + +void LegacySpellSystem::JammedSpellEnd(int spellId) +{ + + SpellPacketBody pkt(spellId); + logger->info("Detected jammed spell ID {} ({} {}), removing", spellId, pkt.spellEnum, GetSpellName(pkt.spellEnum)); + + if (objSystem->IsValidHandle(pkt.caster)) { + d20Sys.d20SendSignal(pkt.caster, DK_SIG_Spell_End, spellId, 0); + + pkt.EndPartsysForTgtObj(pkt.caster); + + //pySpellIntegration.SpellSoundPlay(&pkt, SpellEvent::EndSpellCast); + pkt.RemoveObjFromTargetList(pkt.caster); + } + + + for (auto i = 0; i < pkt.targetCount; ++i) { + auto tgt = pkt.targetListHandles[i]; + if (!objSystem->IsValidHandle(tgt)) + continue; + d20Sys.d20SendSignal(tgt, DK_SIG_Spell_End, spellId, 0); + pkt.EndPartsysForTgtObj(tgt); + pkt.RemoveObjFromTargetList(tgt); + } + + + //SpellEnd(spellId, 1); + /* + Do not use SpellEnd - it could be referencing invalid handles and cause crashes (e.g. playing a sound on the caster, which could be invalid). + All it does in practice is invoke spell trigger for spell_end event(which is usually not very important... some Co8 scripts maybe) + and then calls SpellMarkInactive... so we'll do just that instead. + */ + SpellMarkInactive(spellId); + } SpellMapTransferInfo LegacySpellSystem::SaveSpellForTeleport(const SpellPacket& data) @@ -2175,14 +2179,14 @@ bool LegacySpellSystem::IsSpellActive(int spellid) { return spellPacket.isActive == 1; } return false; -} - -void LegacySpellSystem::DoForSpellsCastRegistry(std::function cb) -{ - for (auto it = spellsCastRegistry.begin(); it != spellsCastRegistry.end(); ++it) { - auto &pkt = *(*it).data; - cb(pkt); - } +} + +void LegacySpellSystem::DoForSpellsCastRegistry(std::function cb) +{ + for (auto it = spellsCastRegistry.begin(); it != spellsCastRegistry.end(); ++it) { + auto &pkt = *(*it).data; + cb(pkt); + } } CondStruct* LegacySpellSystem::GetCondFromSpellCondId(int id) { @@ -2260,9 +2264,9 @@ void LegacySpellSystem::SpellsCastReset(objHndl handle, Stat classEnum){ } } -/* 0x10075BC0 */ -void LegacySpellSystem::SpellKnownRemove(objHndl handle, SpellStoreData& spData) -{ +/* 0x10075BC0 */ +void LegacySpellSystem::SpellKnownRemove(objHndl handle, SpellStoreData& spData) +{ auto obj = objSystem->GetObject(handle); auto numKnown = obj->GetSpellArray(obj_f_critter_spells_known_idx).GetSize(); @@ -2277,9 +2281,9 @@ void LegacySpellSystem::SpellKnownRemove(objHndl handle, SpellStoreData& spData) spellSys.spellRemoveFromStorage(handle, obj_f_critter_spells_known_idx, &knSpell, 0); return; } - } -} - + } +} + /* 0x10075A10 */ void LegacySpellSystem::SpellMemorizedAdd(objHndl handle, int spellEnum, int spellClass, int spellLvl, int spellStoreData, int metaMagicData){ @@ -2427,6 +2431,43 @@ BOOL LegacySpellSystem::SpellEntriesInit(const char * spellRulesFolder){ return TRUE; } +void LegacySpellSystem::AddOracleDomainSpells() +{ + // Oracle domain spell list (Complete Divine, also at realmshelps.net): + // 1st: Identify, 2nd: Augury, 3rd: Divination, 4th: Scrying, + // 5th: Commune, 6th: Legend Lore, 7th: Greater Scrying, + // 8th: Discern Location, 9th: Foresight. + // Granted power (+2 caster level on Divination) is applied separately via + // the Divine Oracle class feature; this method only registers the + // spell-by-level mapping the engine needs for domain spell selection. + static const std::pair oracleSpells[] = { + {238, 1}, // Identify + { 23, 2}, // Augury + {136, 3}, // Divination + {410, 4}, // Scrying + { 69, 5}, // Commune + {264, 6}, // Legend Lore + {209, 7}, // Greater Scrying + {126, 8}, // Discern Location + {186, 9}, // Foresight + }; + + for (auto& spec : oracleSpells) { + auto spEntry = spellEntryRegistry.get(spec.first); + if (!spEntry) { + logger->warn("AddOracleDomainSpells: spell {} not in registry", spec.first); + continue; + } + if (spEntry->spellLvlsNum >= 10) { + logger->warn("AddOracleDomainSpells: spell {} has no room for Oracle entry", spec.first); + continue; + } + spEntry->spellLvls[spEntry->spellLvlsNum].spellClass = Domain_Oracle; + spEntry->spellLvls[spEntry->spellLvlsNum].slotLevel = spec.second; + spEntry->spellLvlsNum++; + } +} + // Factored out logic. Tries to look up a lowercased string key in the map, and // otherwise searches for a prefix match. template @@ -2996,44 +3037,44 @@ bool LegacySpellSystem::numSpellsMemorizedTooHigh(objHndl objHnd) return 1; } return 0; -} - -/* 0x101B5AD0 */ -bool LegacySpellSystem::SpellOpposesCritterAlignment(SpellStoreData& spData, objHndl handle) -{ - auto obj = objSystem->GetObject(handle); - if (!obj) return false; - if (!spData.spellEnum) return false; - - if (isDomainSpell(spData.classCode) || spellSys.GetCastingClass(spData.classCode) == stat_level_cleric) { - - SpellEntry spEntry(spData.spellEnum); - if (!spEntry.spellEnum) return false; - auto critterAlignment = obj->GetInt32(obj_f_critter_alignment); - auto alignmentChoice = obj->GetInt32(obj_f_critter_alignment_choice); - auto descriptor = spEntry.spellDescriptorBitmask; - - if ( - (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_EVIL) - && (critterAlignment & Alignment::ALIGNMENT_GOOD || alignmentChoice == 1) - || - (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_GOOD) - && (critterAlignment & Alignment::ALIGNMENT_EVIL || alignmentChoice == 2) - || - (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_LAWFUL) - && (critterAlignment & Alignment::ALIGNMENT_CHAOTIC ) - || - (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_CHAOTIC) - && (critterAlignment & Alignment::ALIGNMENT_LAWFUL) - ) - { - return true; - } - - - } - - return false; +} + +/* 0x101B5AD0 */ +bool LegacySpellSystem::SpellOpposesCritterAlignment(SpellStoreData& spData, objHndl handle) +{ + auto obj = objSystem->GetObject(handle); + if (!obj) return false; + if (!spData.spellEnum) return false; + + if (isDomainSpell(spData.classCode) || spellSys.GetCastingClass(spData.classCode) == stat_level_cleric) { + + SpellEntry spEntry(spData.spellEnum); + if (!spEntry.spellEnum) return false; + auto critterAlignment = obj->GetInt32(obj_f_critter_alignment); + auto alignmentChoice = obj->GetInt32(obj_f_critter_alignment_choice); + auto descriptor = spEntry.spellDescriptorBitmask; + + if ( + (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_EVIL) + && (critterAlignment & Alignment::ALIGNMENT_GOOD || alignmentChoice == 1) + || + (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_GOOD) + && (critterAlignment & Alignment::ALIGNMENT_EVIL || alignmentChoice == 2) + || + (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_LAWFUL) + && (critterAlignment & Alignment::ALIGNMENT_CHAOTIC ) + || + (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_CHAOTIC) + && (critterAlignment & Alignment::ALIGNMENT_LAWFUL) + ) + { + return true; + } + + + } + + return false; } bool LegacySpellSystem::isDomainSpell(uint32_t spellClassCode){ @@ -3070,10 +3111,10 @@ int LegacySpellSystem::GetSpellSchool(int spellEnum){ return spEntry.spellSchoolEnum; } -bool LegacySpellSystem::IsMonsterSpell(int spellEnum) -{ +bool LegacySpellSystem::IsMonsterSpell(int spellEnum) +{ return spellEnum >= NORMAL_SPELL_RANGE - && spellEnum <= SPELL_LIKE_ABILITY_RANGE; + && spellEnum <= SPELL_LIKE_ABILITY_RANGE; } bool LegacySpellSystem::IsSpellLike(int spellEnum){ @@ -3097,14 +3138,14 @@ bool LegacySpellSystem::IsNewSlotDesignator(int spellEnum) } // Non-Core spells will be added in the expanded range -bool LegacySpellSystem::IsNonCore(int spellEnum) -{ +bool LegacySpellSystem::IsNonCore(int spellEnum) +{ auto spSource = mSpellSources.find(spellEnum); if (spSource != mSpellSources.end()) { return static_cast(spSource->second) > 255; } - return (spellEnum > SPELL_ENUM_MAX_VANILLA); + return (spellEnum > SPELL_ENUM_MAX_VANILLA); } bool LegacySpellSystem::IsSpellSourceEnabled(int spellEnum) @@ -3639,15 +3680,15 @@ int LegacySpellSystem::SpellEnd(int spellId, int endDespiteTargetList) const return 1; } -void LegacySpellSystem::SpellMarkInactive(int spellId) const -{ - SpellPacket pkt; +void LegacySpellSystem::SpellMarkInactive(int spellId) const +{ + SpellPacket pkt; if (!spellsCastRegistry.copy(spellId, &pkt)) { logger->debug("SpellMarkInactive: \t Couldn't find spell in registry. Spell id {}", spellId); return; - } + } pkt.isActive = 0; - spellsCastRegistry.put(spellId, pkt); + spellsCastRegistry.put(spellId, pkt); } #pragma endregion @@ -3744,22 +3785,22 @@ bool SpellPacketBody::EndPartsysForTgtObj(const objHndl& handle){ void SpellPacketBody::TriggerAoeHitScript(){ pySpellIntegration.SpellSoundPlay(this, SpellEvent::SpellStruck); pySpellIntegration.SpellTrigger(this->spellId, SpellEvent::AreaOfEffectHit); -} - -SpellComponentFlag SpellPacketBody::GetSpellComponentFlags() -{ - SpellEntry spEntry(spellEnum); - if (spEntry.spellEnum == 0) { - return SpellComponentFlag(); - } - auto result = spEntry.spellComponentBitmask; - MetaMagicData mmData(this->metaMagicData); - if (mmData.metaMagicFlags & MetaMagic_Still) - result &= ~(SpellComponentFlag::SpellComponent_Somatic); - if (mmData.metaMagicFlags & MetaMagic_Silent) - result &= ~(SpellComponentFlag::SpellComponent_Verbal); - - return (SpellComponentFlag)result; +} + +SpellComponentFlag SpellPacketBody::GetSpellComponentFlags() +{ + SpellEntry spEntry(spellEnum); + if (spEntry.spellEnum == 0) { + return SpellComponentFlag(); + } + auto result = spEntry.spellComponentBitmask; + MetaMagicData mmData(this->metaMagicData); + if (mmData.metaMagicFlags & MetaMagic_Still) + result &= ~(SpellComponentFlag::SpellComponent_Somatic); + if (mmData.metaMagicFlags & MetaMagic_Silent) + result &= ~(SpellComponentFlag::SpellComponent_Verbal); + + return (SpellComponentFlag)result; } uint32_t __cdecl _getWizSchool(objHndl objHnd) @@ -3807,35 +3848,35 @@ void _SetSpontaneousCastingAltNode(objHndl obj, int nodeIdx, SpellStoreData* spe spellSys.SetSpontaneousCastingAltNode(obj, nodeIdx, spellData); } #pragma endregion - -SpellDebugRecord::SpellDebugRecord(const SpellPacketBody& spellPkt) -{ - memcpy(this, &spellPkt, sizeof (SpellPacketBody) ); - castingTime = gameTimeSys.GetElapsed(); - casterDebug = SpellDebugObjInfo(this->caster); - castingMapId = gameSystems->GetMap().GetCurrentMapId(); - for (unsigned int i = 0; i < this->targetCount; ++i) { - targetListDebug.push_back(SpellDebugObjInfo(this->targetListHandles[i])); - } - -} - -bool SpellDebugRecord::Write(OutputStream& file) -{ - - // Write SpellPacketBody data - { - auto& spell = *this; - file.WriteInt32(spell.spellId); - - auto curSpellPkt = spellsCastRegistry.get(spellId); - if (curSpellPkt != nullptr) { // active spell - compare vs. record - auto& pkt = curSpellPkt->spellPktBody; - file.WriteInt32(curSpellPkt->isActive); - } - else { - file.WriteInt32(0); - } + +SpellDebugRecord::SpellDebugRecord(const SpellPacketBody& spellPkt) +{ + memcpy(this, &spellPkt, sizeof (SpellPacketBody) ); + castingTime = gameTimeSys.GetElapsed(); + casterDebug = SpellDebugObjInfo(this->caster); + castingMapId = gameSystems->GetMap().GetCurrentMapId(); + for (unsigned int i = 0; i < this->targetCount; ++i) { + targetListDebug.push_back(SpellDebugObjInfo(this->targetListHandles[i])); + } + +} + +bool SpellDebugRecord::Write(OutputStream& file) +{ + + // Write SpellPacketBody data + { + auto& spell = *this; + file.WriteInt32(spell.spellId); + + auto curSpellPkt = spellsCastRegistry.get(spellId); + if (curSpellPkt != nullptr) { // active spell - compare vs. record + auto& pkt = curSpellPkt->spellPktBody; + file.WriteInt32(curSpellPkt->isActive); + } + else { + file.WriteInt32(0); + } file.WriteInt32(spell.spellEnum); file.WriteInt32(spell.spellEnumOriginal); @@ -3875,32 +3916,32 @@ bool SpellDebugRecord::Write(OutputStream& file) file.WriteInt32(spell.spellRange); file.WriteInt32(spell.savingThrowResult); file.WriteInt32(spell.metaMagicData); - //file.WriteInt32(spell.spellId); - } - - file.WriteGameTime(castingTime); - file.WriteInt32(castingMapId); - - casterDebug.Write(file); - file.WriteInt32(targetListDebug.size()); - for (auto& it : targetListDebug) { - it.Write(file); - } - - file.WriteInt32(MAGIC_NUMBER); - - return true; -} - -SpellDebugRecord::SpellDebugRecord(InputStream& file) { - - auto size = file.ReadUInt32(); - - auto& spell = *this; - { - spell.spellId = file.ReadInt32(); - - auto isActive = file.ReadInt32(); + //file.WriteInt32(spell.spellId); + } + + file.WriteGameTime(castingTime); + file.WriteInt32(castingMapId); + + casterDebug.Write(file); + file.WriteInt32(targetListDebug.size()); + for (auto& it : targetListDebug) { + it.Write(file); + } + + file.WriteInt32(MAGIC_NUMBER); + + return true; +} + +SpellDebugRecord::SpellDebugRecord(InputStream& file) { + + auto size = file.ReadUInt32(); + + auto& spell = *this; + { + spell.spellId = file.ReadInt32(); + + auto isActive = file.ReadInt32(); spell.spellEnum = file.ReadInt32(); spell.spellEnumOriginal = file.ReadInt32(); @@ -3919,124 +3960,124 @@ SpellDebugRecord::SpellDebugRecord(InputStream& file) { spell.savingThrowResult = file.ReadInt32(); spell.metaMagicData = file.ReadInt32(); } - - file.ReadGameTime(castingTime); - castingMapId = file.ReadInt32(); - - casterDebug = SpellDebugObjInfo(file); - - auto targetListSize = file.ReadInt32(); - if (targetListSize > MAX_SPELL_TARGETS) { - throw TempleException("SpellDebugRecord: targetlist size exceeds limit ({})", MAX_SPELL_TARGETS); - } - - for (auto i = 0; i < targetListSize; ++i) { - targetListDebug.push_back(SpellDebugObjInfo(file)); - } - - auto magicNumber = file.ReadInt32(); - if (magicNumber != MAGIC_NUMBER) { - throw TempleException("SpellDebugRecord: targetlist size exceeds limit ({})", MAX_SPELL_TARGETS); - } - -} -//***************************************************************************** -//* SpellSystem -//***************************************************************************** - -bool SpellSystem::Save(TioFile* file) { - - logger->debug("Saving Spells: {} spells initially in SpellsCastRegistry.", spellSys.spellCastIdxTable->itemCount); - spellSys.SpellSavePruneInactive(); - logger->debug("Saving Spells: {} spells after pruning.", spellSys.spellCastIdxTable->itemCount); - - TioOutputStream tios(file); - - auto spellIdSerial = temple::GetPointer(0x10AAF204); - tios.WriteInt32(*spellIdSerial); - - int numSpells = spellSys.spellCastIdxTable->itemCount; - tios.WriteInt32(numSpells); - - auto numSerialized = spellSys.SpellSave(tios); - if (numSerialized != numSpells) - { - logger->error("Serialized wrong number of spells! SAVE IS CORRUPT! Serialized: {}, expected: {}", numSerialized, numSpells); - } - - spellSys.SaveDebugRecords(); - - return TRUE; -} - -SpellSystem::SpellSystem(const GameSystemConf& config) { - auto startup = temple::GetPointer(0x1007b740); - if (!startup(&config)) { - throw TempleException("Unable to initialize game system Spell"); - } - spellSys.Init(config); -} -SpellSystem::~SpellSystem() { - auto shutdown = temple::GetPointer(0x100791d0); - mesFuncs.Close(spellSys.spellEnumsExt); - mesFuncs.Close(spellSys.spellMesExt); - if (config.extendedSpellDescriptions) { - mesFuncs.Close(spellSys.spellMesLong); - } - shutdown(); - -} - -void SpellSystem::Reset() { - auto reset = temple::GetPointer(0x100750f0); - reset(); - - spellSys.ResetDebugRecords(); -} - -const std::string& SpellSystem::GetName() const { - static std::string name("Spell"); - return name; -} - -/* 0x100792A0 */ -bool SpellSystem::Load(GameSystemSaveFile* file) { - spellSys.LoadDebugRecords(); - - { - logger->info("Loading Spells: {} spells initially in SpellsCastRegistry.", spellSys.spellCastIdxTable->itemCount); - - auto spellIdSerial = temple::GetPointer(0x10AAF204); - tio_fread(spellIdSerial, sizeof(int), 1, file->file); - - uint32_t numSpells; - if (tio_fread(&numSpells, 4, 1, file->file) != 1) - return FALSE; - - Expects(numSpells >= 0); - Expects(*spellIdSerial >= numSpells); - - if (numSpells <= 0) - return TRUE; - - uint32_t spellId; - SpellPacket pkt; - - for (uint32_t i = 0; i < numSpells; i++) { - if (spellSys.LoadActiveSpellElement(file->file, spellId, pkt) != 1) { - logger->warn("Loading Spells: Failure! {} spells in SpellsCastRegistry after loading.", spellSys.spellCastIdxTable->itemCount); - return FALSE; - } - if (!(spellId <= *spellIdSerial)) { - logger->warn("Invalid spellId {} detected, greater than spellIdSerial!", spellId); - } - if (!pkt.spellPktBody.caster) { - logger->warn("Null caster object!", spellId); - } - spellSys.SpellsCastRegistryPut(spellId, pkt); - } - logger->info("Loading Spells: {} spells in SpellsCastRegistry after loading.", spellSys.spellCastIdxTable->itemCount); - } - - return TRUE; + + file.ReadGameTime(castingTime); + castingMapId = file.ReadInt32(); + + casterDebug = SpellDebugObjInfo(file); + + auto targetListSize = file.ReadInt32(); + if (targetListSize > MAX_SPELL_TARGETS) { + throw TempleException("SpellDebugRecord: targetlist size exceeds limit ({})", MAX_SPELL_TARGETS); + } + + for (auto i = 0; i < targetListSize; ++i) { + targetListDebug.push_back(SpellDebugObjInfo(file)); + } + + auto magicNumber = file.ReadInt32(); + if (magicNumber != MAGIC_NUMBER) { + throw TempleException("SpellDebugRecord: targetlist size exceeds limit ({})", MAX_SPELL_TARGETS); + } + +} +//***************************************************************************** +//* SpellSystem +//***************************************************************************** + +bool SpellSystem::Save(TioFile* file) { + + logger->debug("Saving Spells: {} spells initially in SpellsCastRegistry.", spellSys.spellCastIdxTable->itemCount); + spellSys.SpellSavePruneInactive(); + logger->debug("Saving Spells: {} spells after pruning.", spellSys.spellCastIdxTable->itemCount); + + TioOutputStream tios(file); + + auto spellIdSerial = temple::GetPointer(0x10AAF204); + tios.WriteInt32(*spellIdSerial); + + int numSpells = spellSys.spellCastIdxTable->itemCount; + tios.WriteInt32(numSpells); + + auto numSerialized = spellSys.SpellSave(tios); + if (numSerialized != numSpells) + { + logger->error("Serialized wrong number of spells! SAVE IS CORRUPT! Serialized: {}, expected: {}", numSerialized, numSpells); + } + + spellSys.SaveDebugRecords(); + + return TRUE; +} + +SpellSystem::SpellSystem(const GameSystemConf& config) { + auto startup = temple::GetPointer(0x1007b740); + if (!startup(&config)) { + throw TempleException("Unable to initialize game system Spell"); + } + spellSys.Init(config); +} +SpellSystem::~SpellSystem() { + auto shutdown = temple::GetPointer(0x100791d0); + mesFuncs.Close(spellSys.spellEnumsExt); + mesFuncs.Close(spellSys.spellMesExt); + if (config.extendedSpellDescriptions) { + mesFuncs.Close(spellSys.spellMesLong); + } + shutdown(); + +} + +void SpellSystem::Reset() { + auto reset = temple::GetPointer(0x100750f0); + reset(); + + spellSys.ResetDebugRecords(); +} + +const std::string& SpellSystem::GetName() const { + static std::string name("Spell"); + return name; +} + +/* 0x100792A0 */ +bool SpellSystem::Load(GameSystemSaveFile* file) { + spellSys.LoadDebugRecords(); + + { + logger->info("Loading Spells: {} spells initially in SpellsCastRegistry.", spellSys.spellCastIdxTable->itemCount); + + auto spellIdSerial = temple::GetPointer(0x10AAF204); + tio_fread(spellIdSerial, sizeof(int), 1, file->file); + + uint32_t numSpells; + if (tio_fread(&numSpells, 4, 1, file->file) != 1) + return FALSE; + + Expects(numSpells >= 0); + Expects(*spellIdSerial >= numSpells); + + if (numSpells <= 0) + return TRUE; + + uint32_t spellId; + SpellPacket pkt; + + for (uint32_t i = 0; i < numSpells; i++) { + if (spellSys.LoadActiveSpellElement(file->file, spellId, pkt) != 1) { + logger->warn("Loading Spells: Failure! {} spells in SpellsCastRegistry after loading.", spellSys.spellCastIdxTable->itemCount); + return FALSE; + } + if (!(spellId <= *spellIdSerial)) { + logger->warn("Invalid spellId {} detected, greater than spellIdSerial!", spellId); + } + if (!pkt.spellPktBody.caster) { + logger->warn("Null caster object!", spellId); + } + spellSys.SpellsCastRegistryPut(spellId, pkt); + } + logger->info("Loading Spells: {} spells in SpellsCastRegistry after loading.", spellSys.spellCastIdxTable->itemCount); + } + + return TRUE; } diff --git a/TemplePlus/spell.h b/TemplePlus/spell.h index 9f7cb39c6..5b8a5da0a 100644 --- a/TemplePlus/spell.h +++ b/TemplePlus/spell.h @@ -338,6 +338,11 @@ struct LegacySpellSystem : temple::AddressTable BOOL SpellEntriesInit(const char* spellRulesFodler); bool SpellEntryFileParse(SpellEntry & spEntry, TioFile *tf); + // Injects the Oracle domain (Complete Divine) into the existing divination + // spells listed in its 1st-9th level granted spell list. Run once after + // SpellEntriesInit so the engine treats Oracle just like any other domain. + void AddOracleDomainSpells(); + std::map> mMultiOptions; void GetSpellEntryExtFromClassSpec(std::map& mapping, int classEnum); diff --git a/TemplePlus/temple_enums.h b/TemplePlus/temple_enums.h index 7f6c860d8..f1ca77dfb 100644 --- a/TemplePlus/temple_enums.h +++ b/TemplePlus/temple_enums.h @@ -1952,7 +1952,8 @@ enum Domain Domain_War = 21, Domain_Water = 22, Domain_Special = 23, - Domain_Count = 24 + Domain_Oracle = 24, // Complete Divine; granted power +2 caster level on Divination spells + Domain_Count = 25 }; #pragma endregion @@ -2011,6 +2012,7 @@ enum Stat : uint32_t { stat_level_warmage = 47, stat_level_beguilers = 48, stat_level_swashbuckler = 49, + stat_level_divine_oracle = 50, stat_level_psion = 58, stat_level_psychic_warrior = 59, diff --git a/TemplePlus/turn_based.cpp b/TemplePlus/turn_based.cpp index 3e02eb4b7..7a93d23fe 100644 --- a/TemplePlus/turn_based.cpp +++ b/TemplePlus/turn_based.cpp @@ -225,7 +225,15 @@ void TurnBasedSys::AddToInitiative(objHndl handle) conds.AddTo(handle, "Flatfooted", {}); auto isSurpriseRound = temple::GetRef(0x10BCAD90); if (isSurpriseRound){ - conds.AddTo(handle, "Surprised", {}); + // Divine Oracle 10+: Immune to Surprise. PDF says the oracle can always + // take a standard action during a surprise round; the cleanest engine + // expression of that is to skip the Surprised condition entirely + // (which means full normal turn instead of strictly a standard action). + // Erring on the player's favor for a 10th-level capstone is consistent + // with how other capstones are translated to ToEE's combat model. + if (objects.StatLevelGet(handle, stat_level_divine_oracle) < 10) { + conds.AddTo(handle, "Surprised", {}); + } } /*auto addToInit = temple::GetRef(0x100DF1E0); diff --git a/tpdata/templeplus/lib/templeplus/constants.py b/tpdata/templeplus/lib/templeplus/constants.py index d424ecda7..91cc618b8 100644 --- a/tpdata/templeplus/lib/templeplus/constants.py +++ b/tpdata/templeplus/lib/templeplus/constants.py @@ -3610,6 +3610,7 @@ stat_level_warmage = 47 stat_level_beguiler = 48 stat_level_swashbuckler = 49 +stat_level_divine_oracle = 50 stat_level_psion = 58 stat_level_psychic_warrior = 59 diff --git a/tpdata/tprules/class_specs.tab b/tpdata/tprules/class_specs.tab index 2cf1d6149..6ff814df4 100644 --- a/tpdata/tprules/class_specs.tab +++ b/tpdata/tprules/class_specs.tab @@ -29,7 +29,7 @@ Thaumaturgist 32 // other 3rd ed stuff Warlock 33 Favored Soul 34 -Divine Oracle 35 +PLACEHOLDER 35 PLACEHOLDER 36 PLACEHOLDER 37 PLACEHOLDER 38 @@ -44,7 +44,7 @@ PLACEHOLDER 46 PLACEHOLDER 47 PLACEHOLDER 48 PLACEHOLDER 49 -PLACEHOLDER 50 +Divine Oracle 50 PLACEHOLDER 51 PLACEHOLDER 52 PLACEHOLDER 53 diff --git a/tpdatasrc/tpgamefiles/mes/spell_ext.mes b/tpdatasrc/tpgamefiles/mes/spell_ext.mes index 651214ebd..bbf5ac116 100644 --- a/tpdatasrc/tpgamefiles/mes/spell_ext.mes +++ b/tpdatasrc/tpgamefiles/mes/spell_ext.mes @@ -1,3 +1,6 @@ +// Domain name; line ID = 4000 + Domain_Oracle (24). +{4024}{Oracle} + {1000}{Moment of Prescience} {1001}{Touch of Fatigue} {1002}{Ray of Exhaustion} diff --git a/tpdatasrc/tpgamefiles/mes/stat_ext.mes b/tpdatasrc/tpgamefiles/mes/stat_ext.mes index cef9f640b..7a957ed99 100644 --- a/tpdatasrc/tpgamefiles/mes/stat_ext.mes +++ b/tpdatasrc/tpgamefiles/mes/stat_ext.mes @@ -19,7 +19,7 @@ // Other supplemental stuff for 3.5 ed {33}{Warlock} {34}{Favored Soul} -{35}{Divine Oracle} +{35}{Red Avenger} {36}{Iaijutsu Master} {37}{Sacred Fist} {38}{Stormlord} @@ -34,6 +34,7 @@ {47}{Warmage} {48}{Beguiler} {49}{Swashbuckler} +{50}{Divine Oracle} // Psionic Classes {58}{Psion} @@ -90,7 +91,7 @@ {1033}{Wrlk} {1034}{Fvrd} -{1035}{DvOr} +{1035}{} {1036}{} {1037}{} {1038}{} @@ -105,7 +106,7 @@ {1047}{Wrmg} {1048}{Bglr} {1049}{Swbk} -{1050}{} +{1050}{DvOr} {1051}{} {1052}{} {1053}{} @@ -301,11 +302,6 @@ Spells: Able to cast lesser planar ally.} {13027}Favored Soul{Favored souls cast divine spells by means of an innate connection rather than through laborious training and prayer, so their divine connection is natural rather than learned.} -{13028}Divine Oracle{All divine oracles are spellcasters sharing a particular devotion to the Divination school of magic, having mastered all available means to catch glimpses of the future. - -Requirements: Knowledge (religion) 8 ranks, Skill Focus (Knowledge), able to cast at least 2 divination spells. -} - {13031}Stormlord{Stormlords wield thunder and lightning like a warrior wields his sword. They serve their deity through random, spectacular acts of violence. Requirements: CE/CN/NE, Fort Save +4, can cast 3rd-level divine spells. @@ -325,6 +321,12 @@ Special: Can cast 1st-level arcane spells and knows 1 abjuration spell of first {13040}Warmage{A warmage is a militant spellcaster whose training focuses on battlefield magic.} {13041}Beguiler{A beguiler is a spellcaster who delights in manipulating others, either to their disadvantage or for their own good.} {13042}Swashbuckler{The swashbuckler embodies the concepts of daring and panache.} + +{13043}Divine Oracle{All divine oracles are spellcasters sharing a particular devotion to the Divination school of magic, having mastered all available means to catch glimpses of the future. + +Requirements: Knowledge (religion) 8 ranks, Skill Focus (Knowledge), able to cast at least 2 divination spells. +} + {13075}Fochlucan Lyrist{The Fochlucan lyrist is a Warrior, thief, spy, poet and woodland champion. Requirements: Alignment NG/TN/CN/NE. diff --git a/tpdatasrc/tpgamefiles/rules/char_class/class035_divine_oracle.py b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py similarity index 97% rename from tpdatasrc/tpgamefiles/rules/char_class/class035_divine_oracle.py rename to tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py index 6d7b80b7e..6372d3585 100644 --- a/tpdatasrc/tpgamefiles/rules/char_class/class035_divine_oracle.py +++ b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py @@ -19,7 +19,7 @@ def GetClassDefinitionFlags(): def GetClassHelpTopic(): return "TAG_DIVINE_ORACLES" -classEnum = stat_level_red_avenger # reusing this unused-but-declared enum slot to avoid C++ changes +classEnum = stat_level_divine_oracle ################################################### diff --git a/tpdatasrc/tpgamefiles/rules/stat_ext.mes b/tpdatasrc/tpgamefiles/rules/stat_ext.mes index 3b87cf788..eab58c66a 100644 --- a/tpdatasrc/tpgamefiles/rules/stat_ext.mes +++ b/tpdatasrc/tpgamefiles/rules/stat_ext.mes @@ -18,7 +18,7 @@ {33}{Warlock} {34}{Favored Soul} -{35}{Divine Oracle} +{35}{Red Avenger} {36}{Iaijutsu Master} {37}{Sacred Fist} {38}{Stormlord} @@ -29,6 +29,7 @@ {43}{Frost Mage} {44}{Artificer} {45}{Abjurant Champion} +{50}{Divine Oracle} // Psionic Classes {58}{Psion} diff --git a/tpdatasrc/tpgamefiles/scr/tpModifiers/divine_oracle.py b/tpdatasrc/tpgamefiles/scr/tpModifiers/divine_oracle.py index 15c03c106..b5bc8bc5a 100644 --- a/tpdatasrc/tpgamefiles/scr/tpModifiers/divine_oracle.py +++ b/tpdatasrc/tpgamefiles/scr/tpModifiers/divine_oracle.py @@ -13,8 +13,8 @@ def GetSpellCasterConditionName(): print "Registering " + GetConditionName() -classEnum = stat_level_red_avenger # reusing this unused-but-declared enum slot to avoid C++ changes -classSpecModule = __import__('class035_divine_oracle') +classEnum = stat_level_divine_oracle +classSpecModule = __import__('class050_divine_oracle') ################################################### @@ -165,8 +165,8 @@ def OracleDomainCasterLevelMod(attachee, args, evt_obj): ## Immune to Surprise (Ex) ## -# At 10th level, the divine oracle can always take a standard action during a -# surprise round. Registered as a marker; full effect would require turn/initiative -# system hooks. +# Marker feat for the character sheet. Mechanical effect is implemented in +# C++ at TurnBasedSys add-to-initiative time: Divine Oracle 10+ characters +# skip the engine "Surprised" condition entirely (turn_based.cpp). immuneSurprise = PythonModifier("Divine Oracle Immune to Surprise", 0) immuneSurprise.MapToFeat("Divine Oracle Immune to Surprise") From 295025f495595441a4abf0bd8e48b9009021af5b Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 17:41:52 +0100 Subject: [PATCH 05/12] spell.cpp: strip line-ending churn, keep only Oracle domain change The branch had re-saved spell.cpp with normalized line endings, producing an 880-line diff (463/422) that was 95% EOL noise and obscured the real ~42-line change. Restored master's exact bytes (mixed LF/CRLF preserved) and re-applied only the two functional edits: the SpellEntriesInit hook calling AddOracleDomainSpells(), and the AddOracleDomainSpells() method itself. No behavioral change vs the previous commit; diff is now 42/1. Co-Authored-By: Claude Opus 4.7 (1M context) --- TemplePlus/spell.cpp | 842 +++++++++++++++++++++---------------------- 1 file changed, 421 insertions(+), 421 deletions(-) diff --git a/TemplePlus/spell.cpp b/TemplePlus/spell.cpp index 04fce9d9b..a60c2af5f 100644 --- a/TemplePlus/spell.cpp +++ b/TemplePlus/spell.cpp @@ -467,40 +467,40 @@ SpellPacketBody::SpellPacketBody(objHndl spellCaster, D20SpellData& spellData) D20SpellDataExtractInfo(&spellData, &spellEnum, &spellEnumOrg, &spellClassCode, &spellSlotLevel, &itemSpellData, &spellMetaMagicData); //From ::TargetCheck - spellEnum = spellEnum; - spellEnumOriginal = spellEnumOrg; - caster = spellCaster; - spellClass = spellClassCode; - spellKnownSlotLevel = spellSlotLevel; - metaMagicData = spellMetaMagicData; + spellEnum = spellEnum; + spellEnumOriginal = spellEnumOrg; + caster = spellCaster; + spellClass = spellClassCode; + spellKnownSlotLevel = spellSlotLevel; + metaMagicData = spellMetaMagicData; invIdx = itemSpellData; SpellEntry spellEntry; - if (!spellSys.spellRegistryCopy(spellEnum, &spellEntry)) + if (!spellSys.spellRegistryCopy(spellEnum, &spellEntry)) { - // set caster level - if (itemSpellData == INV_IDX_INVALID) { - spellSys.SpellPacketSetCasterLevel(this); - } - else { // item spell - casterLevel = max(1, 2 * static_cast(spellSlotLevel) - 1); // todo special handling for Magic domain + // set caster level + if (itemSpellData == INV_IDX_INVALID) { + spellSys.SpellPacketSetCasterLevel(this); + } + else { // item spell + casterLevel = max(1, 2 * static_cast(spellSlotLevel) - 1); // todo special handling for Magic domain } spellRange = spellSys.GetSpellRange(&spellEntry, casterLevel, caster); bool noTargets = false; - if ((spellEntry.modeTargetSemiBitmask & 0xFF) != static_cast(UiPickerType::Personal) - || spellEntry.radiusTarget < 0 - || (spellEntry.flagsTargetBitmask & UiPickerFlagsTarget::Radius)) - { - noTargets = true; - } - orgTargetCount = 1; - targetCount = 1; - targetListHandles[0] = caster; - aoeCenter.location = objects.GetLocationFull(caster); - aoeCenter.off_z = objects.GetOffsetZ(caster); + if ((spellEntry.modeTargetSemiBitmask & 0xFF) != static_cast(UiPickerType::Personal) + || spellEntry.radiusTarget < 0 + || (spellEntry.flagsTargetBitmask & UiPickerFlagsTarget::Radius)) + { + noTargets = true; + } + orgTargetCount = 1; + targetCount = 1; + targetListHandles[0] = caster; + aoeCenter.location = objects.GetLocationFull(caster); + aoeCenter.off_z = objects.GetOffsetZ(caster); if (spellEntry.radiusTarget > 0) { spellRange = spellEntry.radiusTarget; } @@ -640,11 +640,11 @@ bool SpellPacketBody::IsArcane() { bool SpellPacketBody::IsItemSpell(){ return invIdx != INV_IDX_INVALID; -} - -bool SpellPacketBody::IsPermanent() const -{ - return false; // stub +} + +bool SpellPacketBody::IsPermanent() const +{ + return false; // stub } int SpellPacketBody::GetSpellSchool() @@ -1088,11 +1088,11 @@ const char* LegacySpellSystem::GetSpellMesline(uint32_t lineNumber) const{ mesFuncs.GetLine_Safe(*spellMes, &mesLine); return mesLine.value; -} - -const char* LegacySpellSystem::GetDomainName(int domainEnum) const -{ - return GetSpellMesline(4000 + domainEnum); +} + +const char* LegacySpellSystem::GetDomainName(int domainEnum) const +{ + return GetSpellMesline(4000 + domainEnum); } const char * LegacySpellSystem::GetSpellDescription(uint32_t spellEnum) const @@ -1583,148 +1583,148 @@ void LegacySpellSystem::SpellSavePruneInactive() const auto spellName = GetSpellName(it.spellEnum); logger->debug("{}, targetCount {}", spellName, it.targetCount); } -} - - -void LegacySpellSystem::SaveDebugRecords() const -{ - - //auto tioFile = tio_fopen("Save\\Current\\spell_debug_records.bin", "wb"); - VfsOutputStream tioFile("Save\\Current\\spell_debug_records.bin", "wb"); - { - - for (auto& it : spellDebugRecords) { - MemoryOutputStream buf; - auto spellId = it.first; - auto &spell = *it.second.get(); - - auto spellName = GetSpellName(spell.spellEnum); // for debug - - // Write Debugdata - { - spell.Write(buf); - } - auto &memBuf = buf.GetBuffer(); - - tioFile.WriteUInt32(memBuf.size()); - tioFile.WriteBytes(&memBuf[0], memBuf.size()); - } - - } - -} - -void LegacySpellSystem::LoadDebugRecords() { - spellDebugRecords.clear(); - - try { - auto data = vfs->ReadAsBinary("Save\\Current\\spell_debug_records.bin"); - MemoryInputStream file(data); - - - while (file.GetPos() < data.size()) { - - auto newDebugRecord = make_unique(file); - spellDebugRecords.emplace(newDebugRecord.get()->spellId, std::move(newDebugRecord)); - } - - } - catch (TempleException e){ - logger->info(e.what()); - } - -} - -void LegacySpellSystem::ResetDebugRecords() { - spellDebugRecords.clear(); -} - -struct JammedSpellData { - int durationRemaining; - int duration; -}; -std::map jsmap; -void LegacySpellSystem::JammedSpellsCreateRef() -{ - jsmap.clear(); +} + + +void LegacySpellSystem::SaveDebugRecords() const +{ + + //auto tioFile = tio_fopen("Save\\Current\\spell_debug_records.bin", "wb"); + VfsOutputStream tioFile("Save\\Current\\spell_debug_records.bin", "wb"); + { + + for (auto& it : spellDebugRecords) { + MemoryOutputStream buf; + auto spellId = it.first; + auto &spell = *it.second.get(); + + auto spellName = GetSpellName(spell.spellEnum); // for debug + + // Write Debugdata + { + spell.Write(buf); + } + auto &memBuf = buf.GetBuffer(); + + tioFile.WriteUInt32(memBuf.size()); + tioFile.WriteBytes(&memBuf[0], memBuf.size()); + } + + } + +} + +void LegacySpellSystem::LoadDebugRecords() { + spellDebugRecords.clear(); + + try { + auto data = vfs->ReadAsBinary("Save\\Current\\spell_debug_records.bin"); + MemoryInputStream file(data); + + + while (file.GetPos() < data.size()) { + + auto newDebugRecord = make_unique(file); + spellDebugRecords.emplace(newDebugRecord.get()->spellId, std::move(newDebugRecord)); + } + + } + catch (TempleException e){ + logger->info(e.what()); + } + +} + +void LegacySpellSystem::ResetDebugRecords() { + spellDebugRecords.clear(); +} + +struct JammedSpellData { + int durationRemaining; + int duration; +}; +std::map jsmap; +void LegacySpellSystem::JammedSpellsCreateRef() +{ + jsmap.clear(); for (auto it = spellsCastRegistry.begin(); it != spellsCastRegistry.end(); ++it ) { auto& node = *it; bool shouldPrune = false; - SpellPacketBody & pkt = node.data->spellPktBody; - if (node.data->isActive == 0) - continue; - if (!pkt.spellId || !pkt.spellEnum) - continue; - if (pkt.IsPermanent()) - continue; - if (pkt.duration < 0 || pkt.durationRemaining < 0) - continue; - jsmap[pkt.spellId] = {pkt.durationRemaining,pkt.duration }; - } -} - -void LegacySpellSystem::JammedSpellsPrune(int roundsAdvanced) -{ + SpellPacketBody & pkt = node.data->spellPktBody; + if (node.data->isActive == 0) + continue; + if (!pkt.spellId || !pkt.spellEnum) + continue; + if (pkt.IsPermanent()) + continue; + if (pkt.duration < 0 || pkt.durationRemaining < 0) + continue; + jsmap[pkt.spellId] = {pkt.durationRemaining,pkt.duration }; + } +} + +void LegacySpellSystem::JammedSpellsPrune(int roundsAdvanced) +{ for (auto it = spellsCastRegistry.begin(); it != spellsCastRegistry.end(); ++it ) - { + { auto& node = *it; bool shouldPrune = false; - auto& pkt = node.data->spellPktBody; - if (node.data->isActive == 0) - continue; - if (!pkt.spellId || !pkt.spellEnum) - continue; - if (jsmap.find(pkt.spellId) == jsmap.end()) - continue; - auto &js = jsmap[pkt.spellId]; - auto expectedDur = js.durationRemaining - roundsAdvanced; - if (expectedDur < 0 && pkt.durationRemaining >= 0) { - shouldPrune = true; - } - - if (shouldPrune) { - JammedSpellEnd(pkt.spellId); - } - } -} - -void LegacySpellSystem::JammedSpellEnd(int spellId) -{ - - SpellPacketBody pkt(spellId); - logger->info("Detected jammed spell ID {} ({} {}), removing", spellId, pkt.spellEnum, GetSpellName(pkt.spellEnum)); - - if (objSystem->IsValidHandle(pkt.caster)) { - d20Sys.d20SendSignal(pkt.caster, DK_SIG_Spell_End, spellId, 0); - - pkt.EndPartsysForTgtObj(pkt.caster); - - //pySpellIntegration.SpellSoundPlay(&pkt, SpellEvent::EndSpellCast); - pkt.RemoveObjFromTargetList(pkt.caster); - } - - - for (auto i = 0; i < pkt.targetCount; ++i) { - auto tgt = pkt.targetListHandles[i]; - if (!objSystem->IsValidHandle(tgt)) - continue; - d20Sys.d20SendSignal(tgt, DK_SIG_Spell_End, spellId, 0); - pkt.EndPartsysForTgtObj(tgt); - pkt.RemoveObjFromTargetList(tgt); - } - - - //SpellEnd(spellId, 1); - /* - Do not use SpellEnd - it could be referencing invalid handles and cause crashes (e.g. playing a sound on the caster, which could be invalid). - All it does in practice is invoke spell trigger for spell_end event(which is usually not very important... some Co8 scripts maybe) - and then calls SpellMarkInactive... so we'll do just that instead. - */ - SpellMarkInactive(spellId); - + auto& pkt = node.data->spellPktBody; + if (node.data->isActive == 0) + continue; + if (!pkt.spellId || !pkt.spellEnum) + continue; + if (jsmap.find(pkt.spellId) == jsmap.end()) + continue; + auto &js = jsmap[pkt.spellId]; + auto expectedDur = js.durationRemaining - roundsAdvanced; + if (expectedDur < 0 && pkt.durationRemaining >= 0) { + shouldPrune = true; + } + + if (shouldPrune) { + JammedSpellEnd(pkt.spellId); + } + } +} + +void LegacySpellSystem::JammedSpellEnd(int spellId) +{ + + SpellPacketBody pkt(spellId); + logger->info("Detected jammed spell ID {} ({} {}), removing", spellId, pkt.spellEnum, GetSpellName(pkt.spellEnum)); + + if (objSystem->IsValidHandle(pkt.caster)) { + d20Sys.d20SendSignal(pkt.caster, DK_SIG_Spell_End, spellId, 0); + + pkt.EndPartsysForTgtObj(pkt.caster); + + //pySpellIntegration.SpellSoundPlay(&pkt, SpellEvent::EndSpellCast); + pkt.RemoveObjFromTargetList(pkt.caster); + } + + + for (auto i = 0; i < pkt.targetCount; ++i) { + auto tgt = pkt.targetListHandles[i]; + if (!objSystem->IsValidHandle(tgt)) + continue; + d20Sys.d20SendSignal(tgt, DK_SIG_Spell_End, spellId, 0); + pkt.EndPartsysForTgtObj(tgt); + pkt.RemoveObjFromTargetList(tgt); + } + + + //SpellEnd(spellId, 1); + /* + Do not use SpellEnd - it could be referencing invalid handles and cause crashes (e.g. playing a sound on the caster, which could be invalid). + All it does in practice is invoke spell trigger for spell_end event(which is usually not very important... some Co8 scripts maybe) + and then calls SpellMarkInactive... so we'll do just that instead. + */ + SpellMarkInactive(spellId); + } SpellMapTransferInfo LegacySpellSystem::SaveSpellForTeleport(const SpellPacket& data) @@ -2179,14 +2179,14 @@ bool LegacySpellSystem::IsSpellActive(int spellid) { return spellPacket.isActive == 1; } return false; -} - -void LegacySpellSystem::DoForSpellsCastRegistry(std::function cb) -{ - for (auto it = spellsCastRegistry.begin(); it != spellsCastRegistry.end(); ++it) { - auto &pkt = *(*it).data; - cb(pkt); - } +} + +void LegacySpellSystem::DoForSpellsCastRegistry(std::function cb) +{ + for (auto it = spellsCastRegistry.begin(); it != spellsCastRegistry.end(); ++it) { + auto &pkt = *(*it).data; + cb(pkt); + } } CondStruct* LegacySpellSystem::GetCondFromSpellCondId(int id) { @@ -2264,9 +2264,9 @@ void LegacySpellSystem::SpellsCastReset(objHndl handle, Stat classEnum){ } } -/* 0x10075BC0 */ -void LegacySpellSystem::SpellKnownRemove(objHndl handle, SpellStoreData& spData) -{ +/* 0x10075BC0 */ +void LegacySpellSystem::SpellKnownRemove(objHndl handle, SpellStoreData& spData) +{ auto obj = objSystem->GetObject(handle); auto numKnown = obj->GetSpellArray(obj_f_critter_spells_known_idx).GetSize(); @@ -2281,9 +2281,9 @@ void LegacySpellSystem::SpellKnownRemove(objHndl handle, SpellStoreData& spData) spellSys.spellRemoveFromStorage(handle, obj_f_critter_spells_known_idx, &knSpell, 0); return; } - } -} - + } +} + /* 0x10075A10 */ void LegacySpellSystem::SpellMemorizedAdd(objHndl handle, int spellEnum, int spellClass, int spellLvl, int spellStoreData, int metaMagicData){ @@ -3037,44 +3037,44 @@ bool LegacySpellSystem::numSpellsMemorizedTooHigh(objHndl objHnd) return 1; } return 0; -} - -/* 0x101B5AD0 */ -bool LegacySpellSystem::SpellOpposesCritterAlignment(SpellStoreData& spData, objHndl handle) -{ - auto obj = objSystem->GetObject(handle); - if (!obj) return false; - if (!spData.spellEnum) return false; - - if (isDomainSpell(spData.classCode) || spellSys.GetCastingClass(spData.classCode) == stat_level_cleric) { - - SpellEntry spEntry(spData.spellEnum); - if (!spEntry.spellEnum) return false; - auto critterAlignment = obj->GetInt32(obj_f_critter_alignment); - auto alignmentChoice = obj->GetInt32(obj_f_critter_alignment_choice); - auto descriptor = spEntry.spellDescriptorBitmask; - - if ( - (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_EVIL) - && (critterAlignment & Alignment::ALIGNMENT_GOOD || alignmentChoice == 1) - || - (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_GOOD) - && (critterAlignment & Alignment::ALIGNMENT_EVIL || alignmentChoice == 2) - || - (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_LAWFUL) - && (critterAlignment & Alignment::ALIGNMENT_CHAOTIC ) - || - (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_CHAOTIC) - && (critterAlignment & Alignment::ALIGNMENT_LAWFUL) - ) - { - return true; - } - - - } - - return false; +} + +/* 0x101B5AD0 */ +bool LegacySpellSystem::SpellOpposesCritterAlignment(SpellStoreData& spData, objHndl handle) +{ + auto obj = objSystem->GetObject(handle); + if (!obj) return false; + if (!spData.spellEnum) return false; + + if (isDomainSpell(spData.classCode) || spellSys.GetCastingClass(spData.classCode) == stat_level_cleric) { + + SpellEntry spEntry(spData.spellEnum); + if (!spEntry.spellEnum) return false; + auto critterAlignment = obj->GetInt32(obj_f_critter_alignment); + auto alignmentChoice = obj->GetInt32(obj_f_critter_alignment_choice); + auto descriptor = spEntry.spellDescriptorBitmask; + + if ( + (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_EVIL) + && (critterAlignment & Alignment::ALIGNMENT_GOOD || alignmentChoice == 1) + || + (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_GOOD) + && (critterAlignment & Alignment::ALIGNMENT_EVIL || alignmentChoice == 2) + || + (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_LAWFUL) + && (critterAlignment & Alignment::ALIGNMENT_CHAOTIC ) + || + (descriptor & D20SpellDescriptors::D20SPELL_DESCRIPTOR_CHAOTIC) + && (critterAlignment & Alignment::ALIGNMENT_LAWFUL) + ) + { + return true; + } + + + } + + return false; } bool LegacySpellSystem::isDomainSpell(uint32_t spellClassCode){ @@ -3111,10 +3111,10 @@ int LegacySpellSystem::GetSpellSchool(int spellEnum){ return spEntry.spellSchoolEnum; } -bool LegacySpellSystem::IsMonsterSpell(int spellEnum) -{ +bool LegacySpellSystem::IsMonsterSpell(int spellEnum) +{ return spellEnum >= NORMAL_SPELL_RANGE - && spellEnum <= SPELL_LIKE_ABILITY_RANGE; + && spellEnum <= SPELL_LIKE_ABILITY_RANGE; } bool LegacySpellSystem::IsSpellLike(int spellEnum){ @@ -3138,14 +3138,14 @@ bool LegacySpellSystem::IsNewSlotDesignator(int spellEnum) } // Non-Core spells will be added in the expanded range -bool LegacySpellSystem::IsNonCore(int spellEnum) -{ +bool LegacySpellSystem::IsNonCore(int spellEnum) +{ auto spSource = mSpellSources.find(spellEnum); if (spSource != mSpellSources.end()) { return static_cast(spSource->second) > 255; } - return (spellEnum > SPELL_ENUM_MAX_VANILLA); + return (spellEnum > SPELL_ENUM_MAX_VANILLA); } bool LegacySpellSystem::IsSpellSourceEnabled(int spellEnum) @@ -3680,15 +3680,15 @@ int LegacySpellSystem::SpellEnd(int spellId, int endDespiteTargetList) const return 1; } -void LegacySpellSystem::SpellMarkInactive(int spellId) const -{ - SpellPacket pkt; +void LegacySpellSystem::SpellMarkInactive(int spellId) const +{ + SpellPacket pkt; if (!spellsCastRegistry.copy(spellId, &pkt)) { logger->debug("SpellMarkInactive: \t Couldn't find spell in registry. Spell id {}", spellId); return; - } + } pkt.isActive = 0; - spellsCastRegistry.put(spellId, pkt); + spellsCastRegistry.put(spellId, pkt); } #pragma endregion @@ -3785,22 +3785,22 @@ bool SpellPacketBody::EndPartsysForTgtObj(const objHndl& handle){ void SpellPacketBody::TriggerAoeHitScript(){ pySpellIntegration.SpellSoundPlay(this, SpellEvent::SpellStruck); pySpellIntegration.SpellTrigger(this->spellId, SpellEvent::AreaOfEffectHit); -} - -SpellComponentFlag SpellPacketBody::GetSpellComponentFlags() -{ - SpellEntry spEntry(spellEnum); - if (spEntry.spellEnum == 0) { - return SpellComponentFlag(); - } - auto result = spEntry.spellComponentBitmask; - MetaMagicData mmData(this->metaMagicData); - if (mmData.metaMagicFlags & MetaMagic_Still) - result &= ~(SpellComponentFlag::SpellComponent_Somatic); - if (mmData.metaMagicFlags & MetaMagic_Silent) - result &= ~(SpellComponentFlag::SpellComponent_Verbal); - - return (SpellComponentFlag)result; +} + +SpellComponentFlag SpellPacketBody::GetSpellComponentFlags() +{ + SpellEntry spEntry(spellEnum); + if (spEntry.spellEnum == 0) { + return SpellComponentFlag(); + } + auto result = spEntry.spellComponentBitmask; + MetaMagicData mmData(this->metaMagicData); + if (mmData.metaMagicFlags & MetaMagic_Still) + result &= ~(SpellComponentFlag::SpellComponent_Somatic); + if (mmData.metaMagicFlags & MetaMagic_Silent) + result &= ~(SpellComponentFlag::SpellComponent_Verbal); + + return (SpellComponentFlag)result; } uint32_t __cdecl _getWizSchool(objHndl objHnd) @@ -3848,35 +3848,35 @@ void _SetSpontaneousCastingAltNode(objHndl obj, int nodeIdx, SpellStoreData* spe spellSys.SetSpontaneousCastingAltNode(obj, nodeIdx, spellData); } #pragma endregion - -SpellDebugRecord::SpellDebugRecord(const SpellPacketBody& spellPkt) -{ - memcpy(this, &spellPkt, sizeof (SpellPacketBody) ); - castingTime = gameTimeSys.GetElapsed(); - casterDebug = SpellDebugObjInfo(this->caster); - castingMapId = gameSystems->GetMap().GetCurrentMapId(); - for (unsigned int i = 0; i < this->targetCount; ++i) { - targetListDebug.push_back(SpellDebugObjInfo(this->targetListHandles[i])); - } - -} - -bool SpellDebugRecord::Write(OutputStream& file) -{ - - // Write SpellPacketBody data - { - auto& spell = *this; - file.WriteInt32(spell.spellId); - - auto curSpellPkt = spellsCastRegistry.get(spellId); - if (curSpellPkt != nullptr) { // active spell - compare vs. record - auto& pkt = curSpellPkt->spellPktBody; - file.WriteInt32(curSpellPkt->isActive); - } - else { - file.WriteInt32(0); - } + +SpellDebugRecord::SpellDebugRecord(const SpellPacketBody& spellPkt) +{ + memcpy(this, &spellPkt, sizeof (SpellPacketBody) ); + castingTime = gameTimeSys.GetElapsed(); + casterDebug = SpellDebugObjInfo(this->caster); + castingMapId = gameSystems->GetMap().GetCurrentMapId(); + for (unsigned int i = 0; i < this->targetCount; ++i) { + targetListDebug.push_back(SpellDebugObjInfo(this->targetListHandles[i])); + } + +} + +bool SpellDebugRecord::Write(OutputStream& file) +{ + + // Write SpellPacketBody data + { + auto& spell = *this; + file.WriteInt32(spell.spellId); + + auto curSpellPkt = spellsCastRegistry.get(spellId); + if (curSpellPkt != nullptr) { // active spell - compare vs. record + auto& pkt = curSpellPkt->spellPktBody; + file.WriteInt32(curSpellPkt->isActive); + } + else { + file.WriteInt32(0); + } file.WriteInt32(spell.spellEnum); file.WriteInt32(spell.spellEnumOriginal); @@ -3916,32 +3916,32 @@ bool SpellDebugRecord::Write(OutputStream& file) file.WriteInt32(spell.spellRange); file.WriteInt32(spell.savingThrowResult); file.WriteInt32(spell.metaMagicData); - //file.WriteInt32(spell.spellId); - } - - file.WriteGameTime(castingTime); - file.WriteInt32(castingMapId); - - casterDebug.Write(file); - file.WriteInt32(targetListDebug.size()); - for (auto& it : targetListDebug) { - it.Write(file); - } - - file.WriteInt32(MAGIC_NUMBER); - - return true; -} - -SpellDebugRecord::SpellDebugRecord(InputStream& file) { - - auto size = file.ReadUInt32(); - - auto& spell = *this; - { - spell.spellId = file.ReadInt32(); - - auto isActive = file.ReadInt32(); + //file.WriteInt32(spell.spellId); + } + + file.WriteGameTime(castingTime); + file.WriteInt32(castingMapId); + + casterDebug.Write(file); + file.WriteInt32(targetListDebug.size()); + for (auto& it : targetListDebug) { + it.Write(file); + } + + file.WriteInt32(MAGIC_NUMBER); + + return true; +} + +SpellDebugRecord::SpellDebugRecord(InputStream& file) { + + auto size = file.ReadUInt32(); + + auto& spell = *this; + { + spell.spellId = file.ReadInt32(); + + auto isActive = file.ReadInt32(); spell.spellEnum = file.ReadInt32(); spell.spellEnumOriginal = file.ReadInt32(); @@ -3960,124 +3960,124 @@ SpellDebugRecord::SpellDebugRecord(InputStream& file) { spell.savingThrowResult = file.ReadInt32(); spell.metaMagicData = file.ReadInt32(); } - - file.ReadGameTime(castingTime); - castingMapId = file.ReadInt32(); - - casterDebug = SpellDebugObjInfo(file); - - auto targetListSize = file.ReadInt32(); - if (targetListSize > MAX_SPELL_TARGETS) { - throw TempleException("SpellDebugRecord: targetlist size exceeds limit ({})", MAX_SPELL_TARGETS); - } - - for (auto i = 0; i < targetListSize; ++i) { - targetListDebug.push_back(SpellDebugObjInfo(file)); - } - - auto magicNumber = file.ReadInt32(); - if (magicNumber != MAGIC_NUMBER) { - throw TempleException("SpellDebugRecord: targetlist size exceeds limit ({})", MAX_SPELL_TARGETS); - } - -} -//***************************************************************************** -//* SpellSystem -//***************************************************************************** - -bool SpellSystem::Save(TioFile* file) { - - logger->debug("Saving Spells: {} spells initially in SpellsCastRegistry.", spellSys.spellCastIdxTable->itemCount); - spellSys.SpellSavePruneInactive(); - logger->debug("Saving Spells: {} spells after pruning.", spellSys.spellCastIdxTable->itemCount); - - TioOutputStream tios(file); - - auto spellIdSerial = temple::GetPointer(0x10AAF204); - tios.WriteInt32(*spellIdSerial); - - int numSpells = spellSys.spellCastIdxTable->itemCount; - tios.WriteInt32(numSpells); - - auto numSerialized = spellSys.SpellSave(tios); - if (numSerialized != numSpells) - { - logger->error("Serialized wrong number of spells! SAVE IS CORRUPT! Serialized: {}, expected: {}", numSerialized, numSpells); - } - - spellSys.SaveDebugRecords(); - - return TRUE; -} - -SpellSystem::SpellSystem(const GameSystemConf& config) { - auto startup = temple::GetPointer(0x1007b740); - if (!startup(&config)) { - throw TempleException("Unable to initialize game system Spell"); - } - spellSys.Init(config); -} -SpellSystem::~SpellSystem() { - auto shutdown = temple::GetPointer(0x100791d0); - mesFuncs.Close(spellSys.spellEnumsExt); - mesFuncs.Close(spellSys.spellMesExt); - if (config.extendedSpellDescriptions) { - mesFuncs.Close(spellSys.spellMesLong); - } - shutdown(); - -} - -void SpellSystem::Reset() { - auto reset = temple::GetPointer(0x100750f0); - reset(); - - spellSys.ResetDebugRecords(); -} - -const std::string& SpellSystem::GetName() const { - static std::string name("Spell"); - return name; -} - -/* 0x100792A0 */ -bool SpellSystem::Load(GameSystemSaveFile* file) { - spellSys.LoadDebugRecords(); - - { - logger->info("Loading Spells: {} spells initially in SpellsCastRegistry.", spellSys.spellCastIdxTable->itemCount); - - auto spellIdSerial = temple::GetPointer(0x10AAF204); - tio_fread(spellIdSerial, sizeof(int), 1, file->file); - - uint32_t numSpells; - if (tio_fread(&numSpells, 4, 1, file->file) != 1) - return FALSE; - - Expects(numSpells >= 0); - Expects(*spellIdSerial >= numSpells); - - if (numSpells <= 0) - return TRUE; - - uint32_t spellId; - SpellPacket pkt; - - for (uint32_t i = 0; i < numSpells; i++) { - if (spellSys.LoadActiveSpellElement(file->file, spellId, pkt) != 1) { - logger->warn("Loading Spells: Failure! {} spells in SpellsCastRegistry after loading.", spellSys.spellCastIdxTable->itemCount); - return FALSE; - } - if (!(spellId <= *spellIdSerial)) { - logger->warn("Invalid spellId {} detected, greater than spellIdSerial!", spellId); - } - if (!pkt.spellPktBody.caster) { - logger->warn("Null caster object!", spellId); - } - spellSys.SpellsCastRegistryPut(spellId, pkt); - } - logger->info("Loading Spells: {} spells in SpellsCastRegistry after loading.", spellSys.spellCastIdxTable->itemCount); - } - - return TRUE; + + file.ReadGameTime(castingTime); + castingMapId = file.ReadInt32(); + + casterDebug = SpellDebugObjInfo(file); + + auto targetListSize = file.ReadInt32(); + if (targetListSize > MAX_SPELL_TARGETS) { + throw TempleException("SpellDebugRecord: targetlist size exceeds limit ({})", MAX_SPELL_TARGETS); + } + + for (auto i = 0; i < targetListSize; ++i) { + targetListDebug.push_back(SpellDebugObjInfo(file)); + } + + auto magicNumber = file.ReadInt32(); + if (magicNumber != MAGIC_NUMBER) { + throw TempleException("SpellDebugRecord: targetlist size exceeds limit ({})", MAX_SPELL_TARGETS); + } + +} +//***************************************************************************** +//* SpellSystem +//***************************************************************************** + +bool SpellSystem::Save(TioFile* file) { + + logger->debug("Saving Spells: {} spells initially in SpellsCastRegistry.", spellSys.spellCastIdxTable->itemCount); + spellSys.SpellSavePruneInactive(); + logger->debug("Saving Spells: {} spells after pruning.", spellSys.spellCastIdxTable->itemCount); + + TioOutputStream tios(file); + + auto spellIdSerial = temple::GetPointer(0x10AAF204); + tios.WriteInt32(*spellIdSerial); + + int numSpells = spellSys.spellCastIdxTable->itemCount; + tios.WriteInt32(numSpells); + + auto numSerialized = spellSys.SpellSave(tios); + if (numSerialized != numSpells) + { + logger->error("Serialized wrong number of spells! SAVE IS CORRUPT! Serialized: {}, expected: {}", numSerialized, numSpells); + } + + spellSys.SaveDebugRecords(); + + return TRUE; +} + +SpellSystem::SpellSystem(const GameSystemConf& config) { + auto startup = temple::GetPointer(0x1007b740); + if (!startup(&config)) { + throw TempleException("Unable to initialize game system Spell"); + } + spellSys.Init(config); +} +SpellSystem::~SpellSystem() { + auto shutdown = temple::GetPointer(0x100791d0); + mesFuncs.Close(spellSys.spellEnumsExt); + mesFuncs.Close(spellSys.spellMesExt); + if (config.extendedSpellDescriptions) { + mesFuncs.Close(spellSys.spellMesLong); + } + shutdown(); + +} + +void SpellSystem::Reset() { + auto reset = temple::GetPointer(0x100750f0); + reset(); + + spellSys.ResetDebugRecords(); +} + +const std::string& SpellSystem::GetName() const { + static std::string name("Spell"); + return name; +} + +/* 0x100792A0 */ +bool SpellSystem::Load(GameSystemSaveFile* file) { + spellSys.LoadDebugRecords(); + + { + logger->info("Loading Spells: {} spells initially in SpellsCastRegistry.", spellSys.spellCastIdxTable->itemCount); + + auto spellIdSerial = temple::GetPointer(0x10AAF204); + tio_fread(spellIdSerial, sizeof(int), 1, file->file); + + uint32_t numSpells; + if (tio_fread(&numSpells, 4, 1, file->file) != 1) + return FALSE; + + Expects(numSpells >= 0); + Expects(*spellIdSerial >= numSpells); + + if (numSpells <= 0) + return TRUE; + + uint32_t spellId; + SpellPacket pkt; + + for (uint32_t i = 0; i < numSpells; i++) { + if (spellSys.LoadActiveSpellElement(file->file, spellId, pkt) != 1) { + logger->warn("Loading Spells: Failure! {} spells in SpellsCastRegistry after loading.", spellSys.spellCastIdxTable->itemCount); + return FALSE; + } + if (!(spellId <= *spellIdSerial)) { + logger->warn("Invalid spellId {} detected, greater than spellIdSerial!", spellId); + } + if (!pkt.spellPktBody.caster) { + logger->warn("Null caster object!", spellId); + } + spellSys.SpellsCastRegistryPut(spellId, pkt); + } + logger->info("Loading Spells: {} spells in SpellsCastRegistry after loading.", spellSys.spellCastIdxTable->itemCount); + } + + return TRUE; } From 68a3183b43fc117b03823e2d9989c2783ef6d9c7 Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 17:43:40 +0100 Subject: [PATCH 06/12] gitignore: exclude local Divine O.pdf source material Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e2ed93109..adcd24d75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ TemplePlus.ini .claude/ +# Source reference material (copyrighted) kept locally, never committed +/Divine O.pdf + # C++ objects and libs /dist From 951452c7ead36d4136aa1783ed5c398f4620d3bf Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 17:46:47 +0100 Subject: [PATCH 07/12] install doc: drop 'friend' phrasing, address the reader directly Co-Authored-By: Claude Opus 4.7 (1M context) --- DIVINE_ORACLE_INSTALL.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DIVINE_ORACLE_INSTALL.txt b/DIVINE_ORACLE_INSTALL.txt index 383547855..bc76215eb 100644 --- a/DIVINE_ORACLE_INSTALL.txt +++ b/DIVINE_ORACLE_INSTALL.txt @@ -3,8 +3,8 @@ Divine Oracle Prestige Class — Install Instructions This mod adds the Divine Oracle prestige class (from D&D 3.5e Complete Divine) to TemplePlus. This version includes C++ engine changes for the Oracle domain -and Immune to Surprise, so it ships as a custom TemplePlus build — your -friend will replace TemplePlus.exe along with the data files. +and Immune to Surprise, so it ships as a custom TemplePlus build — you +will replace TemplePlus.exe along with the data files. Files to copy into your TemplePlus install @@ -103,8 +103,8 @@ Known limitations 4. A plain cleric who picks Oracle as a regular domain at level 1 will get the Oracle spell list in their domain slots but NOT the +2 caster level granted power -- that bonus is wired to the Divine Oracle class - feature, not to the domain itself. If your friend wants the granted - power, take the Divine Oracle prestige class. + feature, not to the domain itself. To get the granted power, take the + Divine Oracle prestige class. 5. Immune to Surprise gives the Divine Oracle 10+ character full normal actions during a surprise round (instead of strictly "a standard From 457d3a6d83c8e8c3746db94dc991ec6f78fcb9df Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 18:11:32 +0100 Subject: [PATCH 08/12] divine oracle: address code-review follow-ups (P1b/P2/P3 + doc) P2: require divine spellcasting in the Divine Oracle prereq. All spell progression binds to GetHighestDivineClass, so an arcane-only caster could previously qualify yet gain no caster-level advancement. Drops the now-redundant divination-spells-known scan (divine vancian casters satisfy the "2 divination spells" requirement implicitly). P1b: bounds-guard D20StatusSystem::initDomain. ConditionArrayDomains is the vanilla ToEE table (domains 0..Domain_Special); indexing it with a modded domain such as Domain_Oracle would read past the table. Skip domains >= Domain_Oracle (defense-in-depth; not currently reachable since no deity offers the Oracle domain). P3: ship DIVINE_ORACLE_INSTALL.txt in the release artifact via PackRelease.ps1 and TemplePlus.nuspec. Docs: correct install-doc limitation #4 -- a cleric cannot pick Oracle as a regular domain (no deity offers it), so the domain's only functional benefit is the +2 caster level granted power; the registered Oracle spell list is not selectable as daily domain spells. Co-Authored-By: Claude Opus 4.7 (1M context) --- DIVINE_ORACLE_INSTALL.txt | 27 ++++++++++++------- PackRelease.ps1 | 1 + TemplePlus.nuspec | 1 + TemplePlus/d20_status.cpp | 6 ++++- .../char_class/class050_divine_oracle.py | 22 ++++++--------- 5 files changed, 32 insertions(+), 25 deletions(-) diff --git a/DIVINE_ORACLE_INSTALL.txt b/DIVINE_ORACLE_INSTALL.txt index bc76215eb..83fde0c30 100644 --- a/DIVINE_ORACLE_INSTALL.txt +++ b/DIVINE_ORACLE_INSTALL.txt @@ -75,11 +75,13 @@ matching the Complete Divine source: 4th: Scrying 9th: Foresight 5th: Commune -These spell-to-domain registrations are added at engine startup. Some of -these spells (Augury, Commune, etc.) are stubs in the vanilla ToEE engine --- they exist as spell enums but don't do much mechanically. The Oracle -domain registers access to them per the rules; what each spell actually -does is a separate concern. +These spell-to-domain registrations are added at engine startup so the +engine recognizes the Oracle domain. Note, however, that this list is not +selectable as daily domain spells in normal play (see limitation 4 below); +the practical benefit of the Oracle domain is the +2 caster level granted +power. Some of these spells (Augury, Commune, etc.) are also stubs in the +vanilla ToEE engine -- they exist as spell enums but don't do much +mechanically. Known limitations @@ -100,11 +102,16 @@ Known limitations and patching the vanilla evasion implementation, which lives in ToEE.exe and isn't currently hooked by TemplePlus. -4. A plain cleric who picks Oracle as a regular domain at level 1 will - get the Oracle spell list in their domain slots but NOT the +2 caster - level granted power -- that bonus is wired to the Divine Oracle class - feature, not to the domain itself. To get the granted power, take the - Divine Oracle prestige class. +4. Of the Oracle Domain feature, only the granted power (+2 caster level on + Divination spells) is functional in-game -- it is wired to the Divine + Oracle class feature and works automatically. The other half of the + feature ("choose the spells in that domain as her daily domain spells") + is NOT available: the engine only grants domain spells when the domain is + stored in the character's cleric domain slots, and no deity offers the + Oracle domain in the domain picker, so it can never be selected there. + The Oracle spell-to-domain registration exists in the engine but is not + reachable through normal play; treat the domain as the source of the + +2 caster level granted power only. 5. Immune to Surprise gives the Divine Oracle 10+ character full normal actions during a surprise round (instead of strictly "a standard diff --git a/PackRelease.ps1 b/PackRelease.ps1 index c201f7f1c..8204a1db9 100644 --- a/PackRelease.ps1 +++ b/PackRelease.ps1 @@ -26,6 +26,7 @@ copy Release\ICSharpCode.SharpZipLib.dll dist copy Release\FontAwesome.WPF.dll dist copy Release\DeltaCompressionDotNet*.dll dist copy Release\TemplePlus.exe dist +copy DIVINE_ORACLE_INSTALL.txt dist copy -Recurse tpdata dist\tpdata copy -Recurse dependencies\python-lib dist\tpdata\python-lib copy "C:\Program Files (x86)\Windows Kits\10\Redist\D3D\x86\d3dcompiler_47.dll" dist diff --git a/TemplePlus.nuspec b/TemplePlus.nuspec index ca7ac3ba9..52053e862 100644 --- a/TemplePlus.nuspec +++ b/TemplePlus.nuspec @@ -23,6 +23,7 @@ + diff --git a/TemplePlus/d20_status.cpp b/TemplePlus/d20_status.cpp index ace711a41..d645eae02 100644 --- a/TemplePlus/d20_status.cpp +++ b/TemplePlus/d20_status.cpp @@ -214,7 +214,11 @@ void D20StatusSystem::initDomains(objHndl objHnd) void D20StatusSystem::initDomain(Dispatcher * dispatcher, uint32_t domain) { - if (domain) { + // ConditionArrayDomains is the vanilla ToEE table, populated only for the + // vanilla domains (0..Domain_Special). Modded domains such as Domain_Oracle + // have no entry there, so indexing with them would read past the table. + // They carry no vanilla domain condition anyway, so skip them. + if (domain && domain < Domain_Oracle) { CondStruct * condStructDomain = *(conds.ConditionArrayDomains + 3 * domain); uint32_t arg1 = *(conds.ConditionArrayDomainsArg1 + 3 * domain); uint32_t arg2 = *(conds.ConditionArrayDomainsArg2 + 3 * domain); diff --git a/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py index 6372d3585..9d94f0791 100644 --- a/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py +++ b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py @@ -83,20 +83,14 @@ def ObjMeetsPrereqs(obj): # Skill Focus (Knowledge) - ToEE only has the generic Skill Focus (Knowledge) feat if not obj.has_feat(feat_skill_focus_knowledge): return 0 - # Able to cast at least 2 divination spells (divine or arcane) - if obj.divine_spell_level_can_cast() < 1 and obj.arcane_spell_level_can_cast() < 1: - return 0 - divination_spells_known = 0 - for knSp in obj.spells_known: - if knSp.spell_level > 0: - spell_entry = tpdp.SpellEntry(knSp.spell_enum) - if spell_entry.spell_school_enum == Divination: - divination_spells_known += 1 - if divination_spells_known >= 2: - break - # Vancian divine casters (cleric/druid) prepare from full list, so granting - # them the qualification once they have access to 1st level divine spells. - if divination_spells_known < 2 and obj.divine_spell_level_can_cast() < 1: + # Able to cast at least 2 divination spells. The class advances DIVINE + # spellcasting (all progression hooks bind to GetHighestDivineClass), so we + # require divine casting here -- an arcane-only caster could otherwise meet + # the prereq but gain no spellcasting progression from the class. + # Vancian divine casters (cleric/druid) prepare from their full list, so + # they satisfy the "2 divination spells" requirement once they can cast + # 1st-level divine spells. + if obj.divine_spell_level_can_cast() < 1: return 0 return 1 From fe1b4cc37f58226fd9d529e5e53c78d184ee334f Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 18:24:21 +0100 Subject: [PATCH 09/12] divine oracle: enforce "2 divination spells" for spontaneous casters Follow-up to the P2 fix. Requiring only divine_spell_level_can_cast() left the "able to cast at least 2 divination spells" prerequisite unenforced for spontaneous divine casters (Favored Soul), which the install/help docs still state. (The pre-fix code had the same gap -- its divination-count gate was behind `divine_spell_level_can_cast() < 1`, always false for any divine caster -- so this was never a regression, but it is now fixed.) Vancian divine casters (cleric/druid) prepare from their full list and still qualify automatically. A spontaneous divine caster (Favored Soul, the only such class in the engine) must now actually know 2+ divination spells, matching RAW and the documented requirement. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../char_class/class050_divine_oracle.py | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py index 9d94f0791..949e39770 100644 --- a/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py +++ b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py @@ -83,15 +83,27 @@ def ObjMeetsPrereqs(obj): # Skill Focus (Knowledge) - ToEE only has the generic Skill Focus (Knowledge) feat if not obj.has_feat(feat_skill_focus_knowledge): return 0 - # Able to cast at least 2 divination spells. The class advances DIVINE - # spellcasting (all progression hooks bind to GetHighestDivineClass), so we - # require divine casting here -- an arcane-only caster could otherwise meet - # the prereq but gain no spellcasting progression from the class. - # Vancian divine casters (cleric/druid) prepare from their full list, so - # they satisfy the "2 divination spells" requirement once they can cast - # 1st-level divine spells. + # The class advances DIVINE spellcasting (all progression hooks bind to + # GetHighestDivineClass), so require divine casting -- an arcane-only caster + # could otherwise meet the prereq but gain no spellcasting progression. if obj.divine_spell_level_can_cast() < 1: return 0 + # Able to cast at least 2 divination spells. Vancian divine casters + # (cleric/druid) prepare from their full class list, which contains + # divinations, so they satisfy this automatically. Spontaneous divine + # casters only cast what they know, so they must actually know 2+ divination + # spells. Favored Soul is the only spontaneous divine class in the engine. + if char_class_utils.GetHighestDivineClass(obj) == stat_level_favored_soul: + divination_spells_known = 0 + for knSp in obj.spells_known: + if knSp.spell_level > 0: + spell_entry = tpdp.SpellEntry(knSp.spell_enum) + if spell_entry.spell_school_enum == Divination: + divination_spells_known += 1 + if divination_spells_known >= 2: + break + if divination_spells_known < 2: + return 0 return 1 From 4b62e446ea096ee8d1d09e3ce31abf6732d26d9b Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 18:38:21 +0100 Subject: [PATCH 10/12] divine oracle: count castable divinations for restricted-list classes Follow-up to fe1b4cc3. The previous fix auto-qualified every non-Favored -Soul divine caster on the assumption they prepare from a full list with 2+ divinations. That holds for cleric/druid but not for restricted-list divine vancian classes: e.g. Blackguard's explicit spell list contains only one divination (Blessed Aim), yet it would still qualify. Now the prereq counts the divination spells the character can actually cast: - cleric/druid: full divine list -> qualify outright (fast path, also covers multiclass); - Favored Soul (spontaneous): count divinations in spells_known; - paladin/ranger/blackguard (restricted vancian): count divinations in their available class list via char_editor.get_learnable_spells. Require at least two. Mirrors the get_learnable_spells + spell_school_enum filtering pattern already used by the warmage advanced-learning code. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../char_class/class050_divine_oracle.py | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py index 949e39770..ea9284be3 100644 --- a/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py +++ b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py @@ -86,24 +86,34 @@ def ObjMeetsPrereqs(obj): # The class advances DIVINE spellcasting (all progression hooks bind to # GetHighestDivineClass), so require divine casting -- an arcane-only caster # could otherwise meet the prereq but gain no spellcasting progression. - if obj.divine_spell_level_can_cast() < 1: + max_divine_lvl = obj.divine_spell_level_can_cast() + if max_divine_lvl < 1: + return 0 + # Able to cast at least 2 divination spells. Cleric and druid prepare from + # the full divine list, which always contains many divinations, so any + # character with cleric/druid casting qualifies outright (fast path that + # also covers the common multiclass case). + if obj.stat_level_get(stat_level_cleric) > 0 or obj.stat_level_get(stat_level_druid) > 0: + return 1 + # Other divine casters have restricted spell access, so count the divination + # spells they can actually cast: + # - spontaneous casters (Favored Soul) cast only what they know; + # - prepared casters with a limited list (paladin/ranger/blackguard) cast + # their whole available class list (e.g. blackguard has only one + # divination, so it does NOT qualify). + divine_class = char_class_utils.GetHighestDivineClass(obj) + if divine_class == stat_level_favored_soul: + castable_spells = obj.spells_known + else: + castable_spells = char_editor.get_learnable_spells(obj, divine_class, max_divine_lvl) + divination_spells = 0 + for sp in castable_spells: + if sp.spell_level > 0 and tpdp.SpellEntry(sp.spell_enum).spell_school_enum == Divination: + divination_spells += 1 + if divination_spells >= 2: + break + if divination_spells < 2: return 0 - # Able to cast at least 2 divination spells. Vancian divine casters - # (cleric/druid) prepare from their full class list, which contains - # divinations, so they satisfy this automatically. Spontaneous divine - # casters only cast what they know, so they must actually know 2+ divination - # spells. Favored Soul is the only spontaneous divine class in the engine. - if char_class_utils.GetHighestDivineClass(obj) == stat_level_favored_soul: - divination_spells_known = 0 - for knSp in obj.spells_known: - if knSp.spell_level > 0: - spell_entry = tpdp.SpellEntry(knSp.spell_enum) - if spell_entry.spell_school_enum == Divination: - divination_spells_known += 1 - if divination_spells_known >= 2: - break - if divination_spells_known < 2: - return 0 return 1 From 42d71daeb66c2b28a4c003b26d3d8aa3c5d7b487 Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 18:44:49 +0100 Subject: [PATCH 11/12] divine oracle: count divinations character-wide across divine classes Follow-up to 4b62e446. The previous check counted divinations only from GetHighestDivineClass, but "able to cast at least 2 divination spells" is a character-wide requirement. A multiclass caster (e.g. Favored Soul who knows 2 divinations but has more Blackguard levels) was returned the Blackguard list and false-rejected. Now iterate every divine casting class the character has and collect the distinct castable divinations across all of them, each class using its OWN max spell level (not the global divine max): - cleric/druid: full divine list -> qualify outright; - Favored Soul (spontaneous): divinations in spells_known; - paladin/ranger/blackguard (restricted vancian): divinations in their own available class list via get_learnable_spells. Qualify as soon as two distinct divinations are found. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../char_class/class050_divine_oracle.py | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py index ea9284be3..f884e86bd 100644 --- a/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py +++ b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py @@ -86,35 +86,39 @@ def ObjMeetsPrereqs(obj): # The class advances DIVINE spellcasting (all progression hooks bind to # GetHighestDivineClass), so require divine casting -- an arcane-only caster # could otherwise meet the prereq but gain no spellcasting progression. - max_divine_lvl = obj.divine_spell_level_can_cast() - if max_divine_lvl < 1: + if obj.divine_spell_level_can_cast() < 1: return 0 - # Able to cast at least 2 divination spells. Cleric and druid prepare from - # the full divine list, which always contains many divinations, so any - # character with cleric/druid casting qualifies outright (fast path that - # also covers the common multiclass case). - if obj.stat_level_get(stat_level_cleric) > 0 or obj.stat_level_get(stat_level_druid) > 0: - return 1 - # Other divine casters have restricted spell access, so count the divination - # spells they can actually cast: - # - spontaneous casters (Favored Soul) cast only what they know; - # - prepared casters with a limited list (paladin/ranger/blackguard) cast - # their whole available class list (e.g. blackguard has only one - # divination, so it does NOT qualify). - divine_class = char_class_utils.GetHighestDivineClass(obj) - if divine_class == stat_level_favored_soul: - castable_spells = obj.spells_known - else: - castable_spells = char_editor.get_learnable_spells(obj, divine_class, max_divine_lvl) - divination_spells = 0 - for sp in castable_spells: - if sp.spell_level > 0 and tpdp.SpellEntry(sp.spell_enum).spell_school_enum == Divination: - divination_spells += 1 - if divination_spells >= 2: - break - if divination_spells < 2: - return 0 - return 1 + # "Able to cast at least 2 divination spells." The requirement is character + # -wide, so count distinct castable divinations across ALL of the + # character's divine casting classes (a multiclass caster may satisfy it + # from any one of them). + divine_classes = (stat_level_cleric, stat_level_druid, stat_level_paladin, + stat_level_ranger, stat_level_blackguard, stat_level_favored_soul) + divinations = set() + for cls in divine_classes: + cls_lvl = obj.stat_level_get(cls) + if cls_lvl < 1: + continue + # Cleric/druid prepare from the full divine list, which always contains + # well over two divinations -- they qualify outright. + if cls == stat_level_cleric or cls == stat_level_druid: + return 1 + # Spontaneous casters (Favored Soul) cast only what they know; prepared + # casters with a limited list (paladin/ranger/blackguard) cast their + # whole available class list, each up to its OWN max spell level. + if cls == stat_level_favored_soul: + class_spells = obj.spells_known + else: + max_lvl = char_editor.get_max_spell_level(obj, cls, cls_lvl) + if max_lvl < 1: + continue + class_spells = char_editor.get_learnable_spells(obj, cls, max_lvl) + for sp in class_spells: + if sp.spell_level > 0 and tpdp.SpellEntry(sp.spell_enum).spell_school_enum == Divination: + divinations.add(sp.spell_enum) + if len(divinations) >= 2: + return 1 + return 0 # Levelup From bee570df475cdcc7dfbe0fde678a2d1cf2cb14d5 Mon Sep 17 00:00:00 2001 From: James Williams <29534093+williajm@users.noreply.github.com> Date: Sun, 24 May 2026 19:01:46 +0100 Subject: [PATCH 12/12] divine oracle: scope divination count to divine; honest domain text Follow-up review fixes: - Favored Soul branch counted obj.spells_known, which is all known spells regardless of class, so a Favored Soul/Sorcerer could satisfy the divination requirement with ARCANE known spells. Filter known spells by caster_class so only the character's divine (Favored Soul) divinations count, consistent with the class's divine-only scope. - Cleric/druid fast-path now requires get_max_spell_level >= 1, so a divine class that cannot actually cast (e.g. too-low casting stat) no longer auto-qualifies off another class's casting ability. - Oracle Domain feat/help text claimed the character "can choose the spells in that domain as her daily domain spells", which is not supported in-engine (only the +2 caster level granted power works). Reworded the in-game feat and help text to match the install doc. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mes/help/divine_oracle_help.tab | 2 +- .../char_class/class050_divine_oracle.py | 23 +++++++++++-------- .../feats/divine oracle oracle domain.txt | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/tpdatasrc/tpgamefiles/mes/help/divine_oracle_help.tab b/tpdatasrc/tpgamefiles/mes/help/divine_oracle_help.tab index 9668f8e16..e1d3ec3f5 100644 --- a/tpdatasrc/tpgamefiles/mes/help/divine_oracle_help.tab +++ b/tpdatasrc/tpgamefiles/mes/help/divine_oracle_help.tab @@ -2,7 +2,7 @@ TAG_DIVINE_ORACLES TAG_PRESTIGE_CLASSES Divine Oracles All divine oracles are TAG_CLASS_FEATURES_DIVINE_ORACLE_TABLES TAG_DIVINE_ORACLES Divine Oracle Base Attack & Base Save Bonuses Level ~BAB~[TAG_LEVEL_BONUSES] ~Fort.~[TAG_FORTITUDE] ~Reflex~[TAG_REFLEX] ~Will~[TAG_WILL] 1 +0 +0 +0 +2 2 +1 +0 +0 +3 3 +1 +1 +1 +3 4 +2 +1 +1 +4 5 +2 +1 +1 +4 6 +3 +2 +2 +5 7 +3 +2 +2 +5 8 +4 +2 +2 +6 9 +4 +3 +3 +6 10 +5 +3 +3 +7 TAG_CLASS_FEATURES_DIVINE_ORACLE_SPELLS_PER_DAY TAG_DIVINE_ORACLES Divine Oracle Spells per Day/Spells Known A divine oracle continues advancing in spellcasting ability as well as gaining the abilities of her new class. Thus, when a new divine oracle level is gained, the character gains new spells per day (and spells known, if applicable) as if she had also gained a level in whatever spellcasting class she belonged to before she added the prestige class level. She does not, however, gain any other benefit a character of that class would have gained. TAG_CLASS_FEATURES_DIVINE_ORACLE_GRANTED_PROFICIENCIES TAG_DIVINE_ORACLES Divine Oracle Weapon and Armor Proficiency Divine oracles gain no weapon or armor proficiencies. -TAG_CLASS_FEATURES_DIVINE_ORACLE_ORACLE_DOMAIN TAG_DIVINE_ORACLES Divine Oracle Oracle Domain Upon adopting the divine oracle class, the character gains access to the Oracle domain. The character gains the granted power associated with the domain (cast divination spells at +2 caster level), and can choose the spells in that domain as her daily domain spells. +TAG_CLASS_FEATURES_DIVINE_ORACLE_ORACLE_DOMAIN TAG_DIVINE_ORACLES Divine Oracle Oracle Domain Upon adopting the divine oracle class, the character gains the Oracle domain's granted power: she casts divination spells at +2 caster level. (Choosing Oracle-domain spells as daily domain spells is not available in this implementation.) TAG_CLASS_FEATURES_DIVINE_ORACLE_SCRY_BONUS TAG_DIVINE_ORACLES Divine Oracle Scry Bonus A divine oracle adds a +1 sacred bonus to the save DC of all her divination (scrying) spells. TAG_CLASS_FEATURES_DIVINE_ORACLE_PRESCIENT_SENSE TAG_DIVINE_ORACLES Divine Oracle Prescient Sense Beginning at 2nd level, if a divine oracle makes a successful Reflex saving throw against an attack that normally inflicts half damage on a successful save, she instead takes no damage. This form of evasion works no matter what armor the divine oracle wears. TAG_CLASS_FEATURES_DIVINE_ORACLE_DIVINATION_ENHANCEMENT TAG_DIVINE_ORACLES Divine Oracle Divination Enhancement Beginning at 3rd level, a divine oracle may roll twice and take the better result when using divination spells such as augury or divination. diff --git a/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py index f884e86bd..04bd0120c 100644 --- a/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py +++ b/tpdatasrc/tpgamefiles/rules/char_class/class050_divine_oracle.py @@ -89,9 +89,9 @@ def ObjMeetsPrereqs(obj): if obj.divine_spell_level_can_cast() < 1: return 0 # "Able to cast at least 2 divination spells." The requirement is character - # -wide, so count distinct castable divinations across ALL of the - # character's divine casting classes (a multiclass caster may satisfy it - # from any one of them). + # -wide, so count distinct DIVINE divinations the character can actually + # cast across ALL of their divine casting classes (a multiclass caster may + # satisfy it from any one of them). divine_classes = (stat_level_cleric, stat_level_druid, stat_level_paladin, stat_level_ranger, stat_level_blackguard, stat_level_favored_soul) divinations = set() @@ -99,19 +99,22 @@ def ObjMeetsPrereqs(obj): cls_lvl = obj.stat_level_get(cls) if cls_lvl < 1: continue + # A class only counts if it can actually cast spells at this level (e.g. + # a too-low casting stat, or a non-casting paladin/ranger level, yields + # no castable spells). + max_lvl = char_editor.get_max_spell_level(obj, cls, cls_lvl) + if max_lvl < 1: + continue # Cleric/druid prepare from the full divine list, which always contains # well over two divinations -- they qualify outright. if cls == stat_level_cleric or cls == stat_level_druid: return 1 - # Spontaneous casters (Favored Soul) cast only what they know; prepared - # casters with a limited list (paladin/ranger/blackguard) cast their - # whole available class list, each up to its OWN max spell level. + # Spontaneous casters (Favored Soul) cast only what they KNOW for this + # class; prepared limited-list casters (paladin/ranger/blackguard) cast + # their whole available class list. if cls == stat_level_favored_soul: - class_spells = obj.spells_known + class_spells = [sp for sp in obj.spells_known if sp.caster_class == cls] else: - max_lvl = char_editor.get_max_spell_level(obj, cls, cls_lvl) - if max_lvl < 1: - continue class_spells = char_editor.get_learnable_spells(obj, cls, max_lvl) for sp in class_spells: if sp.spell_level > 0 and tpdp.SpellEntry(sp.spell_enum).spell_school_enum == Divination: diff --git a/tpdatasrc/tpgamefiles/rules/feats/divine oracle oracle domain.txt b/tpdatasrc/tpgamefiles/rules/feats/divine oracle oracle domain.txt index 3a4ef3321..22c9758e6 100644 --- a/tpdatasrc/tpgamefiles/rules/feats/divine oracle oracle domain.txt +++ b/tpdatasrc/tpgamefiles/rules/feats/divine oracle oracle domain.txt @@ -1,5 +1,5 @@ name: Divine Oracle Oracle Domain flags: 8 prereqs: -description: Upon adopting the divine oracle class, the character gains access to the Oracle domain. The character gains the granted power associated with the domain (cast divination spells at +2 caster level), and can choose the spells in that domain as her daily domain spells. +description: Upon adopting the divine oracle class, the character gains the Oracle domain's granted power: she casts divination spells at +2 caster level. (Choosing Oracle-domain spells as daily domain spells is not available in this implementation.) prereq descr: Divine Oracle level 1.