Follow along with the video below to see how to install our site as a web app on your home screen.
Note: This feature may not be available in some browsers.

The Events gets the job done but if I could duck casts and save mana, I could shave 1-2 clerics. Oh well.Unfortunately, my friend, it is the same answer as your last question.
Yes, it is possible (but you'd have to modify a function in casting.lua).
No, we don't have a quick toggle for it nor do we plan to.
I am one of those people that kind of leans into thinking that CH rotations should just use a script that does CH rotations, because that's all it needs to do.
This almost sounds like someting you could just script outside, tbh. Slightly more painful then "Let them make a toggle for me", but not sure inserting this into Mercs would be a good call, either. Up to you what those one-two clerics are worth i guess.The Events gets the job done but if I could duck casts and save mana, I could shave 1-2 clerics. Oh well.
CancelHealIfTargetHealthy:
action: /stopcast
condition: >-
${Me.Casting.ID} && ${Target.ID} && ${Target.PctHPs}>=92 && ${Me.CastTimeLeft.TotalSeconds}<=1
&& ${Me.Casting.Name.Equal[Remedy]} || ${Me.Casting.ID} && ${Target.ID}
&& ${Target.PctHPs}>=92 && ${Me.CastTimeLeft.TotalSeconds}<=1 &&
${Me.Casting.Name.Equal[Divine Light]} || ${Me.Casting.ID} && ${Target.ID}
&& ${Target.PctHPs}>=92 && ${Me.CastTimeLeft.TotalSeconds}<=1 &&
${Me.Casting.Name.Equal[Chloroblast]} || ${Me.Casting.ID} && ${Target.ID}
&& ${Target.PctHPs}>=92 && ${Me.CastTimeLeft.TotalSeconds}<=1 &&
${Me.Casting.Name.Equal[Nature's Touch]}
local mq = require('mq')
local ImGui = require('ImGui')
-- CHChainGUI_SitAfterCast.lua
-- Adds large right-side visual heal monitor with top chain controls.
-- No ImGui.Checkbox, no forced window size, no mq.gettime, no mq.delay from GUI callbacks.
local S = {
spellName='Complete Heal', spellGemNumber=1,
chainDelaySeconds=5, spellCastSeconds=10, recoveryBufferSeconds=1.5, memDelaySeconds=12,
stopIfChainTooFast=true, autoSaveOnExit=true, stopOtherLuasOnClerics=false,
cwtnPauseCommand='/clr pause on', cwtnUnpauseCommand='/clr pause off',
enableLateHealInterrupt=true, lateHealInterruptPct=90, lateHealInterruptSecondsLeft=2.0, lateHealInterruptCheckIntervalMs=500, showInterruptEcho=false,
forceSitAfterCast=true, sitDelayAfterHealSeconds=1.0,
openGUI=true, scriptRunning=true, chainRunning=false, preparingChain=false, prepFinishTime=0, prepStartAfterPrep=false,
clerics={}, tanks={}, newClericName='', newTankName='', currentClericIndex=1, currentTankIndex=1,
nextCastTime=0, lastLateHealInterruptCheckTime=0,
activeCasts={}, completedCasts={}, maxCompletedCasts=8, remoteCastStartDelaySeconds=0.8, visualFontScale=1.8,
statusMessage='Idle.', lastCastMessage='None yet.', lastCommandSent='None.', lastErrorMessage='None.', lastEventMessage='None.',
configFile='CHChainGUI_Config.lua', debugLogFile='CHChainGUI_Debug.log',
debugMode=true, debugEchoToMQ=true, debugLogToFile=true, debugLogPulseChecks=false,
commandCounter=0, errorCounter=0, guiErrorCounter=0
}
local function now() return os.clock() end
local function nowMs() return math.floor(os.clock()*1000) end
local function trim(v) if v==nil then return '' end return tostring(v):match('^%s*(.-)%s*$') end
local function q(v) return string.format('%q', tostring(v or '')) end
local function stripSlash(c) return tostring(c or ''):gsub('^/','') end
local function fileExists(p) local f=io.open(p,'r'); if f then f:close(); return true end return false end
local function ts() return os.date('%Y-%m-%d %H:%M:%S') end
local function currentTank()
if #S.tanks==0 then return '' end
if S.currentTankIndex<1 then S.currentTankIndex=1 end
if S.currentTankIndex>#S.tanks then S.currentTankIndex=1 end
return S.tanks[S.currentTankIndex] or ''
end
local function fullCycle() return #S.clerics*S.chainDelaySeconds end
local function minCycle() return S.spellCastSeconds+S.recoveryBufferSeconds end
local function cancelGap() return S.chainDelaySeconds+S.lateHealInterruptSecondsLeft end
local function fmtSec(v) v=tonumber(v) or 0; if v<0 then v=0 end; return string.format('%.1fs',v) end
local function pruneCompleted() S.completedCasts=S.completedCasts or {}; S.maxCompletedCasts=S.maxCompletedCasts or 8; while #S.completedCasts>S.maxCompletedCasts do table.remove(S.completedCasts,1) end end
local function addCastVisual(clericName,tankName)
if S.remoteCastStartDelaySeconds==nil then S.remoteCastStartDelaySeconds=0.8 end
local startTime=now()+S.remoteCastStartDelaySeconds
S.activeCasts=S.activeCasts or {}; table.insert(S.activeCasts,{clericName=tostring(clericName or ''),tankName=tostring(tankName or ''),spellName=tostring(S.spellName or ''),castStartTime=startTime,landTime=startTime+S.spellCastSeconds})
end
local function pulseCastVisuals()
S.activeCasts=S.activeCasts or {}; S.completedCasts=S.completedCasts or {}
local t=now(); local i=1
while i<=#S.activeCasts do
local c=S.activeCasts[i]
if t>=c.landTime then
table.insert(S.completedCasts,{clericName=c.clericName,tankName=c.tankName,spellName=c.spellName,landedTime=t})
table.remove(S.activeCasts,i); pruneCompleted()
else
i=i+1
end
end
end
local function writeLog(m)
if not S.debugMode then return end
m=tostring(m or '')
if S.debugEchoToMQ then mq.cmdf('/echo [CH Debug] %s', m) end
if S.debugLogToFile then
local f,err=io.open(S.debugLogFile,'a')
if f then f:write(string.format('[%s] %s\n', ts(), m)); f:close() else S.lastErrorMessage='Could not write debug log: '..tostring(err) end
end
end
local function logEvent(m) S.lastEventMessage=tostring(m or ''); writeLog('EVENT: '..S.lastEventMessage) end
local function logError(m) S.errorCounter=S.errorCounter+1; S.lastErrorMessage=tostring(m or ''); writeLog('ERROR: '..S.lastErrorMessage) end
local function echo(m) m=tostring(m or ''); mq.cmdf('/echo [CH Chain GUI] %s', m); writeLog('ECHO: '..m) end
local function safe(label, fn) local ok,err=pcall(fn); if not ok then logError(label..': '..tostring(err)) end end
local function summary()
return string.format('Status=%s | LastEvent=%s | LastCommand=%s | LastError=%s | Commands=%d | Errors=%d | GuiErrors=%d | Running=%s | Preparing=%s | Clerics=%d | Tanks=%d | CurrentTank=%s | CurrentCleric=%d | Delay=%.1f | Spell=%s | Gem=%d',
tostring(S.statusMessage), tostring(S.lastEventMessage), tostring(S.lastCommandSent), tostring(S.lastErrorMessage), S.commandCounter, S.errorCounter, S.guiErrorCounter, tostring(S.chainRunning), tostring(S.preparingChain), #S.clerics, #S.tanks, tostring(currentTank()), S.currentClericIndex, S.chainDelaySeconds, tostring(S.spellName), S.spellGemNumber)
end
local function clearLog()
local f,err=io.open(S.debugLogFile,'w')
if f then f:write(string.format('[%s] Debug log cleared.\n',ts())); f:close(); S.lastErrorMessage='None.'; S.errorCounter=0; S.guiErrorCounter=0 else S.lastErrorMessage='Could not clear debug log: '..tostring(err) end
end
local function inputText(label,val) local old=tostring(val or ''); local a,b=ImGui.InputText(label,old); if type(b)=='string' then return b end; if type(a)=='string' then return a end; return old end
local function inputFloat(label,val,step,fast,fmt) local old=tonumber(val) or 0; local a,b=ImGui.InputFloat(label,old,step or 0.1,fast or 1.0,fmt or '%.2f'); if type(b)=='number' then return b end; if type(a)=='number' then return a end; return old end
local function inputInt(label,val) local old=tonumber(val) or 0; local a,b=ImGui.InputInt(label,old); if type(b)=='number' then return b end; if type(a)=='number' then return a end; return old end
local function button(label) local ok,clicked=pcall(function() return ImGui.Button(label) end); if not ok then S.guiErrorCounter=S.guiErrorCounter+1; S.lastErrorMessage='Button failed ['..tostring(label)..']: '..tostring(clicked); return false end; return clicked==true end
local function sameLine() pcall(function() ImGui.SameLine() end) end
local function toggle(label,val) local old=val==true; ImGui.Text(label..': '..(old and 'ON' or 'OFF')); sameLine(); if button('Toggle##'..label) then return not old end; return old end
local function bc(char,cmd,suppress)
char=trim(char); cmd=stripSlash(cmd)
if char=='' then logError('bc blocked: blank character. Command='..tostring(cmd)); return end
if cmd=='' then logError('bc blocked: blank command. Character='..tostring(char)); return end
local full=string.format('/noparse /bct %s //%s',char,cmd)
S.commandCounter=S.commandCounter+1
if not suppress then S.lastCommandSent=full; writeLog('COMMAND #'..S.commandCounter..': '..full) end
mq.cmd(full)
end
local function inRaid(name)
name=trim(name); if name=='' then return false end
local n=mq.TLO.Raid.Members(); if n==nil or n<=0 then return false end
local ok,rn=pcall(function() return mq.TLO.Raid.Member(name).Name() end)
return ok and rn~=nil and rn~=''
end
local function validateRaid()
local missing={}
for _,n in ipairs(S.clerics) do if not inRaid(n) then table.insert(missing,'Cleric not found in raid: '..n) end end
for _,n in ipairs(S.tanks) do if not inRaid(n) then table.insert(missing,'Tank not found in raid: '..n) end end
if #missing>0 then S.statusMessage=table.concat(missing,' | '); echo(S.statusMessage); logError(S.statusMessage); return false end
return true
end
local function timingStatus()
if #S.clerics<=0 then return false,'No clerics are configured.' end
if #S.tanks<=0 then return false,'No tanks are configured.' end
if S.chainDelaySeconds<=0 then return false,'Chain delay must be greater than 0.' end
if fullCycle()<minCycle() then return false,string.format('TOO FAST: full cycle %.1fs, minimum %.1fs. Short by %.1fs.',fullCycle(),minCycle(),minCycle()-fullCycle()) end
return true,string.format('Safe: full cycle %.1fs, minimum %.1fs.',fullCycle(),minCycle())
end
local function saveSettings()
local f,err=io.open(S.configFile,'w')
if not f then S.statusMessage='Failed to save settings: '..tostring(err); echo(S.statusMessage); logError(S.statusMessage); return end
f:write('return {\n')
local fields={'spellName','spellGemNumber','chainDelaySeconds','spellCastSeconds','recoveryBufferSeconds','memDelaySeconds','stopIfChainTooFast','autoSaveOnExit','stopOtherLuasOnClerics','cwtnPauseCommand','cwtnUnpauseCommand','currentTankIndex','enableLateHealInterrupt','lateHealInterruptPct','lateHealInterruptSecondsLeft','lateHealInterruptCheckIntervalMs','debugMode','debugEchoToMQ','debugLogToFile','debugLogPulseChecks','forceSitAfterCast','sitDelayAfterHealSeconds'}
for _,k in ipairs(fields) do local v=S[k]; if type(v)=='string' then f:write(string.format('\t%s = %s,\n',k,q(v))) else f:write(string.format('\t%s = %s,\n',k,tostring(v))) end end
f:write('\tclerics = {\n'); for _,n in ipairs(S.clerics) do f:write(string.format('\t\t%s,\n',q(n))) end; f:write('\t},\n')
f:write('\ttanks = {\n'); for _,n in ipairs(S.tanks) do f:write(string.format('\t\t%s,\n',q(n))) end; f:write('\t},\n')
f:write('}\n'); f:close(); S.statusMessage='Settings saved.'; writeLog('Settings saved to '..S.configFile)
end
local function loadSettings()
if not fileExists(S.configFile) then S.statusMessage='No saved settings found. Configure in GUI, then click Save Settings.'; return end
local lf,le=loadfile(S.configFile); if not lf then S.statusMessage='Could not load settings file: '..tostring(le); echo(S.statusMessage); logError(S.statusMessage); return end
local ok,cfg=pcall(lf); if not ok or type(cfg)~='table' then S.statusMessage='Saved settings file exists, but could not load.'; echo(S.statusMessage); logError(S.statusMessage); return end
for k,v in pairs(cfg) do
if k=='clerics' and type(v)=='table' then S.clerics=v
elseif k=='tanks' and type(v)=='table' then S.tanks=v
elseif S[k]~=nil then
if type(S[k])=='boolean' and type(v)=='boolean' then S[k]=v
elseif type(S[k])=='number' then S[k]=tonumber(v) or S[k]
elseif type(S[k])=='string' and type(v)=='string' then S[k]=v end
end
end
if S.currentTankIndex<1 then S.currentTankIndex=1 end; if S.currentTankIndex>#S.tanks then S.currentTankIndex=1 end
S.statusMessage='Settings loaded.'
end
local function resetDefaults()
S.spellName='Complete Heal'; S.spellGemNumber=1; S.chainDelaySeconds=5; S.spellCastSeconds=10; S.recoveryBufferSeconds=1.5; S.memDelaySeconds=12
S.stopIfChainTooFast=true; S.autoSaveOnExit=true; S.stopOtherLuasOnClerics=false; S.cwtnPauseCommand='/clr pause on'; S.cwtnUnpauseCommand='/clr pause off'
S.enableLateHealInterrupt=true; S.lateHealInterruptPct=90; S.lateHealInterruptSecondsLeft=2.0; S.lateHealInterruptCheckIntervalMs=500
S.debugMode=true; S.debugEchoToMQ=true; S.debugLogToFile=true; S.debugLogPulseChecks=false; S.statusMessage='Defaults restored. Click Save Settings to keep them.'
end
local function addCleric() local n=trim(S.newClericName); if n=='' then S.statusMessage='Enter a cleric name first.'; return end; if not inRaid(n) then S.statusMessage='Cleric not found in raid: '..n; echo(S.statusMessage); logError(S.statusMessage); return end; table.insert(S.clerics,n); S.newClericName=''; S.statusMessage='Added cleric: '..n; logEvent(S.statusMessage); saveSettings() end
local function addTank() local n=trim(S.newTankName); if n=='' then S.statusMessage='Enter a tank name first.'; return end; if not inRaid(n) then S.statusMessage='Tank not found in raid: '..n; echo(S.statusMessage); logError(S.statusMessage); return end; table.insert(S.tanks,n); if #S.tanks==1 then S.currentTankIndex=1 end; S.newTankName=''; S.statusMessage='Added tank: '..n; logEvent(S.statusMessage); saveSettings() end
local function moveUp(list,i) if i<=1 then return end; list[i],list[i-1]=list[i-1],list[i]; saveSettings() end
local function moveDown(list,i) if i>=#list then return end; list[i],list[i+1]=list[i+1],list[i]; saveSettings() end
local function remCleric(i) table.remove(S.clerics,i); if S.currentClericIndex>#S.clerics then S.currentClericIndex=1 end; S.statusMessage='Removed cleric.'; logEvent(S.statusMessage); saveSettings() end
local function remTank(i) table.remove(S.tanks,i); if S.currentTankIndex>#S.tanks then S.currentTankIndex=1 end; if S.currentTankIndex<1 then S.currentTankIndex=1 end; S.statusMessage='Removed tank.'; logEvent(S.statusMessage); saveSettings() end
local function setTank(i) if i<1 or i>#S.tanks then return end; S.currentTankIndex=i; S.statusMessage='Current tank set to: '..currentTank(); logEvent(S.statusMessage); echo(S.statusMessage); saveSettings() end
local function pauseClerics() for _,n in ipairs(S.clerics) do bc(n,S.cwtnPauseCommand) end end
local function unpauseClerics() for _,n in ipairs(S.clerics) do bc(n,S.cwtnUnpauseCommand) end end
local function endMacros() for _,n in ipairs(S.clerics) do bc(n,'/endmacro') end end
local function stopLuas() S.statusMessage='Lua stop skipped for safety.'; logEvent(S.statusMessage) end
local function memCH() for _,n in ipairs(S.clerics) do bc(n,string.format('/memspell %d "%s"',S.spellGemNumber,S.spellName)) end end
local function buildInterruptCondition()
return string.format(
'${Me.Casting.ID} && ${Target.ID} && ${Target.PctHPs}>=%.0f && ${Me.CastTimeLeft.TotalSeconds}<=%.2f && ${Me.Casting.Name.Equal[%s]}',
S.lateHealInterruptPct,
S.lateHealInterruptSecondsLeft,
S.spellName
)
end
local function safeSitCondition()
return '!${Me.Casting.ID} && !${Me.Sitting} && !${Me.Moving}'
end
local function scheduledSitAfterExpectedLandCommand()
if S.forceSitAfterCast ~= true then
return ''
end
local delaySeconds = tonumber(S.sitDelayAfterHealSeconds) or 1.0
if delaySeconds < 0 then delaySeconds = 0 end
if delaySeconds > 10 then delaySeconds = 10 end
-- /timed uses tenths of a second.
-- /timed 8 starts the cast, then 10s cast, then user-configurable sit delay.
local sitTick = 8 + math.floor((S.spellCastSeconds + delaySeconds) * 10)
return string.format(' ; /timed %d /if (%s) /sit', sitTick, safeSitCondition())
end
local function scheduledInterruptCommands()
local cond = buildInterruptCondition()
local commands = {}
-- These timings are tenths of a second from receipt of the remote command.
-- /timed 8 starts the cast. These checks happen late in the CH cast.
-- No echo here; it was too spammy in the cleric MQ windows.
local checks = {70, 75, 80, 85, 90, 95, 100}
for _, tick in ipairs(checks) do
table.insert(commands, string.format('/timed %d /if (%s) /stopcast', tick, cond))
if S.forceSitAfterCast == true then
table.insert(commands, string.format('/timed %d /if (%s) /sit', tick + 5, safeSitCondition()))
end
end
return table.concat(commands, ' ; ')
end
local function castCH(char)
local tank=currentTank(); if tank=='' then S.statusMessage='No current tank.'; logError(S.statusMessage); return end
if char==nil or trim(char)=='' then S.statusMessage='No cleric name for current chain slot.'; logError(S.statusMessage); return end
S.lastCastMessage=string.format('%s casting %s on %s',char,S.spellName,tank); logEvent(S.lastCastMessage); addCastVisual(char,tank)
local command=''
if S.enableLateHealInterrupt == true then
local interruptCommands=scheduledInterruptCommands()
command=string.format('/multiline ; /target clear ; /timed 3 /target %s ; /timed 8 /cast %d ; %s%s',tank,S.spellGemNumber,interruptCommands,scheduledSitAfterExpectedLandCommand())
else
command=string.format('/multiline ; /target clear ; /timed 3 /target %s ; /timed 8 /cast %d%s',tank,S.spellGemNumber,scheduledSitAfterExpectedLandCommand())
end
bc(char,command)
end
local function beginPrep(startAfter)
S.preparingChain=true; S.chainRunning=false; S.prepFinishTime=now()+S.memDelaySeconds; S.prepStartAfterPrep=startAfter==true
S.statusMessage='Preparing clerics: pausing CWTN, ending macros, memming spell.'; logEvent(S.statusMessage)
pauseClerics(); endMacros(); if S.stopOtherLuasOnClerics then stopLuas() end; memCH()
if S.prepStartAfterPrep then S.statusMessage=string.format('Waiting %.1f seconds, then chain starts.',S.memDelaySeconds) else S.statusMessage=string.format('Waiting %.1f seconds for spell mem.',S.memDelaySeconds) end
end
local function startChain()
if S.preparingChain then S.statusMessage='Preparation is already running.'; echo(S.statusMessage); return end
local ok,msg=timingStatus(); S.statusMessage=msg; if not ok and S.stopIfChainTooFast then echo(msg); echo('Chain refused to start. Increase delay or add clerics.'); logError('Unsafe timing.'); return end
if not validateRaid() then return end
beginPrep(true)
end
local function stopChain() S.chainRunning=false; S.preparingChain=false; S.prepFinishTime=0; S.prepStartAfterPrep=false; S.statusMessage='Chain stopped.'; logEvent(S.statusMessage); echo(S.statusMessage) end
local function stopUnpause() stopChain(); unpauseClerics(); echo('Chain stopped and listed clerics unpaused.') end
local function swapTank()
if #S.tanks<=0 then
S.statusMessage='No tanks configured.'
logError(S.statusMessage)
echo(S.statusMessage)
return
end
local old=currentTank()
S.currentTankIndex=tonumber(S.currentTankIndex) or 1
S.currentTankIndex=S.currentTankIndex+1
if S.currentTankIndex>#S.tanks then
S.currentTankIndex=1
end
if S.currentTankIndex<1 then
S.currentTankIndex=1
end
S.statusMessage='Swapped future heals from '..tostring(old)..' to tank: '..currentTank()
logEvent(S.statusMessage)
echo(S.statusMessage)
saveSettings()
end
local function pulsePrep() if not S.preparingChain then return end; if now()<S.prepFinishTime then return end; S.preparingChain=false; if S.prepStartAfterPrep then S.currentClericIndex=1; S.nextCastTime=0; S.chainRunning=true; S.statusMessage='Chain running.'; logEvent(S.statusMessage); echo('Complete Heal chain started.') else S.statusMessage='Preparation complete.'; logEvent(S.statusMessage) end; S.prepStartAfterPrep=false end
local function pulseChain()
if not S.chainRunning or S.preparingChain then return end
local ok,msg=timingStatus(); S.statusMessage='Chain running. '..msg; if not ok and S.stopIfChainTooFast then echo('Timing became unsafe while running. Stopping chain.'); logError('Unsafe timing while running.'); stopChain(); return end
local t=now(); if S.nextCastTime==0 or t>=S.nextCastTime then local c=S.clerics[S.currentClericIndex]; if c and c~='' then castCH(c) end; S.currentClericIndex=S.currentClericIndex+1; if S.currentClericIndex>#S.clerics then S.currentClericIndex=1 end; S.nextCastTime=t+S.chainDelaySeconds end
end
-- Late interrupt sends ${Me...} checks to each cleric. bc() uses /noparse so these evaluate on the cleric, not the controller.
local function pulseInterrupts()
-- Live pulse interrupts intentionally disabled.
-- Interrupt checks are only attached to each CH cast when Late Heal Interrupt is ON.
return
end
local function slash(...)
local args=trim(table.concat({...},' ')); if args=='' or args=='help' then echo('/chchain start | stop | stopunpause | swap | status | summary | gui | exit | debug on | debug off'); return end
if args=='start' then startChain(); return end; if args=='stop' then stopChain(); return end; if args=='stopunpause' then stopUnpause(); return end; if args=='swap' then swapTank(); return end
if args=='status' then echo(S.statusMessage); echo('Current tank: '..currentTank()); echo('Last cast: '..S.lastCastMessage); return end
if args=='summary' then local s=summary(); echo(s); writeLog(s); return end
if args=='debug on' then S.debugMode=true; saveSettings(); echo('Debug mode ON.'); return end
if args=='debug off' then S.debugMode=false; saveSettings(); echo('Debug mode OFF.'); return end
echo('Unknown command: '..args)
end
local function chainControlCastNext()
if #S.clerics>0 then
castCH(S.clerics[S.currentClericIndex])
S.currentClericIndex=S.currentClericIndex+1
if S.currentClericIndex>#S.clerics then S.currentClericIndex=1 end
S.nextCastTime=now()+S.chainDelaySeconds
end
end
local function chainControlCastNext()
if #S.clerics>0 then
castCH(S.clerics[S.currentClericIndex])
S.currentClericIndex=S.currentClericIndex+1
if S.currentClericIndex>#S.clerics then S.currentClericIndex=1 end
S.nextCastTime=now()+S.chainDelaySeconds
end
end
local COLOR_CLERIC = {0.2, 1.0, 0.2, 1.0}
local COLOR_TANK = {1.0, 1.0, 0.1, 1.0}
local COLOR_GREEN = {0.2, 1.0, 0.2, 1.0}
local COLOR_RED = {1.0, 0.15, 0.15, 1.0}
local COLOR_NORMAL = {1.0, 1.0, 1.0, 1.0}
local function coloredText(text, color)
text = tostring(text or '')
color = color or COLOR_NORMAL
if ImGui.TextColored ~= nil then
local ok = pcall(function()
ImGui.TextColored(color[1], color[2], color[3], color[4], text)
end)
if ok then return end
ok = pcall(function()
ImGui.TextColored(color, text)
end)
if ok then return end
end
ImGui.Text(text)
end
local function clericText(text)
coloredText(text, COLOR_CLERIC)
end
local function tankText(text)
coloredText(text, COLOR_TANK)
end
local function greenText(text)
coloredText(text, COLOR_GREEN)
end
local function redText(text)
coloredText(text, COLOR_RED)
end
local function normalText(text)
coloredText(text, COLOR_NORMAL)
end
local function labelAndColoredValue(label, value, color)
ImGui.Text(tostring(label or ''))
sameLine()
coloredText(tostring(value or ''), color)
end
local function drawChainControls(idSuffix, stacked, includeSwapTank)
idSuffix = tostring(idSuffix or 'Main')
includeSwapTank = includeSwapTank == true
ImGui.Text('Chain Control')
if not S.chainRunning and not S.preparingChain then
if button('Start / Prep / Mem / Run Chain##'..idSuffix..'Start') then startChain() end
if not stacked then sameLine() end
if button('Prep / Mem Only##'..idSuffix..'PrepOnly') then beginPrep(false) end
else
if button('Stop Chain##'..idSuffix..'Stop') then stopChain() end
end
if includeSwapTank then
if not stacked then sameLine() end
if button('Swap Tank##'..idSuffix..'SwapTank') then swapTank() end
end
if not stacked then sameLine() end
if button('Stop + Unpause Clerics##'..idSuffix..'StopUnpause') then stopUnpause() end
if not stacked then sameLine() end
if button('Cast Next Cleric Now##'..idSuffix..'CastNext') then
chainControlCastNext()
end
end
local function visualText(text)
ImGui.Text(tostring(text or ''))
end
local function drawBigCastMonitor()
local oldScaleSet=false
pcall(function() S.activeCasts=S.activeCasts or {}; S.completedCasts=S.completedCasts or {}; if S.remoteCastStartDelaySeconds==nil then S.remoteCastStartDelaySeconds=0.8 end; if S.visualFontScale==nil then S.visualFontScale=1.8 end; if S.visualFontScale<1 then S.visualFontScale=1 end; if S.visualFontScale>3 then S.visualFontScale=3 end; ImGui.SetWindowFontScale(S.visualFontScale); oldScaleSet=true end)
visualText('HEAL CAST MONITOR')
ImGui.Separator()
local t=now()
labelAndColoredValue('Tank:', currentTank(), COLOR_TANK)
if S.preparingChain then
greenText('PREP/MEM: '..fmtSec(S.prepFinishTime-t)..' remaining')
end
ImGui.Separator()
drawChainControls('RightMonitor', true, true)
ImGui.Separator()
S.activeCasts=S.activeCasts or {}; S.completedCasts=S.completedCasts or {}
if #S.activeCasts==0 then
if S.forceSitAfterCast then greenText('Auto-sit after cast/interruption: ON') else redText('Auto-sit after cast/interruption: OFF') end
visualText('ACTIVE CASTS: none')
else
visualText('ACTIVE CASTS:')
for i=1,#S.activeCasts do
local c=S.activeCasts[i]
local total=math.max(0.1,c.landTime-c.castStartTime)
local remaining=math.max(0,c.landTime-t)
local elapsed=total-remaining
if t<c.castStartTime then elapsed=0; remaining=c.landTime-t end
local pct=elapsed/total
if pct<0 then pct=0 end
if pct>1 then pct=1 end
clericText(tostring(c.clericName))
tankText(' -> '..tostring(c.tankName))
redText(tostring(c.spellName)..' lands in '..fmtSec(remaining))
if ImGui.ProgressBar~=nil then
local ok=pcall(function() ImGui.ProgressBar(pct, 260, 26) end)
if not ok then
local bars=math.floor(pct*20); visualText('['..string.rep('#',bars)..string.rep('-',20-bars)..']')
end
else
local bars=math.floor(pct*20); visualText('['..string.rep('#',bars)..string.rep('-',20-bars)..']')
end
end
end
-- Recent landed heals intentionally hidden to keep the driver monitor simple.
pcall(function() ImGui.SetWindowFontScale(1.0) end)
end
local function drawLeftConfig()
local ok,msg=timingStatus()
ImGui.Text('Complete Heal Chain'); ImGui.Text('Status: '..tostring(S.statusMessage)); ImGui.Text('Timing: '..tostring(msg)); ImGui.Text('Current Tank: '..tostring(currentTank())); ImGui.Text('Last Cast: '..tostring(S.lastCastMessage))
if S.preparingChain then greenText(string.format('Prep wait remaining: %.1f seconds',math.max(0,S.prepFinishTime-now()))) end
ImGui.Separator(); ImGui.Text('Spell / Timing')
S.spellName=inputText('Spell Name',S.spellName); S.spellGemNumber=inputInt('Spell Gem Number',S.spellGemNumber); S.chainDelaySeconds=inputFloat('Delay Between Clerics Seconds',S.chainDelaySeconds,0.5,1.0,'%.1f'); S.spellCastSeconds=10; S.recoveryBufferSeconds=1.5; S.memDelaySeconds=12; ImGui.Text('Spell Cast Seconds: 10.0'); ImGui.Text('Spell Mem Wait Seconds: 12.0'); ImGui.Text('Recovery Buffer Seconds: 1.5')
if S.spellGemNumber<1 then S.spellGemNumber=1 end; if S.spellGemNumber>13 then S.spellGemNumber=13 end
S.stopIfChainTooFast=toggle('Refuse Start If Chain Too Fast',S.stopIfChainTooFast)
ImGui.Separator(); ImGui.Text('Late Heal Interrupt')
S.enableLateHealInterrupt=toggle('Enable Late Heal Interrupt',S.enableLateHealInterrupt); if S.enableLateHealInterrupt == true then greenText('Late interrupt is ON') else redText('Late interrupt is OFF - no new interrupt timers will be sent') end; S.lateHealInterruptPct=inputFloat('Interrupt If Target HP >=',S.lateHealInterruptPct,1.0,5.0,'%.0f'); S.lateHealInterruptSecondsLeft=inputFloat('Interrupt Only If Cast Seconds Left <=',S.lateHealInterruptSecondsLeft,0.25,1.0,'%.2f'); S.lateHealInterruptCheckIntervalMs=inputInt('Interrupt Check Interval MS',S.lateHealInterruptCheckIntervalMs)
ImGui.Separator(); ImGui.Text('Sit After Heal / Interrupt')
S.forceSitAfterCast=toggle('Force Sit After Cast Or Interrupt',S.forceSitAfterCast)
S.sitDelayAfterHealSeconds=inputFloat('Sit Delay After Heal Lands Seconds',S.sitDelayAfterHealSeconds,0.5,1.0,'%.1f')
if S.sitDelayAfterHealSeconds<0 then S.sitDelayAfterHealSeconds=0 end
if S.sitDelayAfterHealSeconds>10 then S.sitDelayAfterHealSeconds=10 end; ImGui.Text(string.format('Cancel gap max: %.1f seconds',cancelGap()))
ImGui.Separator(); ImGui.Text('Tanks'); S.newTankName=inputText('New Tank Name',S.newTankName); if button('Add Tank From Raid') then addTank() end
for i=1,#S.tanks do tankText(string.format('%d. %s',i,S.tanks[i])); sameLine(); if button('Set Tank '..i) then setTank(i) end; sameLine(); if button('Up T'..i) then moveUp(S.tanks,i) end; sameLine(); if button('Down T'..i) then moveDown(S.tanks,i) end; sameLine(); if button('Remove T'..i) then remTank(i); break end end
ImGui.Separator(); ImGui.Text('Cleric Chain Order'); S.newClericName=inputText('New Cleric Name',S.newClericName); if button('Add Cleric From Raid') then addCleric() end
for i=1,#S.clerics do clericText(string.format('%d. %s',i,S.clerics[i])); sameLine(); if button('Up C'..i) then moveUp(S.clerics,i) end; sameLine(); if button('Down C'..i) then moveDown(S.clerics,i) end; sameLine(); if button('Remove C'..i) then remCleric(i); break end end
ImGui.Separator(); ImGui.Text('CWTN / MQ2Cleric'); S.cwtnPauseCommand=inputText('Pause Command',S.cwtnPauseCommand); S.cwtnUnpauseCommand=inputText('Unpause Command',S.cwtnUnpauseCommand); if button('Pause Chain Clerics Now') then pauseClerics() end; sameLine(); if button('Unpause Chain Clerics Now') then unpauseClerics() end; sameLine(); if button('End Macros On Chain Clerics') then endMacros() end
ImGui.Separator(); ImGui.Text('Settings / Debug'); if button('Save Settings') then saveSettings() end; sameLine(); if button('Reload Settings') then loadSettings() end; sameLine(); if button('Reset Defaults') then resetDefaults() end; sameLine(); if button('Clear Debug Log') then clearLog() end
S.debugMode=toggle('Enable Debug Mode',S.debugMode); S.debugEchoToMQ=toggle('Debug Echo To MQ Window',S.debugEchoToMQ); S.debugLogToFile=toggle('Debug Log To File',S.debugLogToFile); S.debugLogPulseChecks=toggle('Log Spammy Pulse Checks',S.debugLogPulseChecks); S.autoSaveOnExit=toggle('Auto Save On Exit',S.autoSaveOnExit); S.visualFontScale=inputFloat('Right Monitor Font Scale',S.visualFontScale or 1.8,0.1,0.5,'%.1f')
ImGui.Text('Last Event: '..tostring(S.lastEventMessage)); ImGui.Text('Last Command: '..tostring(S.lastCommandSent)); ImGui.Text('Last Error: '..tostring(S.lastErrorMessage)); ImGui.Text(string.format('Commands Sent: %d',S.commandCounter)); ImGui.Text(string.format('Errors: %d',S.errorCounter)); ImGui.Text(string.format('GUI Errors: %d',S.guiErrorCounter))
end
local function drawBody()
-- Two-column layout. Force the right monitor back to the top of column 2.
-- Some MQ ImGui builds do not automatically reset Y on NextColumn(), which caused
-- the monitor to draw at the bottom/right instead of the top/right.
local startY = nil
pcall(function()
if ImGui.GetCursorPosY ~= nil then
startY = ImGui.GetCursorPosY()
end
end)
if ImGui.Columns ~= nil then
ImGui.Columns(2, 'CHChainMainColumns', true)
pcall(function()
local winWidth = 1000
if ImGui.GetWindowWidth ~= nil then
winWidth = ImGui.GetWindowWidth()
end
if ImGui.SetColumnWidth ~= nil then
ImGui.SetColumnWidth(0, winWidth * 0.58)
end
end)
drawLeftConfig()
ImGui.NextColumn()
pcall(function()
if startY ~= nil and ImGui.SetCursorPosY ~= nil then
ImGui.SetCursorPosY(startY)
end
end)
drawBigCastMonitor()
ImGui.Columns(1)
else
drawLeftConfig()
ImGui.Separator()
drawBigCastMonitor()
end
end
local function drawGUI()
if not S.openGUI then return end
-- Do not pass S.openGUI into ImGui.Begin.
-- This MQ ImGui build was flipping S.openGUI false/nil from the Begin return values,
-- which made the script keep running but the GUI disappear after one frame.
local visible = true
local okBegin, beginResult = pcall(function()
return ImGui.Begin('Complete Heal Chain GUI')
end)
if not okBegin then
S.guiErrorCounter = S.guiErrorCounter + 1
S.lastErrorMessage = 'ImGui.Begin failed: '..tostring(beginResult)
mq.cmd('/echo [CH GUI ERROR] '..tostring(S.lastErrorMessage))
return
end
if type(beginResult) == 'boolean' then
visible = beginResult
end
if visible then
local ok,err=pcall(drawBody)
if not ok then
S.guiErrorCounter=S.guiErrorCounter+1
S.lastErrorMessage='GUI body failed: '..tostring(err)
mq.cmd('/echo [CH GUI ERROR] '..tostring(err))
end
end
ImGui.End()
end
loadSettings()
mq.bind('/chchain',slash)
mq.imgui.init('CHChainGUI',drawGUI)
echo('Loaded. Use GUI or /chchain help.')
while S.scriptRunning do safe('pulsePrep',pulsePrep); safe('pulseChain',pulseChain); safe('pulseCastVisuals',pulseCastVisuals); mq.delay(50) end
if S.chainRunning then stopChain() end
if S.autoSaveOnExit then saveSettings() end
echo('Exited.')