diff --git a/.gitignore b/.gitignore index 9f26816..afa9ea1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ !/style/*.* !/.gitattributes !/.gitignore +!/.gitmessage !/EEex.png !/package_mod.bat -!/README.md \ No newline at end of file +!/README.md diff --git a/.gitmessage b/.gitmessage new file mode 100644 index 0000000..3351658 --- /dev/null +++ b/.gitmessage @@ -0,0 +1,33 @@ +# (): +# +# Example: +# fix(opcode): preserve natural roll for op138 crit checks +# +# Conventional Commit guidance: +# - type: feat, fix, refactor, perf, build, docs, test, chore +# - scope: use one precise EEex area +# - summary: imperative, specific, <= 72 chars, no trailing period +# +# Suggested EEex scopes: +# action, opcode, sprite, menu, ui, loader, patch, tp2, docs, packaging +# +# Why: +# - State the concrete bug, limitation, or motivation. +# - Prefer engine behavior over implementation trivia. +# +# What: +# - Summarize the code and behavior changes. +# - Call out hook sites, scripts, or data files when relevant. +# +# Validation: +# - List the checks you actually ran. +# - Include build status, luaparse/tests, runtime checks, or engine variants. +# +# Risks / Notes: +# - Record follow-up work, compatibility concerns, or deliberate tradeoffs. +# +# Refs: +# - Optional issue, artifact, dump, or investigation note. +# +# BREAKING CHANGE: +# - Optional footer when behavior or interfaces change incompatibly. diff --git a/EEex/copy/EEex_Action.lua b/EEex/copy/EEex_Action.lua index 8a5056d..315c5a8 100644 --- a/EEex/copy/EEex_Action.lua +++ b/EEex/copy/EEex_Action.lua @@ -243,6 +243,31 @@ function EEex_Action_Private_SpellObjectOffset(aiBase, curAction, bOnlySprite, r return realActionFunc(aiBase) end +local function EEex_Action_Private_AttackOnce(aiBase, curAction) + + if not aiBase:isSprite(true) then + return EEex_Action_ReturnType.ACTION_ERROR + end + + local target = aiBase:GetTargetShareType1(curAction.m_acteeID, CGameObjectType.SPRITE) + if target == nil then + return EEex_Action_ReturnType.ACTION_ERROR + end + + local sprite = EEex_GameObject_CastUT(aiBase) + sprite:UpdateTarget(target) + + -- Use the engine's normal Attack() path so pathing, ranged projectile creation, and + -- weapon selection behave exactly like a regular attack. The native op138 bridge stops + -- the action after the first matched real roll; doing that purely in Lua was not + -- reliable once the sprite had to walk into range first. + curAction.m_actionID = 3 -- Attack + curAction.m_dest.x = target.m_pos.x + curAction.m_dest.y = target.m_pos.y + + return aiBase:virtual_ExecuteAction() +end + EEex_Action_Private_Switch = { -- Bug Fix: Enable ForceSpellRange / ForceSpellRangeRES @@ -287,6 +312,13 @@ EEex_Action_Private_Switch = { return EEex_Action_ReturnType.ACTION_DONE end, + -- EEex_AttackOnce + [475] = function(aiBase, curAction) + -- This is a thin wrapper around Attack(). The "exactly one swing" behavior is + -- finalized natively once the first real attack roll is consumed. + return EEex_Action_Private_AttackOnce(aiBase, curAction) + end, + -- EEex_SpellObjectOffset / EEex_SpellObjectOffsetRES [476] = function(aiBase, curAction) return EEex_Action_Private_SpellObjectOffset(aiBase, curAction, true, 95, CGameSprite.SpellPoint) diff --git a/EEex/copy/EEex_Action_Patch.lua b/EEex/copy/EEex_Action_Patch.lua index b9d4bf7..5d35d94 100644 --- a/EEex/copy/EEex_Action_Patch.lua +++ b/EEex/copy/EEex_Action_Patch.lua @@ -11,6 +11,7 @@ | 473 EEex_MatchObject(S:Chunk*) | | 473 EEex_MatchObjectEx(S:Chunk*,I:Nth*,I:Range*,I:Flags*X-MATOBJ) | | 474 EEex_SetTarget(S:Name*,O:Target*) | + | 475 EEex_AttackOnce(O:Target*) | | 476 EEex_SpellObjectOffset(O:Target*,I:Spell*Spell,P:Offset*) | | 476 EEex_SpellObjectOffsetRES(S:RES*,O:Target*,P:Offset*) | | 477 EEex_SpellObjectOffsetNoDec(O:Target*,I:Spell*Spell,P:Offset*) | @@ -72,6 +73,10 @@ +----------------------------------------------------------------------------------+ --]] + -- opcode 138 depends on this native callback as well: the wrapper action can queue + -- a normal Attack(), but only the engine knows when movement/pathing has finished and + -- the real attack action has actually started for the chosen target. + EEex_HookAfterCallWithLabels(EEex_Label("CGameSprite::SetCurrAction()-LastCall"), { {"hook_integrity_watchdog_ignore_registers", {EEex_HookIntegrityWatchdogRegister.RAX}}}, {[[ 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_Opcode.lua b/EEex/copy/EEex_Opcode.lua index e92b6d2..0720e86 100644 --- a/EEex/copy/EEex_Opcode.lua +++ b/EEex/copy/EEex_Opcode.lua @@ -129,6 +129,32 @@ function EEex_Opcode_LuaHook_AfterListsResolved(sprite) end end +--[[ ++----------------------------------------------------------------------------------------------------------------+ +| Opcode #138 (0x8A) | ++----------------------------------------------------------------------------------------------------------------+ +| param2 in {0, 8, 11, 12, 13} and (param1 & 1) ~= 0 -> perform a real attack and call the Lua function in | +| `resource` before the attack roll / damage is finalized | ++----------------------------------------------------------------------------------------------------------------+ +| Callback signature: | +| FUNC(op138: CGameEffect, sprite: CGameSprite, baseAttackRoll: number, baseDamageRoll: number) | +| -> number, number[, boolean] | +| | +| Notes: | +| - The callback name must be 8 characters or less, and be ALL UPPERCASE | +| - The Lua patch only redirects opcode 138 into the feature. The actual callback result has to persist | +| across later native attack phases, so EEex.dll / loader labels are patched too | +| - Critical-hit determination still uses the natural roll | +| - `baseDamageRoll` is captured from the engine's raw `Roll:X` damage basis before later modifiers | +| - The returned base attack roll is clamped to the engine's natural d20 range `[1, 20]` | +| - The returned base damage roll is clamped to `>= 0` | +| - The returned base damage roll replaces only the engine's raw `Roll:X` basis; later damage modifiers | +| still apply normally unless the optional third return forces final damage to `0` | +| - If the optional third return is `true` and the returned base damage roll clamps to `0`, final damage | +| is forced to `0` too, ignoring later damage modifiers | ++----------------------------------------------------------------------------------------------------------------+ +--]] + --[[ +--------------------------------------------------------------------------------+ | Opcode #214 | diff --git a/EEex/copy/EEex_Opcode_Patch.lua b/EEex/copy/EEex_Opcode_Patch.lua index 0167106..2d91316 100644 --- a/EEex/copy/EEex_Opcode_Patch.lua +++ b/EEex/copy/EEex_Opcode_Patch.lua @@ -85,6 +85,62 @@ -- Opcode Changes -- -------------------------------------- + --[[ + +-----------------------------------------------------------------------------------------------------------------------+ + | Opcode #138 (0x8A) | + +-----------------------------------------------------------------------------------------------------------------------+ + | param2 in {0, 8, 11, 12, 13} and (param1 & 1) ~= 0 -> invoke Lua callback in `resource`, then perform a real | + | attack instead of the default animation-only behavior | + | | + | resource -> Name of the global Lua function. The name must be 8 characters or less, and be ALL UPPERCASE. | + +-----------------------------------------------------------------------------------------------------------------------+ + | [EEex.dll] EEex::Opcode_Hook_Op138_ApplyEffect(effect: CGameEffect*, sprite: CGameSprite*) -> boolean | + | return: | + | -> false - Effect not handled | + | -> true - Effect handled (skip normal code) | + +-----------------------------------------------------------------------------------------------------------------------+ + --]] + + -- The Lua-side repo can intercept op138 at ApplyEffect(), but the callback result has + -- to survive until much later native attack phases (action start, Hit(), Damage()). + -- That is why the implementation also patches EEex.dll / loader labels in InfinityLoader. + + -- Hook the first instruction of SetSequence::ApplyEffect() so op138 can decide, before any + -- vanilla work runs, whether this particular effect instance should be redirected into the + -- native op138 bridge. Non-op138 callers fall straight back into the original function. + EEex_HookBeforeRestoreWithLabels(EEex_Label("Hook-CGameEffectSetSequence::ApplyEffect()-FirstInstruction"), 0, 6, 6, { + {"stack_mod", 8}, + {"hook_integrity_watchdog_ignore_registers", { + EEex_HookIntegrityWatchdogRegister.RAX, EEex_HookIntegrityWatchdogRegister.R8, EEex_HookIntegrityWatchdogRegister.R9, + EEex_HookIntegrityWatchdogRegister.R10, EEex_HookIntegrityWatchdogRegister.R11 + }}}, + {[[ + ; Preserve the original ApplyEffect(effect, sprite) arguments across the helper call. + ; The native helper returns non-zero only when it fully takes ownership of this op138. + #MAKE_SHADOW_SPACE(48) + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-8)], rcx + mov qword ptr ss:[rsp+#SHADOW_SPACE_BOTTOM(-16)], rdx + call #L(EEex::Opcode_Hook_Op138_ApplyEffect) + test eax, eax + 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) + + ; Handled path: emulate a successful ApplyEffect() return and skip the vanilla body. + ; The native side has already queued the real attack flow and preserved later callback state. + mov eax, 1 + #MANUAL_HOOK_EXIT(1) + ret + ]]} + ) + -- Manually define the ignored registers for the unusual `ret` above + EEex_HookIntegrityWatchdog_IgnoreRegistersForInstance(EEex_Label("Hook-CGameEffectSetSequence::ApplyEffect()-FirstInstruction"), 1, { + EEex_HookIntegrityWatchdogRegister.RAX, EEex_HookIntegrityWatchdogRegister.RCX, EEex_HookIntegrityWatchdogRegister.RDX, + EEex_HookIntegrityWatchdogRegister.R8, EEex_HookIntegrityWatchdogRegister.R9, EEex_HookIntegrityWatchdogRegister.R10, + EEex_HookIntegrityWatchdogRegister.R11 + }) + --[[ +--------------------------------------------------------------------------------------------------+ | Opcode #214 | diff --git a/EEex/copy/EEex_Sprite_Patch.lua b/EEex/copy/EEex_Sprite_Patch.lua index 8a9fd16..e2ab428 100644 --- a/EEex/copy/EEex_Sprite_Patch.lua +++ b/EEex/copy/EEex_Sprite_Patch.lua @@ -487,6 +487,43 @@ }) ) + -- Intercept the formatting call that turns Damage()'s raw base damage roll into the combat-log + -- "Roll:X" text. This is the earliest stable point where the engine has the uncluttered base + -- damage roll in hand, but has not yet folded it into the later total damage result. The native + -- bridge uses this seam for two cases: + -- 1. the silent op138 preview pass, which needs the same base roll the real hit will use + -- 2. the later real hit, where the Lua-returned base damage roll must replace only Roll:X + -- while leaving all later engine modifiers intact + EEex_HookBeforeCallWithLabels(EEex_Label("Hook-CGameSprite::Damage()-FormatBaseDamageRoll"), { + {"hook_integrity_watchdog_ignore_registers", { + EEex_HookIntegrityWatchdogRegister.RAX, EEex_HookIntegrityWatchdogRegister.RCX, EEex_HookIntegrityWatchdogRegister.RDX, + EEex_HookIntegrityWatchdogRegister.RSI, EEex_HookIntegrityWatchdogRegister.R8, EEex_HookIntegrityWatchdogRegister.R9, + EEex_HookIntegrityWatchdogRegister.R10, EEex_HookIntegrityWatchdogRegister.R11 + }}}, + {[[ + ; Preserve the formatter call arguments, ask the native bridge whether this Damage() + ; instance belongs to the active op138 attack, then feed the possibly adjusted base + ; roll back through the exact registers the engine is about to use for Roll:X. + #MAKE_SHADOW_SPACE(64) + mov qword ptr ss:[rsp+40], rcx + mov qword ptr ss:[rsp+48], rdx + mov qword ptr ss:[rsp+56], r8 + + ; Ask the native op138 bridge whether this Damage() call is the silent preview + ; or the later real swing, and if needed replace only the raw Roll:X basis. + mov rcx, rdi + mov rdx, r15 + movsx r8d, si + call #L(EEex::Sprite_Hook_AdjustDamageRollBasis) + + movsx esi, ax + mov r8d, esi + mov rdx, qword ptr ss:[rsp+48] + mov rcx, qword ptr ss:[rsp+40] + #DESTROY_SHADOW_SPACE + ]]} + ) + --[[ +---------------------------------------------------------------------+ | On action change, clear EEex data associated with the ending action | @@ -791,11 +828,119 @@ EEex_HookBeforeCallWithLabels(EEex_Label("Hook-CGameSprite::Hit()-FormatRollCall"), { {"hook_integrity_watchdog_ignore_registers", { - EEex_HookIntegrityWatchdogRegister.R9, EEex_HookIntegrityWatchdogRegister.R10, EEex_HookIntegrityWatchdogRegister.R11 + EEex_HookIntegrityWatchdogRegister.RAX, EEex_HookIntegrityWatchdogRegister.RCX, EEex_HookIntegrityWatchdogRegister.RDX, + EEex_HookIntegrityWatchdogRegister.RDI, EEex_HookIntegrityWatchdogRegister.R8, EEex_HookIntegrityWatchdogRegister.R9, + EEex_HookIntegrityWatchdogRegister.R10, EEex_HookIntegrityWatchdogRegister.R11, EEex_HookIntegrityWatchdogRegister.R12 }}}, {[[ + #MAKE_SHADOW_SPACE(80) + mov qword ptr ss:[rsp+40], rcx + mov qword ptr ss:[rsp+48], rdx + mov qword ptr ss:[rsp+56], r8 + mov qword ptr ss:[rsp+64], r9 + + ; Let the native bridge consume the first real attack roll tied to the queued + ; op138 attack, invoke the Lua callback, and hand back the overridden base roll. + mov rcx, rbx + mov rdx, r14 + mov r8, r15 + mov r9d, dword ptr ss:[rsp+56] + call #L(EEex::Sprite_Hook_OnBeforeFormatRollCall) + + mov r9, qword ptr ss:[rsp+64] + mov rdx, qword ptr ss:[rsp+48] + mov rcx, qword ptr ss:[rsp+40] + mov edi, eax + mov r12d, eax + mov r8d, eax + mov dword ptr ss:[rbp-40h], eax mov rax, ]], EEex_Label("EEex::CGameSprite_Hit_Roll"), [[ #ENDL mov byte ptr ds:[rax], r8b + + #DESTROY_SHADOW_SPACE + ]]} + ) + + -- Intercept the late critical-hit branch inside Hit(). At this point the visible / effective + -- attack roll may already have been overridden for op138, but critical-hit eligibility must + -- still be decided from the natural d20 roll the engine originally rolled. + EEex_HookBeforeConditionalJumpWithLabels(EEex_Label("Hook-CGameSprite::Hit()-AdjustCriticalHitRollThreshold"), 0, { + {"hook_integrity_watchdog_ignore_registers", { + EEex_HookIntegrityWatchdogRegister.RAX, EEex_HookIntegrityWatchdogRegister.RCX, EEex_HookIntegrityWatchdogRegister.RDX, + EEex_HookIntegrityWatchdogRegister.R8, EEex_HookIntegrityWatchdogRegister.R9, + EEex_HookIntegrityWatchdogRegister.R10, EEex_HookIntegrityWatchdogRegister.R11 + }}}, + {[[ + ; Save the branch inputs, recover the natural roll from native op138 state, then rebuild + ; the threshold compare so only crit logic sees the natural roll. The displayed attack roll + ; remains overridden elsewhere; this hook adjusts only the jump decision in front of us. + #MAKE_SHADOW_SPACE(80) + mov qword ptr ss:[rsp+40], rcx + mov qword ptr ss:[rsp+48], rdx + mov qword ptr ss:[rsp+56], r8 + mov qword ptr ss:[rsp+64], r9 + mov dword ptr ss:[rsp+72], ecx + + ; The displayed / effective attack roll may be overridden, but crit thresholds + ; still have to use the natural d20 roll. + mov rcx, rbx + mov rdx, r14 + mov r8d, dword ptr ss:[rbp-40h] + call #L(EEex::Sprite_Hook_GetNaturalRollForLateHitLogic) + + mov edx, dword ptr ss:[rbp-40h] + sub edx, eax + mov ecx, dword ptr ss:[rsp+72] + add ecx, edx + + mov r9, qword ptr ss:[rsp+64] + mov r8, qword ptr ss:[rsp+56] + mov rdx, qword ptr ss:[rsp+48] + cmp dword ptr ss:[rbp-40h], ecx + + #DESTROY_SHADOW_SPACE + ]]} + ) + + -- Mirror the critical-hit fix for the later critical-miss branch. An op138 callback may lower + -- the displayed / effective attack roll below 1, but the engine must only force an automatic + -- miss when the natural d20 roll itself was really 1. + EEex_HookBeforeConditionalJumpWithLabels(EEex_Label("Hook-CGameSprite::Hit()-AdjustCriticalMissRollThreshold"), 0, { + {"hook_integrity_watchdog_ignore_registers", { + EEex_HookIntegrityWatchdogRegister.RAX, EEex_HookIntegrityWatchdogRegister.RCX, EEex_HookIntegrityWatchdogRegister.RDX, + EEex_HookIntegrityWatchdogRegister.R8, EEex_HookIntegrityWatchdogRegister.R9, + EEex_HookIntegrityWatchdogRegister.R10, EEex_HookIntegrityWatchdogRegister.R11 + }}}, + {[[ + ; Same strategy as the crit-hit hook above: preserve live branch state, ask the native + ; bridge for the natural roll that belongs to this attack, then replay the compare with + ; natural-roll semantics so only the automatic-miss gate is corrected. + #MAKE_SHADOW_SPACE(80) + mov qword ptr ss:[rsp+40], rcx + mov qword ptr ss:[rsp+48], rdx + mov qword ptr ss:[rsp+56], r8 + mov qword ptr ss:[rsp+64], r9 + mov dword ptr ss:[rsp+72], eax + + ; Mirror the critical-hit fix for critical misses so a debuffed overridden roll + ; does not manufacture an automatic miss unless the natural roll was really 1. + mov rcx, rbx + mov rdx, r14 + mov r8d, dword ptr ss:[rbp-40h] + call #L(EEex::Sprite_Hook_GetNaturalRollForLateHitLogic) + + mov edx, dword ptr ss:[rbp-40h] + sub edx, eax + mov eax, dword ptr ss:[rsp+72] + add eax, edx + + mov r9, qword ptr ss:[rsp+64] + mov r8, qword ptr ss:[rsp+56] + mov rdx, qword ptr ss:[rsp+48] + mov rcx, qword ptr ss:[rsp+40] + cmp dword ptr ss:[rbp-40h], eax + + #DESTROY_SHADOW_SPACE ]]} ) diff --git a/EEex/loader/EEex.dll b/EEex/loader/EEex.dll index 76f7f46..e64a433 100644 Binary files a/EEex/loader/EEex.dll and b/EEex/loader/EEex.dll differ diff --git a/EEex/loader/InfinityLoader.db b/EEex/loader/InfinityLoader.db index 4d0aa32..eb98b95 100644 --- a/EEex/loader/InfinityLoader.db +++ b/EEex/loader/InfinityLoader.db @@ -1827,6 +1827,9 @@ Operations=ADD -10 Pattern=F6472004 Operations=ADD 25 +[Hook-CGameEffectSetSequence::ApplyEffect()-FirstInstruction] +Pattern=40534883EC20F782C81D000000080000488BC2488BD975160FB65120488BC8E8 + [Hook-CGameEffectRangeEffect::ApplyEffect()] Pattern=4885C9747B Operations=ADD -43 @@ -1924,10 +1927,39 @@ Operations=ADD 7 Pattern=418BF1488D4DD8 Operations=ADD 10 +; opcode 138 / AttackOnce bridge: +; - ApplyEffect() interception starts the feature +; - ExecuteAction()/SetCurrAction() labels track when the real attack begins +; - Hit()/Damage() labels let the native bridge rewrite only the base rolls while +; still letting the engine keep its normal crit and damage-modifier behavior +[Hook-CGameSprite::Damage()-FormatBaseDamageRoll] +ExeSwitch=1 +ExeSwitchAlias=SiegeOfDragonspear.exe:Baldur.exe + +[!ExeSwitch-Baldur.exe-Hook-CGameSprite::Damage()-FormatBaseDamageRoll] +Pattern=440FBFC6488D15ABD52100488D4C2448E8E1E10600 +Operations=ADD 16 + +[!ExeSwitch-BaldurII.exe-Hook-CGameSprite::Damage()-FormatBaseDamageRoll] +Pattern=440FBFC6488D15ABD52100488D4C2448E8E1E10600 +Operations=ADD 16 + +[!ExeSwitch-icewind.exe-Hook-CGameSprite::Damage()-FormatBaseDamageRoll] +Pattern=440FBFC6488D156BF52100488D4C2448E891E20600 +Operations=ADD 16 + [Hook-CGameSprite::Hit()-FormatRollCall] Pattern=488D4DD08D7801 Operations=ADD 18 +[Hook-CGameSprite::Hit()-AdjustCriticalHitRollThreshold] +Pattern=0F8C1D010000B801000000418BFC +Operations=ADD 0 + +[Hook-CGameSprite::Hit()-AdjustCriticalMissRollThreshold] +Pattern=0F8F60FFFFFF6683BBF803000062458BF4 +Operations=ADD 0 + [Hook-CGameSprite::Hit()-MeleeingWithRangedPenalty] Pattern=2BF90FAFC0 Operations=ADD 9 @@ -2119,6 +2151,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 diff --git a/EEex/patch/ACTION.IDS b/EEex/patch/ACTION.IDS index 3e6b53c..ff0a98d 100644 --- a/EEex/patch/ACTION.IDS +++ b/EEex/patch/ACTION.IDS @@ -2,6 +2,7 @@ 473 EEex_MatchObject(S:Chunk*) 473 EEex_MatchObjectEx(S:Chunk*,I:Nth*,I:Range*,I:Flags*X-MATOBJ) 474 EEex_SetTarget(S:Name*,O:Target*) +475 EEex_AttackOnce(O:Target*) 476 EEex_SpellObjectOffset(O:Target*,I:Spell*Spell,P:Offset*) 476 EEex_SpellObjectOffsetRES(S:RES*,O:Target*,P:Offset*) 477 EEex_SpellObjectOffsetNoDec(O:Target*,I:Spell*Spell,P:Offset*) @@ -9,4 +10,4 @@ 478 EEex_ForceSpellObjectOffset(O:Target*,I:Spell*Spell,P:Offset*) 478 EEex_ForceSpellObjectOffsetRES(S:RES*,O:Target*,P:Offset*) 479 EEex_ReallyForceSpellObjectOffset(O:Target*,I:Spell*Spell,P:Offset*) -479 EEex_ReallyForceSpellObjectOffsetRES(S:RES*,O:Target*,P:Offset*) \ No newline at end of file +479 EEex_ReallyForceSpellObjectOffsetRES(S:RES*,O:Target*,P:Offset*)