diff --git a/EEex/copy/EEex_Fix.lua b/EEex/copy/EEex_Fix.lua index e424f12..f59e782 100644 --- a/EEex/copy/EEex_Fix.lua +++ b/EEex/copy/EEex_Fix.lua @@ -40,6 +40,241 @@ function EEex_Fix_Hook_OnSpellOrSpellPointStartedCastingGlow(sprite) EEex_GetUDAux(sprite)["EEex_Fix_HasSpellOrSpellPointStartedCasting"] = 1 end +----------------------------------------------------------------------------------------------------- +-- Fix SPLPROT.2DA stat comparisons not respecting signed stat storage (e.g. negative resistances) -- +----------------------------------------------------------------------------------------------------- + +-- The patch-side assembly receives the raw stat id as an unsigned 16-bit value. +-- A 64 KiB byte map lets the hook answer "is this stat stored as signed?" with +-- one indexed load and no Lua / table lookup inside the hot compare path. +-- 0x10000 bytes = one byte for every possible 16-bit stat id value. +EEex_Fix_Private_SignedSplprotStatBitmap = EEex_Malloc(0x10000) +-- Default every entry to 0 ("not known to require signed relational compares"). +-- This initial clear is a defensive safe default for freshly allocated native memory, +-- before the game-state initialization listener has had a chance to populate the bitmap. +-- Initialization below flips only the confirmed signed stat ids to 1. +EEex_Memset(EEex_Fix_Private_SignedSplprotStatBitmap, 0, 0x10000) + +-- Source of truth for signedness: +-- * CDerivedStats::GetAtOffset() in the game executable +-- * CDerivedStatsTemplate in the matching PDB +-- +-- The generated manifest contains the vanilla stat ids whose engine-native storage is signed. +-- +-- Variants: BGEE, BG2EE, IWDEE + +-- NOTE: +-- This list is generated from EXE/PDB analysis. It is embedded here so the +-- runtime hook can stay data-only: the hook just consults the bitmap and does +-- not need to know anything about engine field names or IDS labels. +EEex_Fix_Private_SignedSplprotStatIDs = { + 1, -- m_nMaxHitPoints + 2, -- m_nArmorClass + 3, -- m_nACCrushingMod + 4, -- m_nACMissileMod + 5, -- m_nACPiercingMod + 6, -- m_nACSlashingMod + 7, -- m_nTHAC0 + 8, -- m_nNumberOfAttacks + 9, -- m_nSaveVSDeath + 10, -- m_nSaveVSWands + 11, -- m_nSaveVSPoly + 12, -- m_nSaveVSBreath + 13, -- m_nSaveVSSpell + 14, -- m_nResistFire + 15, -- m_nResistCold + 16, -- m_nResistElectricity + 17, -- m_nResistAcid + 18, -- m_nResistMagic + 19, -- m_nResistMagicFire + 20, -- m_nResistMagicCold + 21, -- m_nResistSlashing + 22, -- m_nResistCrushing + 23, -- m_nResistPiercing + 24, -- m_nResistMissile + 25, -- m_nLore + 26, -- m_nLockPicking + 27, -- m_nMoveSilently + 28, -- m_nTraps + 29, -- m_nPickPocket + 30, -- m_nFatigue + 31, -- m_nIntoxication + 32, -- m_nLuck + 33, -- m_nTracking + 35, -- m_nSex + 36, -- m_nSTR + 37, -- m_nSTRExtra + 38, -- m_nINT + 39, -- m_nWIS + 40, -- m_nDEX + 41, -- m_nCON + 42, -- m_nCHR + 48, -- m_nReputation + 49, -- m_nHatedRace + 50, -- m_nDamageBonus + 51, -- m_nSpellFailureMage + 52, -- m_nSpellFailurePriest + 53, -- m_nSpellDurationModMage + 54, -- m_nSpellDurationModPriest + 55, -- m_nTurnUndeadLevel + 56, -- m_nBackstabDamageMultiplier + 57, -- m_nLayOnHandsAmount + 58, -- m_bHeld + 59, -- m_bPolymorphed + 60, -- m_nTranslucent + 61, -- m_bIdentifyMode + 62, -- m_bEntangle + 63, -- m_bSanctuary + 64, -- m_bMinorGlobe + 65, -- m_bShieldGlobe + 66, -- m_bGrease + 67, -- m_bWeb + 70, -- m_bCasterHold + 71, -- m_nEncumberance + 72, -- m_nMissileTHAC0Bonus + 73, -- m_nMagicDamageResistance + 74, -- m_nResistPoison + 75, -- m_bDoNotJump + 76, -- m_bAuraCleansing + 77, -- m_nMentalSpeed + 78, -- m_nPhysicalSpeed + 79, -- m_nCastingLevelBonusMage + 80, -- m_nCastingLevelBonusCleric + 81, -- m_bSeeInvisible + 82, -- m_bIgnoreDialogPause + 83, -- m_nMinHitPoints + 84, -- m_THAC0BonusRight + 85, -- m_THAC0BonusLeft + 86, -- m_DamageBonusRight + 87, -- m_DamageBonusLeft + 88, -- m_nStoneSkins + 89, -- m_nProficiencyBastardSword + 90, -- m_nProficiencyLongSword + 91, -- m_nProficiencyShortSword + 92, -- m_nProficiencyAxe + 93, -- m_nProficiencyTwoHandedSword + 94, -- m_nProficiencyKatana + 95, -- m_nProficiencyScimitarWakisashiNinjaTo + 96, -- m_nProficiencyDagger + 97, -- m_nProficiencyWarhammer + 98, -- m_nProficiencySpear + 99, -- m_nProficiencyHalberd + 100, -- m_nProficiencyFlailMorningStar + 101, -- m_nProficiencyMace + 102, -- m_nProficiencyQuarterStaff + 103, -- m_nProficiencyCrossbow + 104, -- m_nProficiencyLongBow + 105, -- m_nProficiencyShortBow + 106, -- m_nProficiencyDart + 107, -- m_nProficiencySling + 108, -- m_nProficiencyBlackjack + 109, -- m_nProficiencyGun + 110, -- m_nProficiencyMartialArts + 111, -- m_nProficiency2Handed + 112, -- m_nProficiencySwordAndShield + 113, -- m_nProficiencySingleWeapon + 114, -- m_nProficiency2Weapon + 115, -- m_nProficiencyClub + 116, -- m_nExtraProficiency2 + 117, -- m_nExtraProficiency3 + 118, -- m_nExtraProficiency4 + 119, -- m_nExtraProficiency5 + 120, -- m_nExtraProficiency6 + 121, -- m_nExtraProficiency7 + 122, -- m_nExtraProficiency8 + 123, -- m_nExtraProficiency9 + 124, -- m_nExtraProficiency10 + 125, -- m_nExtraProficiency11 + 126, -- m_nExtraProficiency12 + 127, -- m_nExtraProficiency13 + 128, -- m_nExtraProficiency14 + 129, -- m_nExtraProficiency15 + 130, -- m_nExtraProficiency16 + 131, -- m_nExtraProficiency17 + 132, -- m_nExtraProficiency18 + 133, -- m_nExtraProficiency19 + 134, -- m_nExtraProficiency20 + 135, -- m_nHideInShadows + 136, -- m_nDetectIllusion + 137, -- m_nSetTraps + 138, -- m_nPuppetMasterId + 139, -- m_nPuppetMasterType + 140, -- m_nPuppetType + 141, -- m_nPuppetId + 142, -- m_bCheckForBerserk + 143, -- m_bBerserkStage1 + 144, -- m_bBerserkStage2 + 145, -- m_nDamageLuck + 147, -- m_nVisualRange + 148, -- m_bExplore + 149, -- m_bThrullCharm + 150, -- m_bSummonDisable + 151, -- m_nHitBonus + 153, -- m_bForceSurge + 154, -- m_nSurgeMod + 155, -- m_bImprovedHaste + 166, -- m_nMeleeTHAC0Bonus + 167, -- m_nMeleeDamageBonus + 168, -- m_nMissileDamageBonus + 169, -- m_bDisableCircle + 170, -- m_nFistTHAC0Bonus + 171, -- m_nFistDamageBonus + 174, -- m_bPreventSpellProtectionEffects + 175, -- m_bImmunityToBackStab + 176, -- m_nLockPickingMTPBonus + 177, -- m_nMoveSilentlyMTPBonus + 178, -- m_nTrapsMTPBonus + 179, -- m_nPickPocketMTPBonus + 180, -- m_nHideInShadowsMTPBonus + 181, -- m_nDetectIllusionMTPBonus + 182, -- m_nSetTrapsMTPBonus + 183, -- m_bPreventAISlowDown + 184, -- m_nExistanceDelayOverride + 185, -- m_bAnimationOnlyHaste + 186, -- m_bNoPermanentDeath + 187, -- m_bImmuneToTurnUndead + 188, -- m_bSummonDisableAction + 189, -- m_nChaosShield + 190, -- m_bNPCBump + 191, -- m_bUseAnyItem + 192, -- m_nAssassinate + 193, -- m_bSexChanged + 194, -- m_nSpellFailureInnate + 195, -- m_bImmuneToTracking + 196, -- m_bDeadMagic + 197, -- m_bImmuneToTimeStop + 198, -- m_bImmuneToSequester + 199, -- m_nStoneSkinsGolem + 200, -- m_nLevelDrain + 201, -- m_bDoNotDraw + 202, -- m_bIgnoreDrainDeath +} + +-- Special cases intentionally excluded from EEex_Fix_Private_SignedSplprotStatIDs: +-- Stat id 146: GetAtOffset() does not resolve this case to a single CDerivedStatsTemplate field load. +-- m_nSpellDurationModBard: signed CDerivedStatsTemplate field, but no vanilla GetAtOffset() case returns it. +-- m_nClassTypeOverrideMixed: signed CDerivedStatsTemplate field, but no vanilla GetAtOffset() case returns it. +-- m_nClassTypeOverrideLower: signed CDerivedStatsTemplate field, but no vanilla GetAtOffset() case returns it. + +local function EEex_Fix_Private_InitializeSignedSplprotStatBitmap() + -- Rebuild from the manifest each time the game state initializes so the + -- bitmap always reflects the authoritative signed-id list for this build. + -- This clear is still required even though the buffer was zeroed at allocation time: + -- reinitialization only writes 1-bits for signed ids, so stale entries must be cleared first. + EEex_Memset(EEex_Fix_Private_SignedSplprotStatBitmap, 0, 0x10000) + + for _, statID in ipairs(EEex_Fix_Private_SignedSplprotStatIDs) do + -- Presence means "treat relational SPLPROT compares for this stat as signed". + EEex_Write8(EEex_Fix_Private_SignedSplprotStatBitmap + statID, 1) + end +end + +EEex_GameState_AddInitializedListener(function() + -- The patch code reads this bitmap from native memory, so populate it only + -- once EEex runtime state is fully available for the current game session. + EEex_Fix_Private_InitializeSignedSplprotStatBitmap() +end) + -------------------------------------------- -- Fix Baldur.lua values not escaping '\' -- -------------------------------------------- diff --git a/EEex/copy/EEex_Fix_Patch.lua b/EEex/copy/EEex_Fix_Patch.lua index 5e44e6b..8bcedca 100644 --- a/EEex/copy/EEex_Fix_Patch.lua +++ b/EEex/copy/EEex_Fix_Patch.lua @@ -166,6 +166,99 @@ ) end + --[[ + +--------------------------------------------------------------------------------------------------------------------+ + | Fix SPLPROT.2DA relational stat comparisons treating signed stats as unsigned | + +--------------------------------------------------------------------------------------------------------------------+ + | [JIT] CRuleTables::IsProtectedFromSpell() | + | Only relations <=, ==, <, >, >=, != are re-evaluated here. Bitwise relations retain the engine's behavior. | + +--------------------------------------------------------------------------------------------------------------------+ + | Why hook with EEex_HookBeforeCallWithLabels(): | + | This site is the call from IsProtectedFromSpell() into CRuleTables::Compare(). At this exact point the caller | + | has already fetched the stat value, loaded the compare constant, decoded the relation, and still has the stat id | + | live in a register. That gives us the narrowest possible interception point: | + | * #L(return) -> let the original Compare() call run unchanged | + | * #L(return_skip) -> skip the call and continue as if Compare() had returned our replacement result | + | Hooking earlier would require reimplementing more of IsProtectedFromSpell(); hooking after the call would mean | + | the engine has already performed the wrong unsigned comparison. | + +--------------------------------------------------------------------------------------------------------------------+ + --]] + + -- Register state at the Compare() call site: + -- edx = stat value read from CDerivedStats::GetAtOffset() + -- r8d = SPLPROT compare constant + -- r9d = relation opcode that Compare() would evaluate + -- r13w = stat id, still available from the surrounding IsProtectedFromSpell() loop + -- + -- We only need one scratch register (r11) to index the signed-stat bitmap, so + -- the watchdog is told to ignore that register for this hook. + EEex_HookBeforeCallWithLabels(EEex_Label("Hook-CRuleTables::IsProtectedFromSpell()-CompareStatCall"), { + {"hook_integrity_watchdog_ignore_registers", {EEex_HookIntegrityWatchdogRegister.R11}}}, + {[[ + ; If this stat is not marked as signed, preserve the engine's original Compare() call. + mov rax, #$(1) ]], {EEex_Fix_Private_SignedSplprotStatBitmap}, [[ #ENDL + movzx r11d, r13w + cmp byte ptr ds:[rax+r11], 0 + jz #L(return) + + ; Compare() also supports non-relational operations. This fix only replaces the + ; six relational operators whose signedness is wrong; everything else stays native. + cmp r9d, 5 + ja #L(return) + + ; Re-evaluate the relation with signed setcc variants using the exact operands the + ; engine was about to pass into Compare(): edx (lhs stat value) vs r8d (rhs constant). + test r9d, r9d + je compare_le + cmp r9d, 1 + je compare_eq + cmp r9d, 2 + je compare_lt + cmp r9d, 3 + je compare_gt + cmp r9d, 4 + je compare_ge + cmp r9d, 5 + je compare_ne + jmp #L(return) + + compare_le: + cmp edx, r8d + setle al + jmp finish_compare + + compare_eq: + cmp edx, r8d + sete al + jmp finish_compare + + compare_lt: + cmp edx, r8d + setl al + jmp finish_compare + + compare_gt: + cmp edx, r8d + setg al + jmp finish_compare + + compare_ge: + cmp edx, r8d + setge al + jmp finish_compare + + compare_ne: + cmp edx, r8d + setne al + + finish_compare: + ; Compare() returns a boolean-like integer in eax. Materialize the same shape and + ; skip the original call, resuming execution immediately after it. + movzx eax, al + jmp #L(return_skip) + ]]} + ) + --[[ +----------------------------------------------------------------------------------------------------------------+ | [JIT] Opcode #182 should consider -1 (instead of 0) the fail return value from CGameSprite::FindItemPersonal() | diff --git a/EEex/copy/EEex_Resource.lua b/EEex/copy/EEex_Resource.lua index c57699c..ebefe22 100644 --- a/EEex/copy/EEex_Resource.lua +++ b/EEex/copy/EEex_Resource.lua @@ -951,6 +951,13 @@ function EEex_Resource_KitSymbolToIDS(kitSymbol) end EEex_Resource_Private_KitIgnoresMeleeingWithRangedPenaltyForItemCategory = {} +EEex_Resource_Private_IWDStrengthEnabled = false +EEex_Resource_Private_IWDStrengthExceptionalThresholds = {} +EEex_Resource_Private_IWDStrengthMinValue = 0 +EEex_Resource_Private_IWDStrengthMaxValue = 0 +EEex_Resource_Private_IWDStrengthMaxExceptional = 0 +EEex_Resource_Private_IWDStrengthMinRank = 0 +EEex_Resource_Private_IWDStrengthMaxRank = 0 EEex_GameState_AddInitializedListener(function() @@ -998,6 +1005,159 @@ EEex_GameState_AddInitializedListener(function() end) end) + ------------------ + -- X-IWDSTR.2DA -- + ------------------ + + EEex_Utility_NewScope(function() + + -- Default to the vanilla engine path unless the tweak is explicitly enabled and the + -- supporting rule tables validate cleanly. All of the derived lookup state below is + -- rebuilt from STRMOD / STRMODEX at startup so nothing in the runtime logic needs to + -- hardcode a particular exceptional ladder. + EEex_Resource_Private_IWDStrengthEnabled = false + EEex_Resource_Private_IWDStrengthExceptionalThresholds = {} + EEex_Resource_Private_IWDStrengthMinValue = 0 + EEex_Resource_Private_IWDStrengthMaxValue = 0 + EEex_Resource_Private_IWDStrengthMaxExceptional = 0 + EEex_Resource_Private_IWDStrengthMinRank = 0 + EEex_Resource_Private_IWDStrengthMaxRank = 0 + + local config = EEex_Resource_Load2DA("X-IWDSTR") + local valueColumn = config:findColumnLabel("VALUE") + local checkModeRow = config:findRowLabel("CHECK_MODE") + + if valueColumn < 0 then + EEex_Error("X-IWDSTR.2DA is missing the VALUE column") + end + + if checkModeRow < 0 then + EEex_Error("X-IWDSTR.2DA is missing the CHECK_MODE row") + end + + local checkMode = config:getAtPoint(valueColumn, checkModeRow) + if checkMode ~= "0" and checkMode ~= "1" then + EEex_Error("X-IWDSTR.2DA CHECK_MODE VALUE must be 0 or 1") + end + + if checkMode == "0" then + return + end + + local function parseContiguousIntegerRows(data, name) + + -- The tweak models strength as a single expanded rank. That is only sound if the + -- source table rows form a contiguous integer domain, so reject sparse / non-integer + -- row labels up front instead of trying to interpret them later. + local _, lastRowIndex = data:getMaxIndices() + if lastRowIndex < 0 then + EEex_Error(name.." has no rows") + end + + local rows = {} + local previousLabel = nil + + for rowIndex = 0, lastRowIndex do + + local rowLabel = data:getRowLabel(rowIndex) + local numericLabel = tonumber(rowLabel, 10) + + if numericLabel == nil or numericLabel ~= math.floor(numericLabel) then + EEex_Error(name.." row label '"..rowLabel.."' is not an integer") + end + + if previousLabel ~= nil and numericLabel ~= previousLabel + 1 then + EEex_Error(name.." row labels must be contiguous ascending integers") + end + + rows[#rows + 1] = { + ["index"] = rowIndex, + ["label"] = numericLabel, + } + + previousLabel = numericLabel + end + + return rows + end + + local strmod = EEex_Resource_Load2DA("STRMOD") + local strmodRows = parseContiguousIntegerRows(strmod, "STRMOD.2DA") + local minStrength = strmodRows[1].label + local maxStrength = strmodRows[#strmodRows].label + + -- Exceptional tiers are inserted between the integer 18 and 19 ranks, so the integer + -- table must at least span those two values for the expanded mapping to be valid. + if minStrength > 18 or maxStrength < 19 then + EEex_Error("STRMOD.2DA must include integer strength rows 18 and 19") + end + + local strmodex = EEex_Resource_Load2DA("STRMODEX") + local toHitColumn = strmodex:findColumnLabel("TO_HIT") + local damageColumn = strmodex:findColumnLabel("DAMAGE") + + if toHitColumn < 0 then + EEex_Error("STRMODEX.2DA is missing the TO_HIT column") + end + + if damageColumn < 0 then + EEex_Error("STRMODEX.2DA is missing the DAMAGE column") + end + + local strmodexRows = parseContiguousIntegerRows(strmodex, "STRMODEX.2DA") + if strmodexRows[1].label ~= 0 then + EEex_Error("STRMODEX.2DA must start at row 0") + end + + local thresholds = {} + local seenThresholds = {} + local addThreshold = function(label) + if not seenThresholds[label] then + seenThresholds[label] = true + thresholds[#thresholds + 1] = label + end + end + local previousToHit = nil + local previousDamage = nil + + -- The first exceptional tier begins at the second STRMODEX row label (for vanilla data: + -- 18/01). That boundary exists even when TO_HIT / DAMAGE still match the row before it, + -- so seed it explicitly before scanning for later stat breakpoints. + if #strmodexRows >= 2 then + addThreshold(strmodexRows[2].label) + end + + for _, row in ipairs(strmodexRows) do + + local toHit = strmodex:getAtPoint(toHitColumn, row.index) + local damage = strmodex:getAtPoint(damageColumn, row.index) + + if previousToHit ~= nil and (toHit ~= previousToHit or damage ~= previousDamage) then + addThreshold(row.label) + end + + previousToHit = toHit + previousDamage = damage + end + + -- `thresholds` now contains the exceptional row labels where the "combat tier" changes. + -- Runtime code treats those labels as the inserted ranks between integer 18 and 19. + if #thresholds == 0 then + EEex_Error("STRMODEX.2DA does not define any TO_HIT/DAMAGE exceptional strength tiers") + end + + EEex_Resource_Private_IWDStrengthEnabled = true + EEex_Resource_Private_IWDStrengthExceptionalThresholds = thresholds + EEex_Resource_Private_IWDStrengthMinValue = minStrength + EEex_Resource_Private_IWDStrengthMaxValue = maxStrength + -- Clamp exceptional values against the actual STRMODEX domain rather than assuming 100. + EEex_Resource_Private_IWDStrengthMaxExceptional = strmodexRows[#strmodexRows].label + -- Expanded ranks reuse the integer STR values directly up to 18, then insert one rank per + -- exceptional threshold before continuing with integer 19+. + EEex_Resource_Private_IWDStrengthMinRank = minStrength + EEex_Resource_Private_IWDStrengthMaxRank = maxStrength + #thresholds + end) + ------------------ -- X-CLSERG.2DA -- ------------------ diff --git a/EEex/copy/EEex_Sprite.lua b/EEex/copy/EEex_Sprite.lua index ea9c37d..314db4c 100644 --- a/EEex/copy/EEex_Sprite.lua +++ b/EEex/copy/EEex_Sprite.lua @@ -1527,6 +1527,185 @@ function EEex_Sprite_Hook_GetProfBonuses_IgnoreWeaponStyles(item, damR, damL, th return false end +local function EEex_Sprite_Private_IWDStrengthClampRank(rank) + if rank < EEex_Resource_Private_IWDStrengthMinRank then + return EEex_Resource_Private_IWDStrengthMinRank + end + if rank > EEex_Resource_Private_IWDStrengthMaxRank then + return EEex_Resource_Private_IWDStrengthMaxRank + end + return rank +end + +local function EEex_Sprite_Private_IWDStrengthToExpandedRank(strength, exceptional) + + -- Expanded rank space: + -- * integer STR below 18 keeps its numeric value + -- * exceptional tiers occupy the ranks inserted after 18 + -- * integer STR above 18 is shifted upward by the number of exceptional tiers + -- This lets a cumulative +N/-N operate in "tier steps" without hardcoding any specific + -- exceptional ladder. + if strength < EEex_Resource_Private_IWDStrengthMinValue or strength > EEex_Resource_Private_IWDStrengthMaxValue then + return nil + end + + if strength < 18 then + return strength + end + + local thresholds = EEex_Resource_Private_IWDStrengthExceptionalThresholds + + if strength == 18 then + + -- Exceptional strength is bucketed by the threshold labels derived from STRMODEX. Values + -- inside the same bucket share one expanded rank because they represent the same combat tier. + if exceptional < 0 then + exceptional = 0 + end + + if exceptional > EEex_Resource_Private_IWDStrengthMaxExceptional then + exceptional = EEex_Resource_Private_IWDStrengthMaxExceptional + end + + local tierIndex = 0 + for _, threshold in ipairs(thresholds) do + if exceptional < threshold then + break + end + tierIndex = tierIndex + 1 + end + + return 18 + tierIndex + end + + return strength + #thresholds +end + +local function EEex_Sprite_Private_IWDStrengthFromExpandedRank(rank) + + -- Inverse of EEex_Sprite_Private_IWDStrengthToExpandedRank(). Converts a stepped rank back to + -- the engine's split STR / STRExtra representation. + if rank < EEex_Resource_Private_IWDStrengthMinRank or rank > EEex_Resource_Private_IWDStrengthMaxRank then + return nil + end + + local thresholds = EEex_Resource_Private_IWDStrengthExceptionalThresholds + local thresholdCount = #thresholds + + if rank <= 18 then + return rank, 0 + end + + local exceptionalRank = rank - 18 + if exceptionalRank <= thresholdCount then + return 18, thresholds[exceptionalRank] + end + + return rank - thresholdCount, 0 +end + +function EEex_Sprite_Hook_OnProcessEffectListStatsReload(sprite) + + if not EEex_Resource_Private_IWDStrengthEnabled then + return + end + + local derivedStats = sprite.m_derivedStats + local strengthScratch = EEex_Utility_GetOrCreateTable(EEex_GetUDAux(sprite), "EEex_Sprite_IWDStrengthScratch") + -- ProcessEffectList() can loop back through Reload() / BonusInit() / HandleList() in the same + -- evaluation pass. Reset the per-pass baseline here so temporary mode 0 effects are recomputed + -- from the freshly reloaded derived stats instead of compounding their own prior output. + strengthScratch["baselineStrength"] = derivedStats.m_nSTR + strengthScratch["baselineExceptional"] = derivedStats.m_nSTRExtra + strengthScratch["currentStrength"] = derivedStats.m_nSTR + strengthScratch["currentExceptional"] = derivedStats.m_nSTRExtra +end + +function EEex_Sprite_Hook_OnApplyStrengthEffect(effect, sprite) + + if not EEex_Resource_Private_IWDStrengthEnabled then + return false + end + + local effectMode = effect.m_dWFlags + -- Only cumulative mode 0 is remapped to tier stepping. Other engine modes, especially mode 3, + -- have separate exceptional-strength behavior that is intentionally left vanilla here. + if effectMode ~= 0 then + return false + end + + local effectAmount = effect.m_effectAmount + if effectAmount == 0 then + return false + end + + local baseStats = sprite.m_baseStats + + if effect.m_durationType == 1 then + + -- Permanent mode 0 mutates the base stat fields directly, so convert the current base STR to + -- expanded rank space, step by the effect amount, then write the converted result back. + local currentRank = EEex_Sprite_Private_IWDStrengthToExpandedRank(baseStats.m_STRBase, baseStats.m_STRExtraBase) + if currentRank == nil then + return false + end + + local targetStrength, targetExceptional = + EEex_Sprite_Private_IWDStrengthFromExpandedRank(EEex_Sprite_Private_IWDStrengthClampRank(currentRank + effectAmount)) + + if targetStrength == nil then + return false + end + + baseStats.m_STRBase = targetStrength + baseStats.m_STRExtraBase = targetExceptional + -- Match the engine's permanent-effect contract: request a repass and mark the effect done. + effect.m_forceRepass = 1 + effect.m_done = 1 + return true + end + + local derivedStats = sprite.m_derivedStats + local strengthScratch = EEex_Utility_GetOrCreateTable(EEex_GetUDAux(sprite), "EEex_Sprite_IWDStrengthScratch") + + -- ProcessEffectList() may rerun Reload() / BonusInit() / HandleList() in the same call. The + -- reload hook resets this scratch to the freshly reloaded derived STR state each pass so the + -- same effect is not tier-stepped again on loop re-entry. + if strengthScratch["currentStrength"] == nil then + -- This fallback is only for callers that reach the hook before the reload callback seeded + -- the pass state; use the current derived value as both baseline and running value. + strengthScratch["baselineStrength"] = derivedStats.m_nSTR + strengthScratch["baselineExceptional"] = derivedStats.m_nSTRExtra + strengthScratch["currentStrength"] = derivedStats.m_nSTR + strengthScratch["currentExceptional"] = derivedStats.m_nSTRExtra + end + + local currentRank = EEex_Sprite_Private_IWDStrengthToExpandedRank( + strengthScratch["currentStrength"], strengthScratch["currentExceptional"]) + + if currentRank == nil then + return false + end + + local targetStrength, targetExceptional = + EEex_Sprite_Private_IWDStrengthFromExpandedRank(EEex_Sprite_Private_IWDStrengthClampRank(currentRank + effectAmount)) + + if targetStrength == nil then + return false + end + + local bonusStats = sprite.m_bonusStats + strengthScratch["currentStrength"] = targetStrength + strengthScratch["currentExceptional"] = targetExceptional + -- Temporary mode 0 contributes through bonusStats. Write the net delta from the per-pass + -- baseline instead of incrementing in place so repeated HandleList() passes stay idempotent. + bonusStats.m_nSTR = targetStrength - strengthScratch["baselineStrength"] + bonusStats.m_nSTRExtra = targetExceptional - strengthScratch["baselineExceptional"] + -- Match the engine's temporary-effect contract: temporary mode 0 is reprocessed each pass. + effect.m_done = 0 + return true +end + --[[ +---------------------------------------------------------------------------------------------------------------------------------+ | Implement X-CLSERG.2DA - Ignore the -8 thac0 penalty characters incur when meleeing with a ranged weapon for specific | diff --git a/EEex/copy/EEex_Sprite_Patch.lua b/EEex/copy/EEex_Sprite_Patch.lua index 8a9fd16..2cc662a 100644 --- a/EEex/copy/EEex_Sprite_Patch.lua +++ b/EEex/copy/EEex_Sprite_Patch.lua @@ -641,6 +641,80 @@ EEex_HookIntegrityWatchdogRegister.R11 }) + --[[ + +-------------------------------------------------------------------------------------------------------------------------------+ + | Implement X-IWDSTR.2DA - Treat cumulative mode 0 strength changes as tier changes when stepping across STRMODEX breakpoints | + +-------------------------------------------------------------------------------------------------------------------------------+ + | [Lua] EEex_Sprite_Hook_OnApplyStrengthEffect(effect: CGameEffect, sprite: CGameSprite) -> boolean | + | return: | + | -> false - Don't alter engine behavior | + | -> true - Effect handled (skip normal code) | + +-------------------------------------------------------------------------------------------------------------------------------+ + --]] + + -- Strategy: + -- * hook the first instruction so Lua sees the original effect / sprite arguments before any + -- engine-side mutation happens + -- * use a "before restore" trampoline so a false Lua result can still resume the untouched + -- function by replaying the displaced prologue bytes and then continuing normally + -- * if Lua returns true, skip that restore/resume path entirely and synthesize the function's + -- normal success return from the trampoline + EEex_HookBeforeRestoreWithLabels(EEex_Label("Hook-CGameEffectSTR::ApplyEffect()-FirstInstruction"), 0, 7, 7, { + -- This hook runs before the overwritten prologue bytes are replayed, so only the caller's + -- return address is on the stack at entry. Model that exact pre-prologue state here. + {"stack_mod", 8}, + {"hook_integrity_watchdog_ignore_registers", { + EEex_HookIntegrityWatchdogRegister.RAX, EEex_HookIntegrityWatchdogRegister.R8, EEex_HookIntegrityWatchdogRegister.R9, + EEex_HookIntegrityWatchdogRegister.R10, EEex_HookIntegrityWatchdogRegister.R11 + }}}, + EEex_FlattenTable({ + {[[ + #MAKE_SHADOW_SPACE(64) + ; Preserve the original arguments across the Lua call so a false return can fall back to + ; the untouched engine implementation. + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-8)], rcx + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-16)], rdx + ]]}, + -- Lua contains the policy decision. Assembly here only preserves arguments, marshals the + -- call, and selects between "resume vanilla" and "return handled" afterwards. + EEex_GenLuaCall("EEex_Sprite_Hook_OnApplyStrengthEffect", { + ["args"] = { + function(rspOffset) return {"mov qword ptr ss:[rsp+#$(1)], rcx", {rspOffset}, "#ENDL"}, "CGameEffect" end, + function(rspOffset) return {"mov qword ptr ss:[rsp+#$(1)], rdx", {rspOffset}, "#ENDL"}, "CGameSprite" end, + }, + ["returnType"] = EEex_LuaCallReturnType.Boolean, + }), + {[[ + jmp no_error + + call_error: + xor rax, rax + + no_error: + ; rax == 0: branch to the hook framework's restore/resume path. Because this is a + ; before-restore hook, that path replays the stolen prologue bytes and then continues in + ; CGameEffectSTR::ApplyEffect() as though the hook had not intercepted it. + ; rax != 0: report "effect handled" and return immediately without running engine code. + test rax, rax + mov rdx, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-16)] + mov rcx, qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-8)] + #DESTROY_SHADOW_SPACE + jz #L(return) + + ; Match ApplyEffect()'s success return convention and exit directly from the trampoline. + mov eax, 1 + #MANUAL_HOOK_EXIT(1) + ret + ]]}, + }) + ) + -- Manually define the ignored registers for the unusual `ret` above + EEex_HookIntegrityWatchdog_IgnoreRegistersForInstance(EEex_Label("Hook-CGameEffectSTR::ApplyEffect()-FirstInstruction"), 1, { + EEex_HookIntegrityWatchdogRegister.RAX, EEex_HookIntegrityWatchdogRegister.RCX, EEex_HookIntegrityWatchdogRegister.RDX, + EEex_HookIntegrityWatchdogRegister.R8, EEex_HookIntegrityWatchdogRegister.R9, EEex_HookIntegrityWatchdogRegister.R10, + EEex_HookIntegrityWatchdogRegister.R11 + }) + --[[ +---------------------------------------------------------------------------------------------------------------------------------+ | Implement X-CLSERG.2DA - Ignore the -8 thac0 penalty characters incur when meleeing with a ranged weapon for specific | diff --git a/EEex/copy/EEex_Stats_Patch.lua b/EEex/copy/EEex_Stats_Patch.lua index cacdcbd..76722cc 100644 --- a/EEex/copy/EEex_Stats_Patch.lua +++ b/EEex/copy/EEex_Stats_Patch.lua @@ -83,7 +83,24 @@ EEex_HookAfterCallWithLabels(EEex_Label("Hook-CGameSprite::ProcessEffectList()-CDerivedStats::Reload()"), { {"hook_integrity_watchdog_ignore_registers", {EEex_HookIntegrityWatchdogRegister.RAX}}}, - statsReloadTemplate("rsi") + EEex_FlattenTable({ + {[[ + #MAKE_SHADOW_SPACE(40) + ]]}, + -- Keep the existing reload-side EEex bookkeeping, then notify the X-IWDSTR runtime that a + -- new ProcessEffectList() pass has started. The Lua callback resets the per-pass strength + -- scratch used by temporary cumulative mode 0 so loop re-entry does not double-apply tiers. + statsReloadTemplate("rsi"), + EEex_GenLuaCall("EEex_Sprite_Hook_OnProcessEffectListStatsReload", { + ["args"] = { + function(rspOffset) return {"mov qword ptr ss:[rsp+#$(1)], rsi #ENDL", {rspOffset}}, "CGameSprite" end, + }, + }), + {[[ + call_error: + #DESTROY_SHADOW_SPACE + ]]}, + }) ) --[[ diff --git a/EEex/copy/X-IWDSTR.2DA b/EEex/copy/X-IWDSTR.2DA new file mode 100644 index 0000000..b529af3 --- /dev/null +++ b/EEex/copy/X-IWDSTR.2DA @@ -0,0 +1,4 @@ +2DA V1.0 +0 + VALUE +CHECK_MODE 0 diff --git a/EEex/loader/InfinityLoader.db b/EEex/loader/InfinityLoader.db index 4d0aa32..33c6347 100644 --- a/EEex/loader/InfinityLoader.db +++ b/EEex/loader/InfinityLoader.db @@ -1807,6 +1807,13 @@ Operations=ADD 23 Pattern=3881F81000007407 Operations=ADD 56 +; X-IWDSTR mode 0 entry hook. Pattern generated from FindPattern/src for the 2.6.6.0 EE +; executables and anchored at the function's first instruction so Lua can either handle the +; effect completely or fall through to the untouched engine path. +[Hook-CGameEffectSTR::ApplyEffect()-FirstInstruction] +Pattern=564154415541564157488BEC +Operations=ADD -2 + [Hook-CGameEffectForceSurge::ApplyEffect()-FirstInstruction] Pattern=898248130000 Operations=ADD -3 @@ -1956,6 +1963,9 @@ Operations=ADD 34 Pattern=8B564825FF7F0000 Operations=ADD 26 +; Used by the X-IWDSTR temporary-mode scratch reset. This fires immediately after +; CDerivedStats::Reload() inside ProcessEffectList() so each pass starts from the freshly +; rebuilt derived STR state. [Hook-CGameSprite::ProcessEffectList()-CDerivedStats::Reload()] Pattern=488D8660050000660F1F440000 Operations=ADD 37 @@ -2119,6 +2129,10 @@ Operations=ADD 35 Pattern=48897C241055488BEC4883EC30 Operations=ADD -5 +[Hook-CRuleTables::IsProtectedFromSpell()-CompareStatCall] +Pattern=E94BFAFFFF83BFA44E000000 +Operations=ADD 48 + [Hook-CScreenWorld::OnKeyDown()-ThievingHotkeyPressSpecialAbilitiesCall] Pattern=3CFF7484 Operations=ADD 10