Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v1.3.15-alpha (2026-04-30)

### Fixed
- **"Cast All" greyed out when the selected character has no preset spells** — the gate fed both action buttons via `BfBot.UI._CanCast()`, which only checked the current character's spell table. On characters with nothing configured for the active preset (e.g. Safana on a buff preset), Cast All was disabled even though other party members had spells in the same preset. Cast All now uses a new `BfBot.UI._CanCastAll()` that mirrors `BuildQueueFromPreset`'s cross-party scope: it falls through to the other portrait slots when the current character is empty. Cast Character keeps the original char-scoped gate.
- **Crash when pressing Stop after reloading a save mid-cast** (#38) — reported by sov_ on Discord. After loading a save while a buff queue was running, only the Stop button was enabled; clicking it triggered an access violation. `BfBot.Exec._casters[].sprite` cached `CGameSprite` userdata from the pre-reload party, and the post-reload save freed those C++ objects — calling `EEex_Action_QueueResponseStringOnAIBase` on the stale userdata segfaulted at the engine level (and `pcall` does not catch C++ access violations). Stop and `_Complete` now re-resolve the caster sprite from the current portrait slot in their cleanup loops, so they never dereference the freed pointer; `BFBTCR` is a no-op on targets without an active `BFBTCH`, so the cleanup is safe even when the slot now holds a different character. A new `_IsStateStale` heuristic compares cached caster names against the live portrait names and proactively hard-resets execution state from `_SafetyTick` when party composition changed across the reload, so the Cast / Cast Character buttons re-enable themselves on the next safety tick instead of leaving the user stuck pressing Stop. Covered by `BfBot.Test.StaleState` (8 assertions).

## v1.3.14-alpha (2026-04-28)

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion buffbot/BfBotCor.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

-- Root namespace
BfBot = BfBot or {}
BfBot.VERSION = "1.3.14-alpha"
BfBot.VERSION = "1.3.15-alpha"
BfBot.MAX_PRESETS = 8

-- ============================================================
Expand Down
116 changes: 104 additions & 12 deletions buffbot/BfBotExe.lua
Original file line number Diff line number Diff line change
Expand Up @@ -460,15 +460,77 @@ function BfBot.Exec._Advance(slot)
BfBot.Exec._ProcessCasterEntry(slot, caster.index + 1)
end

--- Reset all execution state without dereferencing cached sprites.
--- Used to recover from save-reload mid-cast (issue #38), where _casters
--- holds freed CGameSprite pointers from the pre-reload party. Calling
--- into the engine on those pointers would segfault — clear the table
--- first, never touch caster.sprite. Caller is responsible for closing
--- the exec log (see Stop / _Complete recovery branches); this keeps the
--- function side-effect free so the in-game test suite can capture its
--- own output to the log around each subtest.
function BfBot.Exec._HardReset()
BfBot.Exec._state = "idle"
BfBot.Exec._casters = {}
BfBot.Exec._activeCasters = 0
BfBot.Exec._castCount = 0
BfBot.Exec._skipCount = 0
BfBot.Exec._totalEntries = 0
BfBot.Exec._qcMode = 0
end

--- Detect stale execution state from a save reload mid-cast.
--- After loading a save while casting, _casters[].sprite still holds
--- freed CGameSprite pointers from the pre-reload party. We can't safely
--- compare sprite identity directly — EEex returns a fresh userdata
--- wrapper per call to EEex_Sprite_GetInPortrait, and `==` falls through
--- to a __eq metamethod that does NOT pointer-compare the wrapped
--- CGameSprite (verified empirically with two consecutive calls returning
--- different wrappers and `==` evaluating to false).
---
--- Instead, compare the cached character name (a plain string captured
--- at Start time) against the freshly-fetched portrait sprite's name.
--- The fresh sprite is safe to dereference; the cached string never
--- references engine memory. This catches the "different-party-composition
--- reload" case (e.g. user reloads to before recruiting an NPC). It does
--- NOT catch the "same-save reload" case where party composition is
--- unchanged — Stop's cleanup loop must independently re-resolve sprites
--- from the portrait so it doesn't dereference cached caster.sprite.
--- @return boolean: true if state is "running" but at least one caster's
--- cached name no longer matches the current portrait at that slot.
function BfBot.Exec._IsStateStale()
if BfBot.Exec._state ~= "running" then return false end

for slot, caster in pairs(BfBot.Exec._casters) do
if caster.name then
local fresh = EEex_Sprite_GetInPortrait(slot)
local freshName = fresh and BfBot._GetName(fresh) or nil
if freshName ~= caster.name then return true end
end
end

return false
end

--- Log execution summary and transition to "done" state.
function BfBot.Exec._Complete()
-- Clean up lingering cheat buffs
-- Fast-path recovery from save-reload (issue #38) — see Stop().
if BfBot.Exec._IsStateStale() then
BfBot.Exec._HardReset()
BfBot._CloseLog()
return
end

-- Clean up lingering cheat buffs — re-resolve sprite from portrait,
-- never dereference cached caster.sprite (see Stop() rationale).
for slot, caster in pairs(BfBot.Exec._casters) do
if caster.cheatApplied and caster.sprite then
pcall(function()
EEex_Action_QueueResponseStringOnAIBase(
'ReallyForceSpellRES("BFBTCR",Myself)', caster.sprite)
end)
if caster.cheatApplied then
local sprite = EEex_Sprite_GetInPortrait(slot)
if sprite then
pcall(function()
EEex_Action_QueueResponseStringOnAIBase(
'ReallyForceSpellRES("BFBTCR",Myself)', sprite)
end)
end
caster.cheatApplied = false
end
end
Expand Down Expand Up @@ -572,19 +634,39 @@ end

--- Stop execution mid-queue.
function BfBot.Exec.Stop()
-- Fast-path recovery from save-reload mid-cast with party composition
-- change (issue #38): hard-reset to idle without entering the cleanup
-- loop — there's nothing to clean up because the buffs were applied to
-- the previous save's party.
if BfBot.Exec._IsStateStale() then
BfBot._Print("[BuffBot] Stale execution state from save reload — resetting.")
BfBot.Exec._HardReset()
BfBot._CloseLog()
return
end

if BfBot.Exec._state ~= "running" then
BfBot._Print("[BuffBot] Not running.")
return
end
BfBot.Exec._state = "stopped"

-- Clean up lingering cheat buffs
-- Clean up lingering cheat buffs. Re-resolve the sprite from the
-- current portrait slot rather than using cached caster.sprite — that
-- userdata wraps a freed CGameSprite pointer if the user reloaded a
-- save mid-cast (issue #38), and pcall does NOT catch the access
-- violation that engine calls would trigger on the freed pointer.
-- This re-resolution is safe even when _IsStateStale missed a same-
-- party reload: BFBTCR is a no-op on targets without an active BFBTCH.
for slot, caster in pairs(BfBot.Exec._casters) do
if caster.cheatApplied and caster.sprite then
pcall(function()
EEex_Action_QueueResponseStringOnAIBase(
'ReallyForceSpellRES("BFBTCR",Myself)', caster.sprite)
end)
if caster.cheatApplied then
local sprite = EEex_Sprite_GetInPortrait(slot)
if sprite then
pcall(function()
EEex_Action_QueueResponseStringOnAIBase(
'ReallyForceSpellRES("BFBTCR",Myself)', sprite)
end)
end
caster.cheatApplied = false
end
end
Expand Down Expand Up @@ -614,6 +696,16 @@ function BfBot.Exec._SafetyTick()
pcall(BfBot.Innate.RefreshAll)
end

-- Proactively recover from save-reload mid-cast (issue #38). The
-- EEex_LuaAction chain that drives _Advance does NOT resume after a
-- save load, so _state stays "running" forever and the UI gates
-- Cast/CastChar off. Detect via portrait-set mismatch and reset so the
-- user sees a clean idle state on next menu open — and so the running
-- branch below falls through to the BFBTCH cleanup loop.
if BfBot.Exec._IsStateStale() then
BfBot.Exec._HardReset()
end

-- If exec engine is actively running, it owns cheat management — don't interfere
if BfBot.Exec._state == "running" then return end

Expand Down
Loading
Loading