Apply Patch

This commit is contained in:
2026-10-30 19:40:16 +00:00
parent cb154ecf4e
commit 9cb517a2ab
19 changed files with 6657 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
<Bindings>
<Binding name="LOCKPORT_SUMMON_NEXT" header="LOCKPORT">
LockPort:SummonNext()
</Binding>
<Binding name="LOCKPORT_TOGGLE">
LockPort:Toggle()
</Binding>
</Bindings>
+63
View File
@@ -0,0 +1,63 @@
# Changelog
## 3.9.3
Fixed invalid chat escape errors from coordination messages when joining a group.
## 3.9.2
Keep Groups visible and greyed out until the player joins a raid.
## 3.9.1
Fixed the repository layout so fresh addon-manager installations appear as **LockPort**.
## 3.9.0
- Added the raid-group picker beside the renamed **Auto** button.
- Queue selected groups without requiring chat requests.
- Saved per-character group assignments filter manual and automatic summons.
## 3.8.0
- Added target reservations and acknowledgements for multiple automatic casters.
- Added independent-client simulations and older-version compatibility checks.
- Documented mixed-version and communication-loss limits.
## 3.7.8
Shortened the main action to **Summon** and restored equal button widths after label updates.
## 3.7.7
Matched the Chat and Summon button widths to Options and Add Target.
## 3.7.6
Added the persistent **Chat: On/Off** toggle to mute outgoing announcements and whispers.
## 3.7.5
Added optional ClassicAPI `GetCurrentChatEvent()` support with a legacy fallback.
## 3.7.4
Availability announcements now reset alongside Auto Summon after login/reload;
turning Auto off always clears the announcement option.
## 3.7.3
Added consistent **[LockPort]** prefixes to all outgoing player-facing chat.
## 3.7.2
Updated DLL 1.2.1 to preserve and restore the client command context around native casts.
Automatic summoning was confirmed working in-game with this DLL.
## Earlier updates in this fork
- Added optional Auto Summon with party and raid support.
- Avoided false failure announcements caused solely by an interrupted channel.
- Expanded Options and added ClassicAPI/LockPort detection.
DLL **1.2.1** remains compatible with addon versions 3.7.2 through 3.9.3.
+64
View File
@@ -0,0 +1,64 @@
# ClassicAPI 1.17.6 follow-up
Compared tagged v1.17.1 with v1.17.6. LockPort 3.7.5 optionally uses the new
GetCurrentChatEvent() for summon-request message and sender values. The reader
is feature-detected, protected with pcall, and falls back to snapshotted legacy
arguments if unavailable, empty, or failing. It is used only in the supported
request-chat event branch; spell and addon-sync events keep their original paths.
Roster membership remains mandatory. The DLL stays at 1.2.1.
Regression coverage includes overwritten globals, legacy fallback, reader errors,
non-trigger payloads, nested spell events, and addon synchronization.
The new profiling APIs are useful for developer diagnostics, but are not enabled
or queried automatically. The modifier-key fix benefits Lua bindings upstream.
Player-cache improvements are unnecessary for the current group roster logic.
Sources:
- https://github.com/brues-code/ClassicAPI/releases/tag/v1.17.6
- https://github.com/brues-code/ClassicAPI/compare/v1.17.1...v1.17.6
- https://github.com/brues-code/ClassicAPI/blob/v1.17.6/docs/API.md#getcurrentchatevent
---
# ClassicAPI 1.17.1 review
Reviewed the tagged 1.17.1 API documentation, release, and source comparison
against 1.16.0, rather than assuming the current default branch matches the release.
## What changed
The 1.17.1 patch itself replaces the Cooldown frame's Model-based indicator with
native cooldown drawing (sweep, flash, reverse, and edge behavior). LockPort has
no cooldown-sweep UI, so this patch offers no direct summon fix.
The broader 1.16.0 -> 1.17.1 comparison includes corrected spell-failure events:
UNIT_SPELLCAST_INTERRUPTED versus UNIT_SPELLCAST_FAILED follows the engine's
failure reason, with improved cast identity and stop handling.
These are useful for a future event-tracking revision, but a successful ritual
spell event still needs interpretation: starting/completing the portal and the
target actually arriving are different stages. This build keeps the conservative
arrival/uncertain-completion fix rather than mixing another event stream into it.
## Useful APIs used in this build
Verified present in 1.17.1:
- GetUnitSpeed("player"): pause automatic summons while moving.
- C_Spell.UnitCastingInfo("player"): pause while another regular cast is active.
- C_Spell.UnitChannelInfo("player"): pause while another channel is active.
These are optional, feature-detected improvements. Their presence in 1.17.1 does
not mean they were newly introduced in 1.17.1. LockPort continues to work without
ClassicAPI, using its existing cast-event tracking.
UnitXP already supplies accurate distance information in your setup, so another
distance provider would add little value.
## Primary sources
- Release: https://github.com/brues-code/ClassicAPI/releases/tag/v1.17.1
- Exact patch: https://github.com/brues-code/ClassicAPI/compare/v1.17.0...v1.17.1
- Since 1.16.0: https://github.com/brues-code/ClassicAPI/compare/v1.16.0...v1.17.1
- Tagged API reference: https://github.com/brues-code/ClassicAPI/blob/v1.17.1/docs/API.md
- Spell-event correction: https://github.com/brues-code/ClassicAPI/commit/ff57450b7c7fc6b4ae9d3d8a58d51f654ccd1bd4
- Native function ABI/offset reference: https://github.com/brues-code/nampower/blob/main/nampower/main.hpp
- Native offset reference: https://github.com/brues-code/nampower/blob/main/nampower/offsets.hpp
BIN
View File
Binary file not shown.
+1719
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
## Interface: 11200
## Title: |cff9482c9LockPort|r
## Notes: Warlock summon assistant - 123 in chat to get on the list, click to summon, failures are explained to the group.
## Version: 3.9.3
## Author: Phaestas (originally Gurky)
## SavedVariablesPerCharacter: LockPortOptions
LockPort.lua
LockPort_UI.lua
+865
View File
@@ -0,0 +1,865 @@
--[[
LockPort UI - built from the stock 1.12 Blizzard art so it looks like part of the game:
dialog border + header plate, gold GameFont text, class icons from the character
creation sheet, UIPanelButtons, UICheckButtons and an InputBox.
]]
local LP = LockPort
local MAX_ROWS = 10
local ROW_HEIGHT = 18
local FRAME_W = 230
local INNER_W = FRAME_W - 36
local CLASS_ICONS = "Interface\\Glues\\CharacterCreate\\UI-CharacterCreate-Classes"
local CLASS_COORDS = {
WARRIOR = { 0, 0.25, 0, 0.25 }, MAGE = { 0.25, 0.49609375, 0, 0.25 },
ROGUE = { 0.49609375, 0.7421875, 0, 0.25 }, DRUID = { 0.7421875, 0.98828125, 0, 0.25 },
HUNTER = { 0, 0.25, 0.25, 0.5 }, SHAMAN = { 0.25, 0.49609375, 0.25, 0.5 },
PRIEST = { 0.49609375, 0.7421875, 0.25, 0.5 }, WARLOCK = { 0.7421875, 0.98828125, 0.25, 0.5 },
PALADIN = { 0, 0.25, 0.5, 0.75 },
}
local DIALOG_BACKDROP = {
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true, tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
}
---------------------------------------------------------------------------
-- Window styles: "classic" (Blizzard dialog art) or "pfui" (flat, dark, like
-- the original LockPort; uses pfUI's own skinning + font when pfUI is loaded).
-- Picked once when the UI is built, so switching needs a /reload.
---------------------------------------------------------------------------
local FLAT = false
local PF = nil
local FONT = "Fonts\\ARIALN.TTF"
local WHITE = "Interface\\Buttons\\WHITE8X8"
local OLD_TITLE = "|cffb700b7L|cffff00ffo|cffff50ffc|cffff99ffk|cffffc4ffP|cffffffffort|r"
local FLAT_BACKDROP = {
bgFile = WHITE, edgeFile = WHITE, tile = false, tileSize = 0, edgeSize = 1,
insets = { left = 0, right = 0, top = 0, bottom = 0 },
}
local function SetupTheme()
FLAT = (LockPortOptions.theme == "pfui")
PF = pfUI and pfUI.api
if FLAT and pfUI and type(pfUI.font_default) == "string" then FONT = pfUI.font_default end
end
local function Font(fs, size)
if FLAT and fs then fs:SetFont(FONT, size) end
end
local function FlatBackdrop(f, alpha)
f:SetBackdrop(FLAT_BACKDROP)
f:SetBackdropColor(0, 0, 0, alpha or 0.75)
f:SetBackdropBorderColor(0.2, 0.2, 0.2, 1)
end
-- divider / accent colour per style
local function AccentLine(tex)
if FLAT then tex:SetTexture(1, 1, 1, 0.12) else tex:SetTexture(1, 0.82, 0, 0.25) end
end
local function SkinButton(b)
if not FLAT then return end
if PF and PF.SkinButton and pcall(PF.SkinButton, b) then return end
b:SetNormalTexture("")
b:SetPushedTexture("")
b:SetHighlightTexture("")
b:SetDisabledTexture("")
FlatBackdrop(b, 0.6)
local hl = b:CreateTexture(nil, "HIGHLIGHT")
hl:SetTexture(1, 1, 1, 0.12)
hl:SetPoint("TOPLEFT", b, "TOPLEFT", 1, -1)
hl:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", -1, 1)
end
local function SkinCheck(cb)
if not FLAT then return end
if PF and PF.SkinCheckbox then pcall(PF.SkinCheckbox, cb) end
end
local function SkinInput(eb)
if not FLAT then return end
local n = eb:GetName()
for _, part in ipairs({ "Left", "Middle", "Right" }) do
local t = getglobal(n .. part)
if t then t:Hide() end
end
FlatBackdrop(eb, 0.5)
if eb.SetTextInsets then eb:SetTextInsets(4, 4, 0, 0) end
Font(eb, 11)
end
StaticPopupDialogs["LOCKPORT_RELOAD"] = {
text = "LockPort: reload the UI now to apply the new window style?",
button1 = "Reload",
button2 = "Later",
OnAccept = function() ReloadUI() end,
timeout = 0, whileDead = 1, hideOnEscape = 1,
}
function LP:ToggleTheme()
LockPortOptions.theme = (LockPortOptions.theme == "pfui") and "classic" or "pfui"
self:Print("window style: " .. (LockPortOptions.theme == "pfui" and "pfUI" or "Classic") .. " - /reload to apply.")
if self.RefreshOptionsUI then self:RefreshOptionsUI() end
StaticPopup_Show("LOCKPORT_RELOAD")
end
local function ClassColor(class)
local c = class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class]
if c then return c.r, c.g, c.b end
return 1, 0.82, 0
end
local function MakeDialog(name, width, height, title)
local f = CreateFrame("Frame", name, UIParent)
f:SetWidth(width)
f:SetHeight(height)
f:SetFrameStrata("MEDIUM")
if f.SetToplevel then f:SetToplevel(true) end
if FLAT then FlatBackdrop(f, 0.8) else f:SetBackdrop(DIALOG_BACKDROP) end
f:SetMovable(true)
f:EnableMouse(true)
if f.SetClampedToScreen then f:SetClampedToScreen(true) end
f:RegisterForDrag("LeftButton")
f:Hide()
if FLAT then
-- old-LockPort title bar: dark strip, gradient name
local bar = f:CreateTexture(name .. "TitleBar", "BACKGROUND")
bar:SetTexture(1, 1, 1, 0.06)
bar:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1)
bar:SetPoint("TOPRIGHT", f, "TOPRIGHT", -1, -1)
bar:SetHeight(20)
local text = f:CreateFontString(name .. "Title", "OVERLAY", "GameFontNormal")
text:SetPoint("TOP", f, "TOP", 0, -5)
Font(text, 14)
local _, _, rest = string.find(title, "^LockPort%s*(.*)$")
text:SetText(OLD_TITLE .. ((rest and rest ~= "") and (" |cffffffff" .. rest .. "|r") or ""))
f.title = text
local close = CreateFrame("Button", name .. "CloseButton", f, "UIPanelCloseButton")
if not (PF and PF.SkinCloseButton and pcall(PF.SkinCloseButton, close, f, -3, -3)) then
close:SetWidth(24)
close:SetHeight(24)
close:SetPoint("TOPRIGHT", f, "TOPRIGHT", 2, 2)
end
return f
end
local header = f:CreateTexture(name .. "Header", "ARTWORK")
header:SetTexture("Interface\\DialogFrame\\UI-DialogBox-Header")
header:SetWidth(width > 280 and 300 or 230)
header:SetHeight(64)
header:SetPoint("TOP", f, "TOP", 0, 12)
local text = f:CreateFontString(name .. "Title", "OVERLAY", "GameFontNormal")
text:SetPoint("TOP", header, "TOP", 0, -14)
text:SetText(title)
f.title = text
local close = CreateFrame("Button", name .. "CloseButton", f, "UIPanelCloseButton")
close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -5, -5)
return f
end
---------------------------------------------------------------------------
-- Request window
---------------------------------------------------------------------------
local function Row_OnEnter()
local e = this.entry
if not e then return end
local info = e.info
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:AddLine(e.name, ClassColor(info.class))
local line = info.classLocal or ""
if info.zone and info.zone ~= "" then line = line .. " - " .. info.zone end
if line ~= "" then GameTooltip:AddLine(line, 1, 1, 1) end
local status, r, g, b = LP:RequestStatus(e)
if status ~= "" then GameTooltip:AddLine(status, r, g, b) end
local waited = math.floor((GetTime() - e.t) / 60)
GameTooltip:AddLine("Waiting " .. (waited < 1 and "under a minute" or (waited .. " min")), 0.6, 0.6, 0.6)
GameTooltip:AddLine(" ")
GameTooltip:AddLine("Left-click: summon", 0.2, 1, 0.2)
GameTooltip:AddLine("Ctrl+click: target", 0.8, 0.8, 0.8)
GameTooltip:AddLine("Right-click: remove", 1, 0.3, 0.3)
GameTooltip:Show()
end
local function Row_OnClick()
local e = this.entry
if not e then return end
if arg1 == "RightButton" then
LP:RemoveRequest(e.name, true)
elseif IsControlKeyDown() then
TargetUnit(e.info.unit)
else
LP:Summon(e.name)
end
GameTooltip:Hide()
end
local function CreateRow(parent, i)
local row = CreateFrame("Button", "LockPortRow" .. i, parent)
row:SetWidth(INNER_W)
row:SetHeight(ROW_HEIGHT)
row:SetPoint("TOPLEFT", parent, "TOPLEFT", 18, -46 - (i - 1) * ROW_HEIGHT)
row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
if math.mod(i, 2) == 0 then
local stripe = row:CreateTexture(nil, "BACKGROUND")
stripe:SetAllPoints(row)
stripe:SetTexture(1, 1, 1, 0.05)
end
local hl = row:CreateTexture(nil, "HIGHLIGHT")
if FLAT then
hl:SetTexture(1, 1, 1, 0.1)
else
hl:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight")
hl:SetBlendMode("ADD")
end
hl:SetAllPoints(row)
local icon = row:CreateTexture(nil, "ARTWORK")
icon:SetTexture(CLASS_ICONS)
icon:SetWidth(14)
icon:SetHeight(14)
icon:SetPoint("LEFT", row, "LEFT", 3, 0)
row.icon = icon
local name = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
name:SetPoint("LEFT", icon, "RIGHT", 5, 0)
name:SetWidth(92)
name:SetHeight(ROW_HEIGHT)
name:SetJustifyH("LEFT")
Font(name, 12)
row.name = name
local status = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
status:SetPoint("RIGHT", row, "RIGHT", -3, 0)
status:SetWidth(INNER_W - 118)
status:SetHeight(ROW_HEIGHT)
status:SetJustifyH("RIGHT")
Font(status, 10)
row.status = status
row:SetScript("OnClick", Row_OnClick)
row:SetScript("OnEnter", Row_OnEnter)
row:SetScript("OnLeave", function() GameTooltip:Hide() end)
row:Hide()
return row
end
local function SavePosition(f)
local point, _, relPoint, x, y = f:GetPoint()
LockPortOptions.pos = { point = point, relPoint = relPoint, x = x, y = y }
end
local function RestorePosition(f)
f:ClearAllPoints()
local p = LockPortOptions.pos
if p and p.point then
f:SetPoint(p.point, UIParent, p.relPoint or p.point, p.x or 0, p.y or 0)
else
f:SetPoint("CENTER", UIParent, "CENTER", 0, 120)
end
end
local function CreateGroupsFrame()
local f = MakeDialog("LockPortGroupsFrame", 230, 336, "Raid Groups")
f:SetParent(LockPortFrame)
f:SetPoint("TOPLEFT", LockPortFrame, "TOPRIGHT", 6, 0)
local tip = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
tip:SetPoint("TOPLEFT", f, "TOPLEFT", 18, -30)
tip:SetWidth(194); tip:SetHeight(24)
tip:SetText("Select your raid groups, then queue their players for summoning.")
f.groups = {}
for i = 1, 8 do
local group = i
local cb = CreateFrame("CheckButton", "LockPortGroupCheck" .. i, f, "UICheckButtonTemplate")
cb:SetWidth(24); cb:SetHeight(24)
cb:SetPoint("TOPLEFT", f, "TOPLEFT", 18, -56 - (i - 1) * 22)
local label = cb:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
label:SetPoint("LEFT", cb, "RIGHT", 2, 0)
cb.label = label
cb:SetScript("OnClick", function() LP:ToggleSummonGroup(group) end)
SkinCheck(cb)
f.groups[i] = cb
end
local all = CreateFrame("Button", "LockPortGroupsAll", f, "UIPanelButtonTemplate")
all:SetWidth(95); all:SetHeight(22)
all:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 46)
all:SetText("All groups")
all:SetScript("OnClick", function() LP:SetSummonGroups(nil) end)
SkinButton(all)
local none = CreateFrame("Button", "LockPortGroupsNone", f, "UIPanelButtonTemplate")
none:SetWidth(95); none:SetHeight(22)
none:SetPoint("LEFT", all, "RIGHT", 2, 0)
none:SetText("None")
none:SetScript("OnClick", function() LP:SetSummonGroups({}) end)
SkinButton(none)
local queue = CreateFrame("Button", "LockPortQueueGroups", f, "UIPanelButtonTemplate")
queue:SetWidth(192); queue:SetHeight(22)
queue:SetPoint("TOPLEFT", all, "BOTTOMLEFT", 0, -2)
queue:SetText("Queue selected groups")
queue:SetScript("OnClick", function() LP:QueueSelectedGroups() end)
SkinButton(queue)
f:SetScript("OnShow", function() LP:RefreshGroupsUI() end)
return f
end
function LP:RefreshGroupsUI()
local f = LockPortGroupsFrame
if not f then return end
if GetNumRaidMembers() == 0 then f:Hide(); return end
local counts = {}
for _, info in pairs(self:ScanRoster()) do
if info.subgroup then counts[info.subgroup] = (counts[info.subgroup] or 0) + 1 end
end
local groups = LockPortOptions.summonGroups
for i = 1, 8 do
f.groups[i]:SetChecked((type(groups) ~= "table" or groups[i]) and 1 or nil)
f.groups[i].label:SetText("Group " .. i .. " (" .. (counts[i] or 0) .. " players)")
end
end
function LP:ToggleGroups()
if GetNumRaidMembers() == 0 then return self:Print("Group summoning is available in raids.") end
local f = LockPortGroupsFrame
if not f then return end
if f:IsVisible() then f:Hide() else f:Show(); self:RefreshGroupsUI() end
end
local function CreateMainFrame()
-- fixed height for 10 rows, like the original LockPort
local f = MakeDialog("LockPortFrame", FRAME_W, 46 + MAX_ROWS * ROW_HEIGHT + 104, "LockPort")
f:SetScript("OnDragStart", function() if not LockPortOptions.locked then this:StartMoving() end end)
f:SetScript("OnDragStop", function() this:StopMovingOrSizing(); SavePosition(this) end)
f:SetScript("OnShow", function() PlaySound("igMainMenuOpen"); LP:RefreshList() end)
f:SetScript("OnHide", function() PlaySound("igMainMenuClose") end)
f:SetScript("OnUpdate", function()
this.elapsed = (this.elapsed or 0) + arg1
if this.elapsed > 0.5 then this.elapsed = 0; LP:RefreshList() end
end)
local sub = f:CreateFontString("LockPortFrameSubtitle", "OVERLAY", "GameFontNormalSmall")
sub:SetPoint("TOPLEFT", f, "TOPLEFT", 20, -28)
sub:SetText("Summon requests")
sub:SetWidth(INNER_W - 4)
sub:SetHeight(12)
sub:SetJustifyH("LEFT")
Font(sub, 11)
f.subtitle = sub
local count = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
count:SetPoint("TOPRIGHT", f, "TOPRIGHT", -20, -28)
f.count = count
local div = f:CreateTexture(nil, "ARTWORK")
AccentLine(div)
div:SetHeight(1)
div:SetPoint("TOPLEFT", f, "TOPLEFT", 18, -42)
div:SetPoint("TOPRIGHT", f, "TOPRIGHT", -18, -42)
f.rows = {}
for i = 1, MAX_ROWS do f.rows[i] = CreateRow(f, i) end
local empty = f:CreateFontString(nil, "OVERLAY", "GameFontDisable")
empty:SetPoint("TOP", f, "TOP", 0, -52)
empty:SetText("No one needs a summon.")
Font(empty, 12)
f.empty = empty
local more = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
more:SetPoint("TOPRIGHT", f.rows[MAX_ROWS], "BOTTOMRIGHT", -3, -1)
f.more = more
-- footer: shards + helpers
local footer = CreateFrame("Frame", nil, f)
footer:SetWidth(INNER_W)
footer:SetHeight(20)
footer:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 18, 66)
f.footer = footer
local fdiv = footer:CreateTexture(nil, "ARTWORK")
AccentLine(fdiv)
fdiv:SetHeight(1)
fdiv:SetPoint("TOPLEFT", footer, "TOPLEFT", 0, 2)
fdiv:SetPoint("TOPRIGHT", footer, "TOPRIGHT", 0, 2)
local shardIcon = footer:CreateTexture(nil, "ARTWORK")
shardIcon:SetTexture("Interface\\Icons\\INV_Misc_Gem_Amethyst_02")
shardIcon:SetWidth(16)
shardIcon:SetHeight(16)
shardIcon:SetPoint("LEFT", footer, "LEFT", 2, -3)
local shards = footer:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
shards:SetPoint("LEFT", shardIcon, "RIGHT", 4, 0)
Font(shards, 10)
f.shards = shards
local helpers = footer:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
helpers:SetPoint("RIGHT", footer, "RIGHT", -2, -3)
Font(helpers, 10)
f.helpers = helpers
-- buttons
local summon = CreateFrame("Button", "LockPortSummonButton", f, "UIPanelButtonTemplate")
summon:SetWidth(95)
summon:SetHeight(22)
summon:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 40)
summon:SetText("Summon")
summon:SetScript("OnClick", function() LP:SummonNext() end)
summon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOP")
GameTooltip:AddLine("Summon next in line")
if LP.readyText then GameTooltip:AddLine(LP.readyText, 1, 0.82, 0) end
GameTooltip:AddLine("Skips anyone offline, dead, in combat, inside an instance, being summoned by another warlock, or who failed 3 times in a row.", 1, 1, 1, 1)
local key = GetBindingKey("LOCKPORT_SUMMON_NEXT")
if key then GameTooltip:AddLine("Hotkey: " .. key, 0.6, 0.6, 0.6) end
GameTooltip:Show()
end)
summon:SetScript("OnLeave", function() GameTooltip:Hide() end)
f.summon = summon
local chat = CreateFrame("Button", "LockPortChatToggle", f, "UIPanelButtonTemplate")
chat:SetWidth(95)
chat:SetHeight(22)
chat:SetPoint("LEFT", summon, "RIGHT", 2, 0)
chat:SetText("Chat: On")
chat:SetScript("OnClick", function() LP:SetChatMuted(not LockPortOptions.chatMuted) end)
chat:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOP")
GameTooltip:AddLine("Outgoing chat")
GameTooltip:AddLine("Turn off while travelling to mute all LockPort announcements and whispers. Requests and summoning still work. This setting is saved across reloads.", 1, 1, 1, 1)
GameTooltip:Show()
end)
chat:SetScript("OnLeave", function() GameTooltip:Hide() end)
SkinButton(chat)
f.chatToggle = chat
local add = CreateFrame("Button", "LockPortAddButton", f, "UIPanelButtonTemplate")
add:SetWidth(95)
add:SetHeight(22)
add:SetPoint("TOPLEFT", summon, "BOTTOMLEFT", 0, -2)
add:SetText("Add Target")
add:SetScript("OnClick", function() LP:Slash("add") end)
local opts = CreateFrame("Button", "LockPortOptionsButton", f, "UIPanelButtonTemplate")
opts:SetWidth(95)
opts:SetHeight(22)
opts:SetPoint("LEFT", add, "RIGHT", 2, 0)
opts:SetText("Options")
opts:SetScript("OnClick", function() LP:ToggleOptions() end)
-- Hidden entirely when the companion client DLL is absent.
local auto = CreateFrame("Button", "LockPortRaidAutoSummon", f, "UIPanelButtonTemplate")
auto:SetWidth(95)
auto:SetHeight(22)
auto:SetPoint("TOPLEFT", add, "BOTTOMLEFT", 0, -2)
auto:SetText("Auto: Off")
auto:SetScript("OnClick", function()
LP:SetRaidAutoSummon(not LP.raidAutoSummon)
LP:RefreshList()
end)
auto:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOP")
GameTooltip:AddLine("Auto Summon")
GameTooltip:AddLine("Automatically summons queued party or raid members when two helpers are nearby. Enables summons-available announcements. Turns off when you leave the group or reload.", 1, 1, 1, 1)
GameTooltip:Show()
end)
auto:SetScript("OnLeave", function() GameTooltip:Hide() end)
auto:Hide()
SkinButton(auto)
f.raidAuto = auto
local groups = CreateFrame("Button", "LockPortGroupsButton", f, "UIPanelButtonTemplate")
groups:SetWidth(95); groups:SetHeight(22)
groups:SetPoint("LEFT", auto, "RIGHT", 2, 0)
groups:SetText("Groups: All")
groups:SetScript("OnClick", function() LP:ToggleGroups() end)
groups:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOP")
GameTooltip:AddLine("Group summoning")
if GetNumRaidMembers() == 0 then GameTooltip:AddLine("Join a raid to select groups.", 1, 0.5, 0.1) end
GameTooltip:AddLine("Selected raid groups: " .. LP:SummonGroupSummary(), 1, 0.82, 0)
GameTooltip:AddLine("Select your groups and queue their players without a 123 request. Summon and Auto use your selection. Other warlocks should select their own groups.", 1, 1, 1, 1)
GameTooltip:Show()
end)
groups:SetScript("OnLeave", function() GameTooltip:Hide() end)
SkinButton(groups)
groups:Hide()
f.groupSummon = groups
SkinButton(summon); SkinButton(add); SkinButton(opts)
RestorePosition(f)
return f
end
function LP:RefreshList()
local f = LockPortFrame
if not f or not LockPortOptions then return end
if GetNumRaidMembers() == 0 and LockPortGroupsFrame then LockPortGroupsFrame:Hide() end
if not self:IsWarlock() then f:Hide(); return end
if not f:IsVisible() then return end
f.chatToggle:SetText(LockPortOptions.chatMuted and "|cffff4040Chat: Off|r" or "Chat: On")
local native = self:HasNativeMod()
local raid = GetNumRaidMembers() > 0
local extra = 26 -- Keep the Groups control visible even outside raids.
f:SetHeight(46 + MAX_ROWS * ROW_HEIGHT + 104 + extra)
f.summon:ClearAllPoints()
f.summon:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 16, 40 + extra)
f.footer:ClearAllPoints()
f.footer:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 18, 66 + extra)
if native then
f.raidAuto:Show()
f.raidAuto:SetText(self.raidAutoSummon and "|cff40ff40Auto: On|r" or "Auto: Off")
if self:InGroup() then f.raidAuto:Enable() else f.raidAuto:Disable() end
else
f.raidAuto:Hide()
end
f.groupSummon:Show()
if raid then
local summary, count = self:SummonGroupSummary()
f.groupSummon:SetText(count == 0 and "|cffff4040Groups: 0|r" or ("Groups: " .. (count == 8 and "All" or count)))
f.groupSummon:Enable()
self:RefreshGroupsUI()
else
f.groupSummon:SetText("Groups")
f.groupSummon:Disable()
if LockPortGroupsFrame then LockPortGroupsFrame:Hide() end
end
self:ScanRoster()
local list = self:SortedRequests()
local n = table.getn(list)
local shown = n
if shown > MAX_ROWS then shown = MAX_ROWS end
for i = 1, MAX_ROWS do
local row, e = f.rows[i], list[i]
if e then
row.entry = e
local c = CLASS_COORDS[e.info.class or ""]
if c then
row.icon:SetTexCoord(c[1], c[2], c[3], c[4])
row.icon:Show()
else
row.icon:Hide()
end
row.name:SetText(e.name)
row.name:SetTextColor(ClassColor(e.info.class))
local text, r, g, b = self:RequestStatus(e)
row.status:SetText(text)
row.status:SetTextColor(r, g, b)
row:Show()
else
row.entry = nil
row:Hide()
end
end
if n == 0 then f.empty:Show() else f.empty:Hide() end
if n > MAX_ROWS then f.more:SetText("+" .. (n - MAX_ROWS) .. " more") else f.more:SetText("") end
f.count:SetText("")
local shardCount = self:CountShards()
f.shards:SetText(shardCount .. (shardCount == 1 and " Soul Shard" or " Soul Shards"))
if shardCount == 0 then f.shards:SetTextColor(1, 0.25, 0.25) else f.shards:SetTextColor(1, 1, 1) end
local helpers = self:InGroup() and self:CountHelpers(nil) or 0
f.helpers:SetText("Helpers: " .. helpers .. "/2")
if helpers >= 2 then f.helpers:SetTextColor(0.25, 1, 0.25) else f.helpers:SetTextColor(1, 0.5, 0.1) end
if self.pending and not self.pending.done then f.summon:Disable() else f.summon:Enable() end
-- status line: ready / why not
local text = self.readyText
if n > 0 and text then
if self.isReady then
f.subtitle:SetTextColor(0.25, 1, 0.25)
elseif string.find(text, "^Summoning") then
f.subtitle:SetTextColor(0.58, 0.51, 0.79)
else
f.subtitle:SetTextColor(1, 0.5, 0.1)
end
f.subtitle:SetText(text .. " (" .. n .. ")")
else
if FLAT then f.subtitle:SetTextColor(0.85, 0.85, 0.85) else f.subtitle:SetTextColor(1, 0.82, 0) end
f.subtitle:SetText("Summon requests (" .. n .. ")")
end
f.summon:SetText(self.isReady and "|cff40ff40Summon|r" or "Summon")
-- UIPanelButton text/skin hooks can resize buttons; restore both columns
-- after refreshing labels so each top button matches the one beneath it.
f.summon:SetWidth(95)
f.chatToggle:SetWidth(95)
f.raidAuto:SetWidth(95)
f.groupSummon:SetWidth(95)
end
---------------------------------------------------------------------------
-- Options window
---------------------------------------------------------------------------
local checks = {}
local function MakeCheck(parent, key, label, tip, x, y)
local name = "LockPortCheck_" .. key
local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate")
cb:SetWidth(24)
cb:SetHeight(24)
cb:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
local text = getglobal(name .. "Text")
if not text then
text = cb:CreateFontString(name .. "Text", "OVERLAY")
text:SetPoint("LEFT", cb, "RIGHT", 0, 1)
end
text:SetFontObject(GameFontHighlightSmall)
Font(text, 11)
text:SetText(label)
cb.label = text
cb.key = key
cb.tip = tip
cb:SetScript("OnClick", function()
if this.key == "announceOpen" and LP.raidAutoSummon then this:SetChecked(1); return end
LockPortOptions[this.key] = this:GetChecked() and true or false
PlaySound(this:GetChecked() and "igMainMenuOptionCheckBoxOn" or "igMainMenuOptionCheckBoxOff")
LP:RefreshList()
end)
cb:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:AddLine(this.label:GetText())
GameTooltip:AddLine(this.tip, 1, 1, 1, 1)
GameTooltip:Show()
end)
cb:SetScript("OnLeave", function() GameTooltip:Hide() end)
SkinCheck(cb)
checks[key] = cb
return cb
end
local function MakeSection(parent, text, y)
local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
fs:SetPoint("TOPLEFT", parent, "TOPLEFT", 20, y)
fs:SetText(text)
if FLAT then Font(fs, 12); fs:SetTextColor(0.58, 0.51, 0.79) end
return fs
end
local function MakeInput(parent, name, key, width, x, y)
local eb = CreateFrame("EditBox", name, parent, "InputBoxTemplate")
eb:SetWidth(width)
eb:SetHeight(20)
eb:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
eb:SetAutoFocus(false)
eb:SetMaxLetters(255)
eb.key = key
eb:SetScript("OnEditFocusGained", function() this.focused = true end)
eb:SetScript("OnEditFocusLost", function() this.focused = nil end)
eb:SetScript("OnEnterPressed", function() this:ClearFocus() end)
eb:SetScript("OnEscapePressed", function() this:ClearFocus() end)
eb:SetScript("OnTextChanged", function()
if LockPortOptions then LockPortOptions[this.key] = this:GetText() end
end)
SkinInput(eb)
return eb
end
local function CreateOptionsFrame()
local f = MakeDialog("LockPortOptionsFrame", 400, 612, "LockPort Options")
f:SetFrameStrata("DIALOG")
f:SetPoint("CENTER", UIParent, "CENTER", 0, 40)
f:SetScript("OnDragStart", function() this:StartMoving() end)
f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); LP:RefreshOptionsUI() end)
f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose") end)
table.insert(UISpecialFrames, "LockPortOptionsFrame") -- Esc closes it
local y = -32
MakeSection(f, "When summoning", y)
MakeCheck(f, "whisper", "Whisper the player you're summoning", "Sends \"Summoning you to <zone>\" to the target when the ritual starts.", 20, y - 16)
MakeCheck(f, "zone", "Include zone in the /say message", "Adds the zone and subzone to the summon announcement.", 20, y - 38)
MakeCheck(f, "shards", "Include Soul Shard count", "Adds \"[N shards left]\" to the summon announcement.", 20, y - 60)
MakeCheck(f, "customMessage", "Use a custom /say message:", "Replaces the generated message. {name}, {zone} and {shards} are filled in for you.", 20, y - 82)
local msg = MakeInput(f, "LockPortMessageBox", "message", 340, 30, y - 106)
local hint = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
hint:SetPoint("TOPLEFT", msg, "BOTTOMLEFT", -4, -2)
hint:SetText("Tokens: {name} {zone} {shards}")
y = -170
MakeSection(f, "Whispers & announcements", y)
MakeCheck(f, "announceFail", "Tell the party/raid why a summon failed", "e.g. \"[LockPort] Can't summon Bob: Bob is in combat.\"", 20, y - 16)
MakeCheck(f, "whisperFail", "Whisper the player why it failed", "Lets them know they're still on the list and what to fix.", 20, y - 38)
MakeCheck(f, "whisperStatus", "Answer repeat 123s with a status whisper", "If someone types 123 while already here, or again while waiting, they're told why: already here, place in line, waiting on helpers, summoning someone else, etc. Only one LockPort warlock replies.", 20, y - 60)
MakeCheck(f, "announceOpen", "Post \"summons are available\" every", "Posts \"[LockPort] Summons are available - type 123 to get summoned. (N in line)\" on a timer while you're in a group. Skipped while you're in combat; with several LockPort warlocks only one posts each round.", 20, y - 82)
local every = MakeInput(f, "LockPortAnnounceEveryBox", "announceOpenEvery", 26, 262, y - 84)
every:ClearAllPoints()
every:SetPoint("LEFT", checks.announceOpen.label, "RIGHT", 10, 0)
every:SetMaxLetters(2)
local everyHint = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
everyHint:SetPoint("LEFT", every, "RIGHT", 6, 0)
everyHint:SetText("min")
Font(everyHint, 11)
MakeCheck(f, "askHelpers", "Ask the raid for helpers when fewer than 2 are near", "Posts e.g. \"[LockPort] Need 1 more person at Blackrock Mountain to help summon - 3 players waiting.\" when someone is waiting (or your portal is up) and fewer than 2 group members are near you. At most every 2 minutes, not in combat, and only one LockPort warlock posts.", 20, y - 104)
y = -306
MakeSection(f, "Requests", y)
MakeCheck(f, "sound", "Play a sound on new requests", "Plays a short sound when someone is added to the list.", 20, y - 16)
MakeCheck(f, "readySound", "Chime when a summon is ready", "Plays the ready-check sound when the next player can be summoned: you're out of combat with shards and 2 helpers are near you.", 20, y - 38)
local flash = MakeCheck(f, "flash", "Flash the taskbar when alt-tabbed", "Flashes on new requests and when a summon is ready. Needs the UnitXP SP3 client mod.", 20, y - 60)
MakeCheck(f, "autoShow", "Open this window on new requests", "Pops the LockPort window up when someone asks for a summon.", 20, y - 82)
MakeCheck(f, "autoRemove", "Remove players once they arrive", "Takes people off everyone's list when they're already standing near you.", 20, y - 104)
MakeCheck(f, "locked", "Lock window position", "Stops the summon window from being dragged.", 20, y - 126)
local trigLabel = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
trigLabel:SetPoint("TOPLEFT", f, "TOPLEFT", 24, y - 158)
trigLabel:SetText("Request words:")
Font(trigLabel, 11)
local trig = MakeInput(f, "LockPortTriggerBox", "triggers", 170, 124, y - 153)
trig:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:AddLine("Request words")
GameTooltip:AddLine("Comma separated. A group member starting a message with one of these is added to the list. Default: 123", 1, 1, 1, 1)
GameTooltip:Show()
end)
trig:SetScript("OnLeave", function() GameTooltip:Hide() end)
-- Summon Next hotkey: click, then press a key (right-click clears)
local keyLabel = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
keyLabel:SetPoint("TOPLEFT", f, "TOPLEFT", 24, y - 186)
keyLabel:SetText("Summon Next key:")
Font(keyLabel, 11)
local bind = CreateFrame("Button", "LockPortBindButton", f, "UIPanelButtonTemplate")
bind:SetWidth(120)
bind:SetHeight(20)
bind:SetPoint("LEFT", keyLabel, "RIGHT", 10, 0)
bind:RegisterForClicks("LeftButtonUp", "RightButtonUp")
bind:SetScript("OnClick", function()
if arg1 == "RightButton" then
LP:ClearSummonKey()
else
this.waiting = true
this:EnableKeyboard(true)
this:SetText("Press a key...")
end
end)
bind:SetScript("OnKeyDown", function() LP:CaptureSummonKey(this, arg1) end)
bind:SetScript("OnHide", function() LP:StopKeyCapture(this) end)
bind:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:AddLine("Summon Next hotkey")
GameTooltip:AddLine("Click, then press the key (Shift/Ctrl/Alt combos work). Esc cancels, right-click clears it. Same as Key Bindings > LockPort.", 1, 1, 1, 1)
GameTooltip:Show()
end)
bind:SetScript("OnLeave", function() GameTooltip:Hide() end)
SkinButton(bind)
f.bind = bind
local styleLabel = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
styleLabel:SetPoint("TOPLEFT", f, "TOPLEFT", 24, y - 212)
styleLabel:SetText("Window style:")
Font(styleLabel, 11)
local style = CreateFrame("Button", "LockPortStyleButton", f, "UIPanelButtonTemplate")
style:SetWidth(90)
style:SetHeight(20)
style:SetPoint("LEFT", styleLabel, "RIGHT", 10, 0)
style:SetScript("OnClick", function() LP:ToggleTheme() end)
style:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:AddLine("Window style")
GameTooltip:AddLine("Classic: Blizzard dialog look.\npfUI: flat dark look like the original LockPort (uses pfUI's skin when pfUI is installed).\nNeeds a /reload.", 1, 1, 1, 1)
GameTooltip:Show()
end)
style:SetScript("OnLeave", function() GameTooltip:Hide() end)
SkinButton(style)
f.style = style
local modsHeader = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
modsHeader:SetPoint("BOTTOM", f, "BOTTOM", 0, 52)
modsHeader:SetText("Client mods")
local mods = f:CreateFontString("LockPortModsText", "OVERLAY", "GameFontHighlightSmall")
mods:SetPoint("TOP", modsHeader, "BOTTOM", 0, -4)
mods:SetWidth(360)
mods:SetHeight(32)
mods:SetJustifyH("CENTER")
f.mods = mods
f.flash = flash
return f
end
function LP:RefreshOptionsUI()
local O = LockPortOptions
local f = LockPortOptionsFrame
if not O or not f then return end
for key, cb in pairs(checks) do cb:SetChecked(O[key] and 1 or nil) end
if not LockPortMessageBox.focused then LockPortMessageBox:SetText(O.message or "") end
if not LockPortAnnounceEveryBox.focused then LockPortAnnounceEveryBox:SetText(O.announceOpenEvery or "3") end
if not f.bind.waiting then f.bind:SetText(GetBindingKey("LOCKPORT_SUMMON_NEXT") or "Not bound") end
if not LockPortTriggerBox.focused then LockPortTriggerBox:SetText(O.triggers or "123") end
if self.mods.unitxp then
f.flash:Enable()
f.flash.label:SetTextColor(1, 1, 1)
else
f.flash:Disable()
f.flash.label:SetTextColor(0.5, 0.5, 0.5)
end
f.mods:SetText(self:ModStatusText())
f.style:SetText(O.theme == "pfui" and "pfUI" or "Classic")
end
---------------------------------------------------------------------------
-- Summon Next hotkey (stored as a normal WoW key binding)
---------------------------------------------------------------------------
local MODIFIER_KEYS = { LSHIFT = 1, RSHIFT = 1, LCTRL = 1, RCTRL = 1, LALT = 1, RALT = 1, UNKNOWN = 1 }
local function SaveKeyBindings()
if GetCurrentBindingSet then SaveBindings(GetCurrentBindingSet()) else SaveBindings(1) end
end
function LP:StopKeyCapture(b)
b.waiting = false
b:EnableKeyboard(false)
self:RefreshOptionsUI()
end
function LP:ClearSummonKey()
local k1, k2 = GetBindingKey("LOCKPORT_SUMMON_NEXT")
if k1 then SetBinding(k1) end
if k2 then SetBinding(k2) end
SaveKeyBindings()
self:Print("Summon Next key cleared.")
self:RefreshOptionsUI()
end
function LP:CaptureSummonKey(b, key)
if not b.waiting or not key or MODIFIER_KEYS[key] then return end
if key == "ESCAPE" then return self:StopKeyCapture(b) end
local combo = (IsAltKeyDown() and "ALT-" or "") .. (IsControlKeyDown() and "CTRL-" or "") ..
(IsShiftKeyDown() and "SHIFT-" or "") .. key
local old = GetBindingAction(combo)
local k1, k2 = GetBindingKey("LOCKPORT_SUMMON_NEXT")
if k1 then SetBinding(k1) end
if k2 then SetBinding(k2) end
SetBinding(combo, "LOCKPORT_SUMMON_NEXT")
SaveKeyBindings()
if old and old ~= "" and old ~= "LOCKPORT_SUMMON_NEXT" then
self:Print(combo .. " was bound to " .. old .. " - it now summons the next player.")
else
self:Print("Summon Next is now on " .. combo .. ".")
end
self:StopKeyCapture(b)
end
function LP:CreateUI()
if LockPortFrame then return end
SetupTheme()
CreateMainFrame()
CreateGroupsFrame()
CreateOptionsFrame()
end
+1
View File
@@ -0,0 +1 @@
96bdb518e0b58106d585b7a9c577cd3437b497f97178656b4fc7fe68acd777c8 LockPort.dll
+214
View File
@@ -0,0 +1,214 @@
// LockPort companion client mod, API 1.2.1.
// Build: zig cc -target x86-windows-gnu -shared -O2 LockPort.c -o LockPort.dll
// Designed for the inspected OctoWoW 1.18.1 x86 executable layout.
// No background game thread: Lua APIs run only on the game's Lua thread.
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef void (__stdcall *LoadFunctionsFn)(void);
typedef int (__fastcall *LuaFunction)(void *);
typedef void (__fastcall *RegisterFn)(const char *, LuaFunction);
typedef const char *(__fastcall *ToStringFn)(void *, int);
typedef void (__fastcall *PushStringFn)(void *, const char *);
typedef void (__fastcall *PushBooleanFn)(void *, int);
typedef int (__fastcall *GetTopFn)(void *);
typedef void (__fastcall *SetTopFn)(void *, int);
typedef int32_t (__fastcall *SpellSlotFn)(const char *, uint32_t *);
typedef uint64_t (__fastcall *UnitGuidFn)(const char *);
typedef void (__fastcall *BookCastFn)(uint32_t, uint32_t, uint64_t);
static LoadFunctionsFn originalLoad;
static void *trampoline;
static DWORD luaThread;
static DWORD lastAttempt;
static int hasAttempt;
static unsigned char saved[6];
static SIZE_T patchLength;
static int __fastcall Version(void *state) {
((PushStringFn)0x006F3890)(state, "1.2.1");
return 1;
}
static int validUnit(const char *s) {
if (!s) return 0;
int limit;
if (strncmp(s, "raid", 4) == 0) { s += 4; limit = 40; }
else if (strncmp(s, "party", 5) == 0) { s += 5; limit = 4; }
else return 0;
if (*s < '1' || *s > '9') return 0;
int number = *s++ - '0';
if (*s >= '0' && *s <= '9') number = number * 10 + *s++ - '0';
return *s == 0 && number >= 1 && number <= limit;
}
static int result(void *state, int attempted, const char *reason) {
((PushBooleanFn)0x006F39F0)(state, attempted);
((PushStringFn)0x006F3890)(state, reason);
return 2;
}
static int __fastcall Summon(void *state) {
DWORD now = GetTickCount();
const char *unit = ((ToStringFn)0x006F3690)(state, 1);
if (GetCurrentThreadId() != luaThread) return result(state, 0, "wrong thread");
if (!validUnit(unit)) return result(state, 0, "invalid group unit");
if (hasAttempt && (DWORD)(now - lastAttempt) < 2000)
return result(state, 0, "native rate limit");
// Resolve an explicit target and the actual spellbook entry. No Lua
// string execution, hardware-input simulation, or target-dependent cast.
uint64_t guid = ((UnitGuidFn)0x00515970)(unit);
if (!guid) return result(state, 0, "unit unavailable");
uint32_t book = 0;
int32_t slot = ((SpellSlotFn)0x004B3950)("Ritual of Summoning", &book);
if (slot < 0 || slot >= 1024 || book != 0)
return result(state, 0, "ritual not in spellbook");
lastAttempt = now;
hasAttempt = 1;
// The action gate (0x494A50, kind 2) refuses casts from restricted
// frame scripts such as OnUpdate, including native calls made by Lua.
// Mirror command-dispatch context scoping: save, clear for this one
// engine call, and restore immediately. No persistent gate patch.
volatile uintptr_t *context = (volatile uintptr_t *)0x00CEEAC0;
volatile uint32_t *depth = (volatile uint32_t *)0x00CEEAC4;
uintptr_t savedContext = *context;
uint32_t savedDepth = *depth;
*context = 0;
*depth = 0;
((BookCastFn)0x004B3300)((uint32_t)slot, book, guid);
*context = savedContext;
*depth = savedDepth;
return result(state, 1, "spellbook request sent");
}
static void __stdcall LoadHook(void) {
originalLoad();
luaThread = GetCurrentThreadId();
// Pin the module before giving Lua pointers into its code.
HMODULE pinned = NULL;
GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_PIN, (LPCSTR)(uintptr_t)&Version, &pinned);
((RegisterFn)0x00704120)("GetLockPortVersion", Version);
((RegisterFn)0x00704120)("LockPortNativeSummon", Summon);
}
typedef struct { DWORD rva; unsigned char bytes[16]; } Signature;
static const Signature signatures[] = {
{0x90250,{0x56,0xe8,0xfa,0x60,0x27,0x00,0xe8,0x45,0x66,0x0a,0x00,0xe8,0x90,0xf0,0x05,0x00}},
{0xb3950,{0x55,0x8b,0xec,0x81,0xec,0x04,0x06,0x00,0x00,0x56,0x57,0x68,0x00,0x04,0x00,0x00}},
{0xb3300,{0x55,0x8b,0xec,0x83,0xec,0x20,0x53,0x56,0x8b,0xf2,0x57,0x89,0x75,0xf8,0x8b,0xd9}},
{0x115970,{0x55,0x8b,0xec,0x51,0x56,0x68,0x90,0x00,0x00,0x00,0x8b,0xf1,0xe8,0xcf,0x2b,0xf5}},
{0x304120,{0x56,0x57,0x8b,0xf9,0xe8,0xa7,0xff,0xff,0xff,0x8b,0xf0,0x6a,0x00,0x8b,0xce,0xe8}},
{0x304cd0,{0x56,0x6a,0x00,0x8b,0xf1,0x52,0x56,0xe8,0x14,0x5a,0xf4,0xff,0x8b,0xd0,0x8b,0xce}},
{0x2f3690,{0x56,0x57,0x8b,0xf9,0xe8,0x77,0xfd,0xff,0xff,0x8b,0xf0,0x85,0xf6,0x75,0x03,0x5f}},
{0x2f3890,{0x85,0xd2,0x56,0x8b,0xf1,0x75,0x06,0x5e,0xe9,0x53,0xff,0xff,0xff,0x57,0x83,0xc9}},
{0x2f39f0,{0x8b,0x41,0x08,0xc7,0x00,0x01,0x00,0x00,0x00,0x56,0x8b,0xf2,0x8b,0x15,0xc0,0xea}},
{0x2f3070,{0x8b,0x41,0x08,0x2b,0x41,0x0c,0xc1,0xf8,0x04,0xc3,0x90,0x90,0x90,0x90,0x90,0x90}},
{0x2f3080,{0x85,0xd2,0x8b,0x41,0x08,0x7c,0x38,0x56,0x8b,0x71,0x0c,0xc1,0xe2,0x04,0x03,0xf2}}
};
static int fileOffset(const unsigned char *image, DWORD rva, DWORD *offset) {
const IMAGE_DOS_HEADER *dos = (const IMAGE_DOS_HEADER *)image;
const IMAGE_NT_HEADERS32 *nt = (const IMAGE_NT_HEADERS32 *)(image + dos->e_lfanew);
const IMAGE_SECTION_HEADER *sections = IMAGE_FIRST_SECTION(nt);
for (unsigned int i = 0; i < nt->FileHeader.NumberOfSections; ++i) {
const IMAGE_SECTION_HEADER *s = sections + i;
if (rva >= s->VirtualAddress && rva + 16 <= s->VirtualAddress + s->SizeOfRawData) {
*offset = s->PointerToRawData + rva - s->VirtualAddress;
return 1;
}
}
return 0;
}
static int matchesClient(void) {
unsigned char *base = (unsigned char *)GetModuleHandleA(NULL);
if ((uintptr_t)base != 0x00400000) return 0;
IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)base;
if (dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew <= 0 || dos->e_lfanew > 4096) return 0;
IMAGE_NT_HEADERS32 *nt = (IMAGE_NT_HEADERS32 *)(base + dos->e_lfanew);
if (nt->Signature != IMAGE_NT_SIGNATURE || nt->FileHeader.Machine != IMAGE_FILE_MACHINE_I386 ||
nt->OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR32_MAGIC ||
nt->OptionalHeader.SizeOfImage < 0x00900000) return 0;
char filename[MAX_PATH];
if (!GetModuleFileNameA(NULL, filename, MAX_PATH) || strlen(filename) >= MAX_PATH - 1) return 0;
HANDLE file = CreateFileA(filename, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE |
FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (file == INVALID_HANDLE_VALUE) return 0;
int ok = 1;
for (SIZE_T i = 0; i < sizeof(signatures) / sizeof(signatures[0]); ++i) {
DWORD offset = 0, count = 0;
unsigned char bytes[16];
if (!fileOffset(base, signatures[i].rva, &offset) ||
SetFilePointer(file, offset, NULL, FILE_BEGIN) == INVALID_SET_FILE_POINTER ||
!ReadFile(file, bytes, sizeof(bytes), &count, NULL) ||
count != sizeof(bytes) || memcmp(bytes, signatures[i].bytes, sizeof(bytes))) {
ok = 0;
break;
}
}
CloseHandle(file);
return ok;
}
static void relativeJump(unsigned char *at, const void *destination) {
at[0] = 0xE9;
int32_t displacement = (int32_t)((uintptr_t)destination - (uintptr_t)at - 5);
memcpy(at + 1, &displacement, 4);
}
static int install(void) {
if (!matchesClient()) return 0;
// These clients require a registration compatibility hook for Lua functions
// living in a DLL. Nampower, SuperWoW, and ClassicAPI provide it.
// Do not alter this shared engine check or interfere with their hooks.
unsigned char registrationSupport = *(unsigned char *)0x0042A320;
if (registrationSupport != 0xE9 && registrationSupport != 0xC3) return 0;
unsigned char *target = (unsigned char *)0x00490250;
// Preserve an existing loader detour (Nampower/SuperWoW load order).
if (target[0] == 0xE9) {
int32_t displacement;
memcpy(&displacement, target + 1, 4);
unsigned char *previous = target + 5 + displacement;
MEMORY_BASIC_INFORMATION info;
if (!VirtualQuery(previous, &info, sizeof(info)) || info.State != MEM_COMMIT ||
!(info.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY))) return 0;
originalLoad = (LoadFunctionsFn)previous;
patchLength = 5;
} else {
if (memcmp(target, signatures[0].bytes, 6)) return 0;
// Two whole instructions: push esi; call rel32. Relocate that call.
trampoline = VirtualAlloc(NULL, 16, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!trampoline) return 0;
unsigned char *t = (unsigned char *)trampoline;
memcpy(t, target, 6);
int32_t displacement;
memcpy(&displacement, target + 2, 4);
uintptr_t callTarget = (uintptr_t)target + 6 + displacement;
displacement = (int32_t)(callTarget - (uintptr_t)t - 6);
memcpy(t + 2, &displacement, 4);
relativeJump(t + 6, target + 6);
DWORD old;
if (!VirtualProtect(t, 16, PAGE_EXECUTE_READ, &old)) {
VirtualFree(t, 0, MEM_RELEASE); trampoline = NULL; return 0;
}
FlushInstructionCache(GetCurrentProcess(), t, 16);
originalLoad = (LoadFunctionsFn)t;
patchLength = 6;
}
DWORD old;
if (!VirtualProtect(target, patchLength, PAGE_EXECUTE_READWRITE, &old)) return 0;
memcpy(saved, target, patchLength);
relativeJump(target, (void *)(uintptr_t)&LoadHook);
if (patchLength == 6) target[5] = 0x90;
DWORD ignored;
VirtualProtect(target, patchLength, old, &ignored);
FlushInstructionCache(GetCurrentProcess(), target, patchLength);
return 1;
}
BOOL WINAPI DllMain(HINSTANCE module, DWORD reason, LPVOID reserved) {
(void)reserved;
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(module);
if (!install()) {
OutputDebugStringA("LockPort.dll: unsupported client or loader hook; no changes made.\n");
return FALSE;
}
}
// This plugin has process lifetime. Do not hot-unload/reload it.
return TRUE;
}
+64
View File
@@ -0,0 +1,64 @@
# LockPort client mod 1.2.1
Optional companion for LockPort 3.9.3. The addon works without it for manual summons.
## Install with VanillaFixes
1. Close WoW.
2. Back up the existing addon folder and dlls.txt.
3. Install this repository directly as Interface/AddOns/LockPort, with LockPort.toc at that folder's top level.
4. Copy LockPort.dll from the repository root beside VanillaFixes.exe.
5. Add one line containing LockPort.dll at the end of dlls.txt, after other client mods.
6. Launch through VanillaFixes. Join a party or raid on a warlock and enable Auto Summon.
Updating from addon 3.7.2, 3.7.3, or 3.7.4 needs only the addon folder and /reload.
Earlier DLL versions must be replaced, followed by a full client restart.
/lockport debug should report native=1.2.1.
## Compatibility and behavior
Built for the inspected 32-bit OctoWoW 1.18.1 WoW.exe and WoW_Modernized.exe
function signatures. This is not a universal build for all client versions.
It refuses unsupported executables and requires Lua-registration compatibility
provided by Nampower, SuperWoW, or ClassicAPI. Load LockPort after those mods.
The DLL resolves a canonical raid1..raid40 or party1..party4 unit to its GUID,
finds Ritual of Summoning in the spellbook, and invokes the client spellbook
function. It temporarily saves and clears the frame-script execution context
and depth around that call, then restores both. This follows the command context
pattern documented in [ClassicAPI 1.17.1](https://github.com/brues-code/ClassicAPI/blob/v1.17.1/src/Offsets.h).
It limits calls to the Lua thread and rate-limits requests. A successful return
means the call was submitted, not that a server summon or arrival succeeded.
The addon still requires helpers and target eligibility. Helpers click the
portal and the summoned player accepts. Auto Summon and availability
announcements reset on login/reload; leaving the group disables Auto Summon.
The user confirmed successful automatic summoning with 1.2.1 in-game.
Regression tests use mocked game APIs and do not cover every client/mod setup.
To uninstall, close WoW and remove LockPort.dll from dlls.txt. Do not hot-unload.
## Build and tests
Source: LockPort.c. Build from the repository root with an LLVM-MinGW i686 compiler:
powershell -File native/build.ps1 -Compiler i686-w64-mingw32-clang.exe
Python addon tests require lupa with its lua51 module:
python -m pip install lupa
python tests/test_addon.py
Build the native harness as an x86 executable at image base 0x10000000 with ASLR disabled:
i686-w64-mingw32-clang -O2 -Wall -Wextra -Werror -static-libgcc -Wl,--image-base,0x10000000 -Wl,--disable-dynamicbase tests/native_tests.c -o native_tests.exe
native_tests.exe "path/to/WoW.exe" LockPort.dll
The first argument defaults to WoW.exe; the second defaults to LockPort.dll.
Run from the repository root. Tests check unsupported-host rejection, client
signatures, hook relocation/chaining, thread/unit/rate checks, GUID and spellbook
forwarding, and scoped context restoration. No server connection is made.
The binary uses Windows system libraries (UCRT and Kernel32).
SHA256SUMS.txt records its checksum. See CLASSICAPI-REVIEW.md for the API review.
+7
View File
@@ -0,0 +1,7 @@
param([string]$Compiler = "i686-w64-mingw32-clang.exe")
$ErrorActionPreference = "Stop"
$source = Join-Path $PSScriptRoot "LockPort.c"
$output = Join-Path (Split-Path $PSScriptRoot -Parent) "LockPort.dll"
& $Compiler -shared -O2 -Wall -Wextra -Werror -static-libgcc $source -o $output
if ($LASTEXITCODE -ne 0) { throw "LockPort.dll build failed ($LASTEXITCODE)" }
Write-Host "Built $output"
+106
View File
@@ -0,0 +1,106 @@
# Raid group workload tests — LockPort 3.9.0
The full 3.8.0 suite below still passes with the group filter enabled by default
for all raid groups. Additional tests cover:
- Three warlocks assigned groups 1–2 / 3–4 / 5–6 queue six targets without 123
requests, and summon each once through the real scheduler and message bus.
- Overlapping selections retain duplicate prevention.
- Requests in unassigned groups stay queued without being summoned.
- Changing assignment during reservation releases and reassigns targets.
- Bulk queueing skips offline players, tolerates repeated clicks without
duplicate entries, and queues nobody with no groups selected.
- Raid subgroup moves are read from the current roster before casting.
- Unit checks cover default All / None, parties ignoring the raid filter,
reservation cancellation, actual GetRaidRosterInfo subgroup parsing, direct
and next-in-line manual casts, and group-picker UI controls.
These are simulated tests. Live UI and multi-client checks are still pending.
The older-version and communication-loss limits below remain applicable.
---
# Coordination test report — LockPort 3.8.0
Tested on 2026-10-27 using independent Lua 5.1 runtimes. Production addon
source retains Lua 5.0-compatible syntax. The game APIs, channel start/stop,
and network transport are simulated; no live WoW clients or server were used.
## Results
The frozen 3.7.8 fixture reproduces the original race: two warlocks both cast
at the same target at 103.3 seconds with 300 ms message latency.
With 3.8.0 on every automatic caster:
- Two and three warlocks divide queued targets in raids and valid five-player
parties. Party unit indices exclude self and stay within party1..party4.
- 100 randomized three-client runs process 560 requests without a duplicate.
Forty runs use 50–1400 ms per-message delay and five requests each; sixty
use 0–1800 ms delay and six requests each. Delays independently reorder messages.
- Delayed ACKs defer casting. Dropped ACKs block casts while peer heartbeats
continue; delivering messages again restores progress.
- Movement, missing helpers, and missing shards exclude an unready caster.
- Disabling Auto Summon, disconnecting, moving, native rejection, and no-start
timeout release a reservation for another caster.
- A stale ACK cannot authorize a newer reservation. A release delivered before
its reserve prevents the old reserve from returning.
- Reloading during reservation performs a fresh handshake.
- A confirmed start holds the target for 130 seconds even when the owner starts
another target. The hold expires rather than blocking forever.
- An updated manual click respects a received remote reservation.
- A request initially received by one caster is shared and summoned once.
- Outsiders, non-warlocks, malformed/unknown protocol messages, and messages
from the wrong chat channel do not create reservations.
- Existing chat-mute, cast tracking, UI, request handling, and ClassicAPI
regression checks continue to pass.
## Older versions
Real frozen addon fixtures, not a rewritten legacy event handler, are used:
- LockPort 3.7.8 from the delivered package.
- Original repository addon from commit c7fe979ddb90f005f727e9bc561d6dd456847f80:
https://github.com/OEVG2/LockPort/blob/c7fe979ddb90f005f727e9bc561d6dd456847f80/LockPort/LockPort.lua
Both fixtures share add/remove messages in both directions. A received older
manual summon claim prevents the updated automatic client from duplicating it.
This verifies those specific versions and message orders, not every old fork.
## Explicit limits reproduced by the tests
A 3.7.8 automatic caster cannot participate in the new reservation/ACK protocol.
When it and 3.8.0 start together, duplicate casts remain possible. Upgrade all
automatic casters to 3.8.0 for the new coordination.
Total message loss before discovering another automatic caster can also produce
duplicates. Heartbeat expiry permits recovery but cannot prove that an
unreachable client stopped casting. No finite timeout guarantees exclusivity
under arbitrary network partitions. A manual cast made before receiving a
reservation or claim can likewise race with an automatic cast.
These are executable known-limit demonstrations, not claimed passing guarantees.
## Protocol
LPCoord protocol 1 is hidden addon traffic, separate from public chat. Heartbeats
advertise enabled/disabled Auto Summon every 2 seconds. A caster reserves one
target, waits at least 2 seconds, and requires ACKs from discovered enabled peers.
Competing reservations yield by character-name order; losers pick another target.
Reservation leases last 6 seconds and peer heartbeats expire after 8 seconds.
Cast attempts and confirmed starts are separate states. Existing RSAdd, RSRemove,
lp VERSION and LPWhisper messages remain compatible. Chat Off mutes player-facing
messages while retaining synchronization and reservations.
Run from the package/repository root:
python -m pip install lupa
python tests/test_addon.py
python tests/test_coordination.py
## Live check still required
Update two warlocks to 3.8.0, restart/reload the addon, enable Auto Summon on both,
and have two distant group members request summons together. Confirm each is
summoned once, then repeat with one caster moving or turning Auto Summon off.
DLL 1.2.1 is unchanged. This build still needs live multi-warlock validation.
+1435
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+146
View File
@@ -0,0 +1,146 @@
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
#include <stdint.h>
#include <assert.h>
#include <string.h>
static unsigned char *client;
static const char *clientPath="WoW.exe";
static HMODULE WINAPI TestGetModuleHandleA(LPCSTR name) {
return name ? GetModuleHandleA(name) : (HMODULE)client;
}
static DWORD WINAPI TestGetModuleFileNameA(HMODULE module, LPSTR dest, DWORD length) {
if (module) return GetModuleFileNameA(module,dest,length);
if(strlen(clientPath)+1>length)return 0;
strcpy(dest,clientPath); return (DWORD)strlen(dest);
}
#define GetModuleHandleA TestGetModuleHandleA
#define GetModuleFileNameA TestGetModuleFileNameA
#include "../native/LockPort.c"
#undef GetModuleHandleA
#undef GetModuleFileNameA
static LuaFunction versionAPI, summonAPI;
static int registrations, originalCalls, executions, booleanValue, topRestores;
static const char *inputUnit;
static char versionValue[32];
static void __stdcall FakeOriginal(void) { originalCalls++; }
static void __fastcall FakeRegister(const char *name,LuaFunction fn) {
if(!strcmp(name,"GetLockPortVersion"))versionAPI=fn;
else if(!strcmp(name,"LockPortNativeSummon"))summonAPI=fn;
else assert(0);
registrations++;
}
static const char *__fastcall FakeToString(void *s,int index) { (void)s;assert(index==1);return inputUnit; }
static void __fastcall FakePushString(void *s,const char *text) { (void)s;strcpy(versionValue,text); }
static void __fastcall FakePushBoolean(void *s,int b) { (void)s;booleanValue=b; }
static int __fastcall FakeGetTop(void *s) { (void)s;return 1; }
static void __fastcall FakeSetTop(void *s,int top) { (void)s;assert(top==1);topRestores++; }
static uint64_t __fastcall FakeGuid(const char *unit) {
assert(!strcmp(unit,"raid2") || !strcmp(unit,"party4"));
return UINT64_C(0x1122334455667788);
}
static int32_t __fastcall FakeSlot(const char *spell, uint32_t *book) {
assert(!strcmp(spell,"Ritual of Summoning"));
*book=0;return 7;
}
static void __fastcall FakeBookCast(uint32_t slot,uint32_t book,uint64_t guid) {
assert(slot==7&&book==0&&guid==UINT64_C(0x1122334455667788));
// Model the real action gate: a frame-script context refuses the cast.
if (*(uintptr_t *)0xCEEAC0 || *(uint32_t *)0xCEEAC4) return;
executions++;
}
static void redirect(uintptr_t address, void *fn) { relativeJump((unsigned char *)address,fn); }
static void loadClient(void) {
// Reserve only the handful of code regions used by these tests.
// A full low-address image can overlap the test process's CRT heap.
const uintptr_t regions[]={0x400000,0x420000,0x490000,0x4b0000,0x510000,0x6f0000,0x700000,0xce0000};
for(unsigned i=0;i<sizeof(regions)/sizeof(regions[0]);i++)
assert(VirtualAlloc((void *)regions[i],0x10000,
MEM_COMMIT|MEM_RESERVE,PAGE_EXECUTE_READWRITE)==(void *)regions[i]);
client=(unsigned char *)0x400000;
FILE *f=fopen(clientPath,"rb");assert(f);
fseek(f,0,SEEK_END);long size=ftell(f);rewind(f);
unsigned char *raw=malloc((size_t)size);assert(raw);
assert(fread(raw,1,(size_t)size,f)==(size_t)size);fclose(f);
IMAGE_DOS_HEADER *dos=(IMAGE_DOS_HEADER *)raw;
IMAGE_NT_HEADERS32 *nt=(IMAGE_NT_HEADERS32 *)(raw+dos->e_lfanew);
memcpy(client,raw,nt->OptionalHeader.SizeOfHeaders);
IMAGE_SECTION_HEADER *sections=IMAGE_FIRST_SECTION(nt);
for(unsigned j=1;j<sizeof(regions)/sizeof(regions[0]);j++){
DWORD rva=(DWORD)(regions[j]-0x400000);
for(int i=0;i<nt->FileHeader.NumberOfSections;i++){
IMAGE_SECTION_HEADER *s=sections+i;
if(rva>=s->VirtualAddress && rva+0x10000<=s->VirtualAddress+s->SizeOfRawData)
memcpy((void *)regions[j],raw+s->PointerToRawData+rva-s->VirtualAddress,0x10000);
}
}
free(raw);
}
static void stubFunctions(void) {
redirect(0x704120,(void *)(uintptr_t)&FakeRegister);
redirect(0x6f3690,(void *)(uintptr_t)&FakeToString);
redirect(0x6f3890,(void *)(uintptr_t)&FakePushString);
redirect(0x6f39f0,(void *)(uintptr_t)&FakePushBoolean);
redirect(0x6f3070,(void *)(uintptr_t)&FakeGetTop);
redirect(0x6f3080,(void *)(uintptr_t)&FakeSetTop);
redirect(0x515970,(void *)(uintptr_t)&FakeGuid);
redirect(0x4b3950,(void *)(uintptr_t)&FakeSlot);
redirect(0x4b3300,(void *)(uintptr_t)&FakeBookCast);
}
int main(int argc, char **argv) {
if (argc > 1) clientPath = argv[1];
loadClient();
// The actual built DLL must reject this unrelated host executable.
const char *dllPath = argc > 2 ? argv[2] : "LockPort.dll";
assert(GetFileAttributesA(dllPath) != INVALID_FILE_ATTRIBUTES);
HMODULE dll=LoadLibraryA(dllPath);
DWORD loadError=GetLastError();
assert(!dll && loadError==ERROR_DLL_INIT_FAILED);
puts("PASS DLL rejects an unsupported host");
assert(matchesClient());
puts("PASS inspected client function signatures");
assert(validUnit("raid1")&&validUnit("raid40")&&validUnit("party1")&&validUnit("party4"));
const char *invalid[]={"player","party0","party5","party01","party1x","raid0","raid41","raid01","raid2x","raid2\");print(1)--","raid",NULL};
for(unsigned i=0;i<sizeof(invalid)/sizeof(invalid[0]);i++)assert(!validUnit(invalid[i]));
puts("PASS unit validation excludes non-group tokens and script input");
// Exercise the real relocated prologue, replacing only its subsequent work.
*(unsigned char *)0x706350=0xC3;
*(unsigned char *)0x490256=0x5E; // original loader eventually restores esi
redirect(0x490257,(void *)(uintptr_t)&FakeOriginal);
stubFunctions();
assert(!install());
puts("PASS missing Lua-registration compatibility hook rejected");
*(unsigned char *)0x42A320=0xC3; // supplied by existing client mods
assert(install());
((LoadFunctionsFn)0x490250)();
assert(originalCalls==1&&registrations==2&&versionAPI&&summonAPI);
puts("PASS relocated loader trampoline and API registration");
assert(versionAPI((void *)1)==1&&!strcmp(versionValue,"1.2.1"));
*(uintptr_t *)0xCEEAC0=0x12345678;
*(uint32_t *)0xCEEAC4=3;
FakeBookCast(7,0,UINT64_C(0x1122334455667788));
assert(executions==0);
puts("PASS frame-script context reproduces the old silent cast refusal");
inputUnit="raid2";assert(summonAPI((void *)1)==2&&booleanValue==1&&executions==1&&topRestores==0);
assert(summonAPI((void *)1)==2&&booleanValue==0&&executions==1);
assert(*(uintptr_t *)0xCEEAC0==0x12345678 && *(uint32_t *)0xCEEAC4==3);
puts("PASS scoped command context permits cast and restores both values");
puts("PASS native summon bridge, explicit spellbook slot and target GUID, and rate limit");
hasAttempt=0; inputUnit="raid2x";summonAPI((void *)1);
assert(booleanValue==0&&executions==1);
inputUnit="raid2";luaThread=GetCurrentThreadId()+1;summonAPI((void *)1);
assert(booleanValue==0&&executions==1);
puts("PASS bad target and wrong game thread rejected");
luaThread=GetCurrentThreadId();hasAttempt=0;inputUnit="party4";summonAPI((void *)1);
assert(booleanValue==1&&executions==2);
puts("PASS native party summon dispatch");
// Exercise coexistence with an earlier mod's loader detour.
DWORD old;VirtualProtect((void *)0x490250,6,PAGE_EXECUTE_READWRITE,&old);
memcpy((void *)0x490250,signatures[0].bytes,6);
redirect(0x490250,(void *)(uintptr_t)&FakeOriginal);
assert(install());((LoadFunctionsFn)0x490250)();
assert(originalCalls==2&&registrations==4);
puts("PASS existing mod loader detour preserved");
return 0;
}
+49
View File
@@ -0,0 +1,49 @@
PASS off without native DLL
PASS requires group
PASS enables open announcements
PASS auto starts without input after settling
PASS no duplicate or immediate uncertain retry
PASS movement pauses auto casting
PASS live ClassicAPI casting pauses auto casting
PASS works without ClassicAPI
PASS not ready pauses auto casting
PASS leaving raid disables and restores announcements
PASS disabling auto always disables announcements
PASS native disappearance disables
PASS interruption with arrival
PASS interruption with unconfirmed completion
PASS automatic native spellbook Summon dispatch
PASS manual Summon dispatch
PASS syntax LockPort.lua
PASS syntax LockPort_UI.lua
PASS UI native-only visibility, expanded layout, raid gating, and button toggle
PASS party enables automatic casting
PASS raid to party conversion stays enabled
PASS leaving party disables and restores announcements
PASS old raid-only DLL is rejected
PASS live idle clears stale busy flag
PASS missing stop event releases pending summon conservatively
PASS active channel is never cleared by recovery
PASS unknown live state preserves tracking
PASS brief idle gap during casting is tolerated
PASS integrated automatic party2 cast receives start event
PASS integrated automatic raid2 cast receives start event
PASS off clears announcements even when already off
PASS reload clears announcements auto
PASS reload clears announcements manual
PASS ClassicAPI chat payload survives overwritten globals
PASS legacy chat fallback snapshots sender before roster scan
PASS empty ClassicAPI chat result uses legacy fallback
PASS ClassicAPI chat errors use legacy fallback
PASS non-request actual payload does not use corrupted trigger globals
PASS nested spell event does not replay outer chat
PASS addon sync payload is not replaced by chat reader
PASS mute blocks summon say and whisper then resumes
PASS mute clears pending status whispers and skips new replies
PASS mute blocks forced availability announcements
PASS raid group selection defaults to all and supports none
PASS group selection ignores party roster
PASS group selection releases outstanding reservation
PASS real raid roster subgroup parsing and subgroup changes
PASS Summon and direct row casts respect raid group selection
PASS saved raid group selection survives initialization
+273
View File
@@ -0,0 +1,273 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path("work/python-deps").resolve()))
from lupa.lua51 import LuaRuntime
PACKAGE=Path(__file__).resolve().parent.parent
core=(PACKAGE/"LockPort.lua").read_text(encoding="utf-8")
setup=r'''
now=100; raidSize=5; partySize=0; moving=0; casting=nil; nativeOK=true
function GetTime() return now end
function GetRaidRosterInfo() return nil end
function GetNumRaidMembers() return raidSize end
function GetNumPartyMembers() return partySize end
function GetLockPortVersion() return "1.2.1" end
function LockPortNativeSummon(unit) nativeCalls=(nativeCalls or 0)+1; lastUnit=unit; return nativeOK end
function GetUnitSpeed() return moving end
function UnitName(u) if u=="player" then return "Tester" end return "Bob" end
function UnitClass() return "Warlock","WARLOCK" end
function UnitAffectingCombat() return false end
function UnitIsConnected() return true end
function UnitIsDeadOrGhost() return false end
function CreateFrame() return {RegisterEvent=function() end, SetScript=function() end} end
function SendAddonMessage() end
function SendChatMessage() end
function GetZoneText() return "Test" end
function GetSubZoneText() return "" end
function TargetUnit() end
function CastSpellByName() manualCalls=(manualCalls or 0)+1 end
C_Spell={UnitCastingInfo=function() return casting end,UnitChannelInfo=function() return nil end}
SlashCmdList={}
LockPortOptions={announceOpen=false,askHelpers=false,autoRemove=true}
'''
def env():
L=LuaRuntime()
L.execute(setup); L.execute(core)
L.execute(r'''
LP=LockPort; LP.mods={}
LP.Print=function(self,text) lastPrint=text end
LP.RefreshOptions=function() end; LP.UpdateUI=function() end
LP.OpenAnnounce=function() openCalls=(openCalls or 0)+1 end
LP.ReadyState=function() return true end
LP.NextCandidate=function() if LP.autoRetryAfter and LP.autoRetryAfter.Bob and now<LP.autoRetryAfter.Bob then return nil end;return {name="Bob"} end
LP.Summon=function(self,name,automatic) casts=(casts or 0)+1; wasAutomatic=automatic end
''')
return L
def check(name,script):
L=env(); L.execute(script); print("PASS",name)
check("off without native DLL",r'''GetLockPortVersion=nil; LP:SetRaidAutoSummon(true); assert(not LP.raidAutoSummon)''')
check("requires group",r'''raidSize=0; LP:SetRaidAutoSummon(true); assert(not LP.raidAutoSummon)''')
check("enables open announcements",r'''LP:SetRaidAutoSummon(true); assert(LP.raidAutoSummon and LockPortOptions.announceOpen and openCalls==1)''')
check("auto starts without input after settling",r'''LP:SetRaidAutoSummon(true); LP:CheckRaidAutoSummon(100); LP:CheckRaidAutoSummon(101); assert(not casts); LP:CheckRaidAutoSummon(102); assert(casts==1 and wasAutomatic)''')
check("no duplicate or immediate uncertain retry",r'''LP:SetRaidAutoSummon(true); LP:CheckRaidAutoSummon(100); now=102;LP:CheckRaidAutoSummon(now); now=103;LP:CheckRaidAutoSummon(now);now=106;LP:CheckRaidAutoSummon(now); assert(casts==1)''')
check("movement pauses auto casting",r'''LP:SetRaidAutoSummon(true);moving=7; LP:CheckRaidAutoSummon(100);LP:CheckRaidAutoSummon(110);assert(not casts)''')
check("live ClassicAPI casting pauses auto casting",r'''LP:SetRaidAutoSummon(true);casting="Shadow Bolt"; LP:CheckRaidAutoSummon(100);LP:CheckRaidAutoSummon(110);assert(not casts)''')
check("works without ClassicAPI",r'''GetUnitSpeed=nil;C_Spell=nil;LP:SetRaidAutoSummon(true); LP:CheckRaidAutoSummon(100); LP:CheckRaidAutoSummon(102);assert(casts==1)''')
check("not ready pauses auto casting",r'''LP.ReadyState=function() return false end;LP:SetRaidAutoSummon(true);LP:CheckRaidAutoSummon(100);LP:CheckRaidAutoSummon(102);assert(not casts)''')
check("leaving raid disables and restores announcements",r'''LP:SetRaidAutoSummon(true);raidSize=0;LP.CheckRaidAutoSummon(LP,101);assert(not LP.raidAutoSummon and not LockPortOptions.announceOpen)''')
check("disabling auto always disables announcements",r'''LockPortOptions.announceOpen=true;LP:SetRaidAutoSummon(true);LP:SetRaidAutoSummon(false);assert(not LockPortOptions.announceOpen)''')
check("native disappearance disables",r'''LP:SetRaidAutoSummon(true);LockPortNativeSummon=nil;LP:CheckRaidAutoSummon(101);assert(not LP.raidAutoSummon)''')
# Test actual FinishPending and arrival watcher, with mocked game state.
for arrived in (True,False):
L=env()
L.execute(r'''
LP.pending={name="Bob",unit="raid2",started=true,interrupted=true}
LP.ScanRoster=function() return {Bob={unit="raid3"}} end
LP.IsNearby=function() return '''+("true" if arrived else "false")+r''' end
LP.RemoveRequest=function() removed=true end
LP.AddRequest=function() requeued=true end
LP.Fail=function() failureAnnouncements=(failureAnnouncements or 0)+1 end
LP:FinishPending()
assert(not failureAnnouncements)
assert(not LP.failCount.Bob)
''')
L.execute('assert(removed)' if arrived else 'assert(requeued and LP.watch.unconfirmed)')
print("PASS interruption with "+("arrival" if arrived else "unconfirmed completion"))
# Real Summon path: native attempt, manual fallback, and native rejection.
for native in (True,False):
L=env();L.execute(core)
L.execute(r'''
LP=LockPort;LP.Print=function() end;LP.UpdateUI=function() end
LP.IsWarlock=function() return true end;LP.KnowsRitual=function() return true end
LP.ScanRoster=function() return {Bob={unit="raid2"}} end
LP.TargetProblem=function() return nil end;LP.CountShards=function() return 5 end
LP.CountHelpers=function() return 2 end;LP.SendAddon=function() end
LP:Summon("Bob",'''+("true" if native else "false")+r''')
assert(LP.pending.name=="Bob")
''')
L.execute('assert(nativeCalls==1 and not manualCalls)' if native else 'assert(manualCalls==1 and not nativeCalls)')
print("PASS "+("automatic native spellbook" if native else "manual")+" Summon dispatch")
# All delivered Lua files parse with a Lua 5.1 runtime; source uses Lua 5.0 syntax.
L=LuaRuntime()
for p in PACKAGE.glob("*.lua"):
L.execute('assert(loadstring(...))',p.read_text(encoding="utf-8"))
print("PASS syntax",p.name)
# Build UI with lightweight frame mocks to check control placement and gating.
L=env()
L.execute(r'''
StaticPopupDialogs={};UISpecialFrames={};RAID_CLASS_COLORS={}
frames={}
local methods={}
function methods:GetName() return self.name end
function methods:SetText(v) self.text=v end
function methods:SetWidth(v) self.width=v end
function methods:SetHeight(v) self.height=v end
function methods:SetPoint(...) self.point={...} end
function methods:ClearAllPoints() self.point=nil end
function methods:SetScript(name,fn) self.scripts[name]=fn end
function methods:Hide() self.visible=false end
function methods:Show() self.visible=true end
function methods:IsVisible() return self.visible end
function methods:Enable() self.enabled=true end
function methods:Disable() self.enabled=false end
function methods:SetChecked(v) self.checked=v end
function methods:GetChecked() return self.checked end
local function frame(name)
local f={name=name,scripts={},visible=true,enabled=true}
setmetatable(f,{__index=function(t,k) return methods[k] or function() end end})
if name then _G[name]=f;frames[name]=f end
return f
end
function methods:CreateTexture(name) return frame(name) end
function methods:CreateFontString(name) return frame(name) end
function CreateFrame(kind,name) return frame(name) end
function getglobal(name) return _G[name] end
function GetBindingKey() return nil end
function PlaySound() end
LP.RefreshOptions=function() end
LP.ScanRoster=function() return {} end
LP.SortedRequests=function() return {} end
LP.CountShards=function() return 5 end
LP.InGroup=function() return raidSize>0 or partySize>0 end
LP.CountHelpers=function() return 2 end
LockPortOptions.theme="classic"
''')
L.execute((PACKAGE/"LockPort_UI.lua").read_text(encoding="utf-8"))
L.execute(r'''
LP:CreateUI()
LockPortFrame:Show();LP:RefreshList()
assert(LockPortFrame.raidAuto.visible)
assert(LockPortFrame.raidAuto.width==95 and LockPortFrame.groupSummon.width==95)
assert(LockPortFrame.groupSummon.visible and LockPortFrame.groupSummon.point[2]==LockPortFrame.raidAuto)
LP:ToggleGroups();assert(LockPortGroupsFrame.visible)
this=LockPortGroupsNone;this.scripts.OnClick();assert(LockPortOptions.summonGroups and not next(LockPortOptions.summonGroups))
this=LockPortGroupsFrame.groups[3];this.scripts.OnClick();assert(LockPortOptions.summonGroups[3])
this=LockPortGroupsAll;this.scripts.OnClick();assert(not LockPortOptions.summonGroups)
assert(LockPortFrame.summon.width==95 and LockPortFrame.chatToggle.width==95)
this=LockPortFrame.chatToggle;this.scripts.OnClick();assert(LockPortOptions.chatMuted)
LP:RefreshList();assert(string.find(this.text,"Off"))
this.scripts.OnClick();assert(not LockPortOptions.chatMuted)
local nativeHeight=LockPortFrame.height
assert(LockPortFrame.raidAuto.point[1]=="TOPLEFT")
assert(LockPortFrame.raidAuto.point[3]=="BOTTOMLEFT" and LockPortFrame.raidAuto.point[5]==-2)
GetLockPortVersion=nil;LP:RefreshList()
assert(not LockPortFrame.raidAuto.visible and LockPortFrame.groupSummon.visible and LockPortFrame.height==nativeHeight)
GetLockPortVersion=function() return "1.2.1" end;raidSize=0;LP:RefreshList()
assert(LockPortFrame.raidAuto.visible and not LockPortFrame.raidAuto.enabled)
assert(LockPortFrame.groupSummon.visible and not LockPortFrame.groupSummon.enabled and not LockPortGroupsFrame.visible)
partySize=3;LP:RefreshList();assert(LockPortFrame.groupSummon.visible and not LockPortFrame.groupSummon.enabled)
GetLockPortVersion=nil;LP:RefreshList();assert(LockPortFrame.groupSummon.visible and not LockPortFrame.groupSummon.enabled and LockPortFrame.height==nativeHeight)
GetLockPortVersion=function() return "1.2.1" end;partySize=0
raidSize=5;LP:RefreshList();assert(LockPortFrame.raidAuto.enabled and LockPortFrame.groupSummon.enabled)
this=LockPortFrame.raidAuto;this.checked=true
this.scripts.OnClick()
assert(LP.raidAutoSummon and LockPortOptions.announceOpen)
''')
print("PASS UI native-only visibility, expanded layout, raid gating, and button toggle")
check("party enables automatic casting",r'''raidSize=0;partySize=3;LP:SetRaidAutoSummon(true);assert(LP.raidAutoSummon and LockPortOptions.announceOpen);LP:CheckRaidAutoSummon(100);LP:CheckRaidAutoSummon(102);assert(casts==1 and wasAutomatic)''')
check("raid to party conversion stays enabled",r'''LP:SetRaidAutoSummon(true);raidSize=0;partySize=3;LP:CheckRaidAutoSummon(100);assert(LP.raidAutoSummon);LP:CheckRaidAutoSummon(102);assert(casts==1)''')
check("leaving party disables and restores announcements",r'''raidSize=0;partySize=3;LP:SetRaidAutoSummon(true);partySize=0;LP:CheckRaidAutoSummon(101);assert(not LP.raidAutoSummon and not LockPortOptions.announceOpen)''')
check("old raid-only DLL is rejected",r'''GetLockPortVersion=function() return "1.0.0" end;assert(not LP:HasNativeMod());LP:SetRaidAutoSummon(true);assert(not LP.raidAutoSummon)''')
check("live idle clears stale busy flag",r'''LP.castBusy=true;LP:RecoverCastTracking(100);assert(not LP.castBusy)''')
check("missing stop event releases pending summon conservatively",r'''LP.pending={name="Bob",unit="party1",t=90,started=true};LP.FinishPending=function(self) assert(self.pending.interrupted);self.pending.done=true end;LP:RecoverCastTracking(100);assert(not LP.pending.done);LP:RecoverCastTracking(102);assert(LP.pending.done)''')
check("active channel is never cleared by recovery",r'''C_Spell.UnitChannelInfo=function() return "Ritual of Summoning" end;LP.pending={name="Bob",t=90,started=true};LP:RecoverCastTracking(100);LP:RecoverCastTracking(105);assert(not LP.pending.done and LP.castBusy)''')
check("unknown live state preserves tracking",r'''C_Spell=nil;LP.castBusy=true;LP.pending={name="Bob",t=90,started=true};LP:RecoverCastTracking(100);LP:RecoverCastTracking(105);assert(not LP.pending.done and LP.castBusy)''')
check("brief idle gap during casting is tolerated",r'''LP.pending={name="Bob",t=90,started=true};LP:RecoverCastTracking(100);casting="Ritual of Summoning";LP:RecoverCastTracking(101);assert(not LP.pending.inactiveSince and not LP.pending.done)''')
# Exercise the real scheduler, candidate checks, Summon, and start-event handler together.
for unit in ("party2", "raid2"):
L=env();L.execute(core)
L.execute(r'''
LP=LockPort
raidSize='''+("0" if unit.startswith("party") else "5")+r''';partySize=3
LP.Print=function() end;LP.UpdateUI=function() end;LP.RefreshOptions=function() end
LP.OpenAnnounce=function() end;LP.SendAddon=function() end
LP.KnowsRitual=function() return true end
LP.CountShards=function() return 5 end;LP.CountHelpers=function() return 2 end
LP.TargetProblem=function() return nil end
LP.roster={Bob={unit="'''+unit+r'''",online=true}}
LP.ScanRoster=function(self) return self.roster end
LP.SortedRequests=function(self) return {{name="Bob",info=self.roster.Bob}} end
LP.requests={{name="Bob",t=90}}
LockPortNativeSummon=function(unit) actualCasts=actualCasts+1;arg1=LP.SPELL;LP:OnEvent("SPELLCAST_START");return true,"spellbook request sent" end
actualCasts=0
CastSpellByName=function(name)
actualCasts=actualCasts+1;arg1=name;LP:OnEvent("SPELLCAST_START")
end
LP:SetRaidAutoSummon(true)
LP:CheckRaidAutoSummon(100)
now=102;LP:CheckRaidAutoSummon(now)
assert(actualCasts==1 and LP.pending.started and LP.pending.automatic)
assert(not LP.requests[1] and #LP.autoTrace>0)
''')
print("PASS integrated automatic "+unit+" cast receives start event")
check("off clears announcements even when already off",r'''LP.raidAutoSummon=false;LockPortOptions.announceOpen=true;LP:SetRaidAutoSummon(false);assert(not LP.raidAutoSummon and not LockPortOptions.announceOpen)''')
# Simulate SavedVariables surviving a reload while addon runtime state is recreated.
for enabled_by_auto in (True, False):
before=env()
if enabled_by_auto:
before.execute("LP:SetRaidAutoSummon(true)")
else:
before.execute("LockPortOptions.announceOpen=true")
after=env()
after.globals().LockPortOptions.announceOpen=before.globals().LockPortOptions.announceOpen
after.execute(r'''
LP.DetectMods=function(self) self.mods={} end
LP.CreateUI=function() end
GetAddOnMetadata=function() return "test" end
LP:Initialize()
assert(not LP.raidAutoSummon and not LockPortOptions.announceOpen)
sent=0;SendChatMessage=function() sent=sent+1 end
''')
# env overrides OpenAnnounce; restore the production method before exercising its timer gate.
after.execute(core)
after.execute("LockPort:OpenAnnounce(1000); assert(sent==0)")
print("PASS reload clears announcements", "auto" if enabled_by_auto else "manual")
# Chat dispatch uses stable message/sender values and never replays an outer chat
# event while handling a nested spell or addon-sync event.
def chat_check(name, reader, globals_text="123", globals_sender="Bob"):
L=env()
L.globals().arg1=globals_text; L.globals().arg2=globals_sender
L.execute(r'''
LP.MatchesTrigger=function(self,text) return text=="123" end
LP.ScanRoster=function() arg1="clobbered";arg2="Wrong";return {Bob={unit="raid2"}} end
LP.OnTrigger=function(self,name,info,event) triggered=(triggered or 0)+1;assert(name=="Bob" and info.unit=="raid2");triggerEvent=event end
''')
L.execute(reader)
L.execute('LP:OnEvent("CHAT_MSG_RAID");assert(triggered==1 and triggerEvent=="CHAT_MSG_RAID")')
print("PASS",name)
chat_check("ClassicAPI chat payload survives overwritten globals",'GetCurrentChatEvent=function() return "CHAT_MSG_RAID","123","Bob" end',"wrong","Wrong")
chat_check("legacy chat fallback snapshots sender before roster scan",'GetCurrentChatEvent=nil')
chat_check("empty ClassicAPI chat result uses legacy fallback",'GetCurrentChatEvent=function() end')
chat_check("ClassicAPI chat errors use legacy fallback",'GetCurrentChatEvent=function() error("unavailable") end')
check("non-request actual payload does not use corrupted trigger globals",r'''GetCurrentChatEvent=function() return "CHAT_MSG_RAID","hello","Bob" end;arg1="123";arg2="Bob";LP.OnTrigger=function() error("false request") end;LP:OnEvent("CHAT_MSG_RAID")''')
check("nested spell event does not replay outer chat",r'''GetCurrentChatEvent=function() return "CHAT_MSG_RAID","123","Bob" end;LP.OnTrigger=function() error("duplicate request") end;arg1="Shadow Bolt";LP:OnEvent("SPELLCAST_START");assert(LP.castBusy)''')
check("addon sync payload is not replaced by chat reader",r'''GetCurrentChatEvent=function() error("must not read addon sync") end;LP.AddRequest=function(self,name) assert(name=="Bob");synced=true end;LP.ScanRoster=function() return {} end;arg1="RSAdd";arg2="Bob";arg4="Other";LockPortOptions.autoRemove=false;LP:OnEvent("CHAT_MSG_ADDON");assert(synced)''')
check("mute blocks summon say and whisper then resumes",r'''LP.CountShards=function() return 5 end;LP.RemoveRequest=function() end;LP.SendAddon=function() end;sent=0;SendChatMessage=function() sent=sent+1 end;LockPortOptions.whisper=true;LP:SetChatMuted(true);LP.pending={name="Bob"};LP:OnRitualStarted();assert(sent==0 and LP.pending.started);LP:SetChatMuted(false);LP.pending={name="Bob"};LP:OnRitualStarted();assert(sent==2)''')
check("mute clears pending status whispers and skips new replies",r'''LP.whisperQueue={{key="status:Bob",name="Bob",text="old",at=0}};LP:SetChatMuted(true);assert(table.getn(LP.whisperQueue)==0);LockPortOptions.whisperStatus=true;LP:QueueWhisper("Bob","status","new");assert(table.getn(LP.whisperQueue)==0);LP:SetChatMuted(false);LP:ProcessWhisperQueue(100);assert(table.getn(LP.whisperQueue)==0)''')
L=env();L.execute(core);L.execute(r'''LP=LockPort;LockPortOptions.chatMuted=true;GetNumRaidMembers=function() error("must stop before sending") end;LP:OpenAnnounce(100,true)''');print("PASS mute blocks forced availability announcements")
check("raid group selection defaults to all and supports none",r'''assert(LP:IsSelectedRaidGroup({subgroup=8}));LP:SetSummonGroups({});assert(not LP:IsSelectedRaidGroup({subgroup=1}));LP:SetSummonGroups(nil);assert(LP:IsSelectedRaidGroup({subgroup=1}))''')
check("group selection ignores party roster",r'''raidSize=0;partySize=3;LP:SetSummonGroups({[8]=true});assert(LP:IsSelectedRaidGroup({unit="party1"}))''')
check("group selection releases outstanding reservation",r'''LP.coordReservation={id=1,target="Bob",phase="reserve"};LP.autoReadyName="Bob";LP:SetSummonGroups({[2]=true});assert(not LP.coordReservation and not LP.autoReadyName);assert(LP:IsSelectedRaidGroup({subgroup=2}) and not LP:IsSelectedRaidGroup({subgroup=1}))''')
# Parse the real raid roster's third field and immediately follow subgroup moves.
L=env();L.execute(r'''raidSize=1;group=4;function GetRaidRosterInfo() return "Bob",0,group,60,"Warrior","WARRIOR","Test",true,false end;LP:SetSummonGroups({[4]=true});assert(LP:IsSelectedRaidGroup(LP:ScanRoster().Bob));group=5;assert(not LP:IsSelectedRaidGroup(LP:ScanRoster().Bob))''');print("PASS real raid roster subgroup parsing and subgroup changes")
# Exercise real candidate selection and direct casts against an assignment.
L=env();L.execute(core);L.execute(r'''
LP=LockPort;LP.Print=function() end;LP.UpdateUI=function() end;LP.SendAddon=function() end
LP.roster={Alice={unit="raid2",subgroup=1,online=true},Bob={unit="raid3",subgroup=2,online=true}}
LP.ScanRoster=function(self) return self.roster end;LP.TargetProblem=function() return nil end
LP.CountShards=function() return 5 end;LP.CountHelpers=function() return 2 end;LP.KnowsRitual=function() return true end
LP.requests={{name="Alice",t=1},{name="Bob",t=2}}
LP:SetSummonGroups({[2]=true});assert(LP:NextCandidate().name=="Bob")
LP:Summon("Alice");assert(not LP.pending and not manualCalls)
LP:SummonNext();assert(LP.pending.name=="Bob" and manualCalls==1)
LP.pending=nil;LP:SetSummonGroups({});assert(not LP:NextCandidate())
''');print("PASS Summon and direct row casts respect raid group selection")
check("saved raid group selection survives initialization",r'''LockPortOptions.summonGroups={[2]=true,[7]=true};LP.DetectMods=function(self) self.mods={} end;LP.CreateUI=function() end;GetAddOnMetadata=function() return "3.9.0" end;LP:Initialize();assert(LockPortOptions.summonGroups[2] and LockPortOptions.summonGroups[7] and not LockPortOptions.summonGroups[1])''')
+35
View File
@@ -0,0 +1,35 @@
PASS reproduced duplicate cast with two uncoordinated 3.7.8 clients
PASS 2 warlocks split requests in raid [(103.3, 'Alpha', 'Target1'), (104.4, 'Bravo', 'Target2')]
PASS 2 warlocks split requests in party [(103.3, 'Alpha', 'Target1'), (104.4, 'Bravo', 'Target2')]
PASS 3 warlocks split requests in raid [(103.3, 'Alpha', 'Target1'), (104.4, 'Bravo', 'Target2'), (105.5, 'Charlie', 'Target3')]
PASS 3 warlocks split requests in party [(103.3, 'Alpha', 'Target1'), (104.4, 'Bravo', 'Target2')]
PASS 40 randomized delayed/reordered three-client runs, five requests each
PASS delayed acknowledgements prevent early cast
PASS lost acknowledgements hold casting; recovery after delivery resumes
PASS unready warlock excluded: moving
PASS unready warlock excluded: noHelpers
PASS unready warlock excluded: noShards
PASS reservation handoff after off
PASS reservation handoff after disconnect
PASS reservation handoff after movement
PASS handoff after native reject
PASS handoff after native no-start
PASS old manual summon claim prevents new automatic duplicate
PASS bidirectional list synchronization with 3.7.8
KNOWN LIMIT mixed new/3.7.8 simultaneous auto can still duplicate: [(103.3, 'Alpha', 'Target1'), (103.3, 'Bravo', 'Target1')]
KNOWN LIMIT total message loss before peer discovery can duplicate
PASS delayed ACK cannot authorize a newer reservation
PASS out-of-order release prevents a stale reservation from returning
PASS acceptance hold survives owner starting another target and later expires
PASS reload during reservation re-handshakes without duplicate cast
PASS updated manual click respects another updated warlock reservation
PASS malformed, non-warlock, outsider and wrong-channel messages ignored
PASS request initially visible to one warlock is shared and cast once
PASS 60 additional randomized three-client runs, six requests each
PASS original repository addon: list sync both directions and manual cast claim
PASS three warlocks queue assigned raid groups without requests and split six targets
PASS overlapping group assignments still coordinate without duplicate casts
PASS unassigned-group requests stay queued and are not summoned
PASS assignment change during reservation releases and reassigns targets
PASS group queue skips offline players, is idempotent, and handles no selection
PASS current raid subgroup moves are honored before casting
+390
View File
@@ -0,0 +1,390 @@
"""Independent Lua clients; simulated WoW APIs and delayed addon-message bus.
Runs the real scheduler, reservation, candidate, cast-start and queue code.
No WoW process, network server, or native DLL is executed.
"""
import heapq
import random
import sys
from pathlib import Path
from zipfile import ZipFile
sys.path.insert(0, str(Path('work/python-deps').resolve()))
from lupa.lua51 import LuaRuntime
PACKAGE = Path(__file__).resolve().parent.parent
CORE = (PACKAGE/'LockPort.lua').read_text(encoding='utf-8')
# Frozen pre-reservation code, used as a real mixed-version client.
LEGACY = (PACKAGE/'tests/fixtures/LockPort-3.7.8.lua').read_text(encoding='utf-8')
class World:
def __init__(self, names=('Alpha', 'Bravo', 'Charlie'), targets=3, delay=0.2, seed=0, party=False):
self.now=100.0; self.rng=random.Random(seed); self.delay=delay
self.names=list(names); self.targets=['Target'+str(i+1) for i in range(targets)]
self.members=self.names+['HelperOne','HelperTwo']+self.targets
if party:self.members=self.names+self.targets+['HelperOne','HelperTwo'][:max(0,5-len(self.names)-len(self.targets))]
self.subgroups={n:8 for n in self.members}; self.subgroups.update({n:i+1 for i,n in enumerate(self.targets)})
self.online={n:True for n in self.members}; self.clients={};self.events=[];self.serial=0
self.casts=[];self.messages=[];self.drop=lambda *args:False;self.party=party
self.reject=set();self.no_start=set();self.extra_delay=lambda *args:0
def later(self, delay, fn):
self.serial+=1;heapq.heappush(self.events,(self.now+delay,self.serial,fn))
def send(self,sender,prefix,message,channel):
assert "|" not in message, "Invalid escape code in addon message: " + message
self.messages.append((self.now,sender,prefix,message))
for receiver in self.clients:
if receiver==sender or not self.online.get(receiver):continue
if self.drop(sender,receiver,prefix,message):continue
delay=self.delay(self.rng) if callable(self.delay) else self.delay
delay+=self.extra_delay(sender,receiver,prefix,message)
self.later(delay,lambda r=receiver:self.deliver(r,prefix,message,channel,sender))
def deliver(self,receiver,prefix,message,channel,sender):
if not self.online.get(receiver):return
c=self.clients[receiver];g=c.globals()
g.arg1,g.arg2,g.arg3,g.arg4=prefix,message,channel,sender
c.execute('LockPort:OnEvent("CHAT_MSG_ADDON")')
def roster(self,L,me):
return L.table_from({n:L.table_from(dict(unit=('party' if self.party else 'raid')+str(i+1),
online=self.online[n], subgroup=self.subgroups[n], **{'class':'WARLOCK' if n in self.names else 'WARRIOR'}))
for i,n in enumerate([n for n in self.members if not self.party or n!=me]) if self.online[n]})
def client(self,name,legacy=False,source=None):
L=LuaRuntime();g=L.globals();g.me=name;g.now=self.now
unit_names=[n for n in self.members if not self.party or n!=name]
g.pyUnitName=lambda u: name if u=='player' else unit_names[int(u[5:] if u.startswith('party') else u[4:])-1]
g.pyRoster=lambda:self.roster(L,name)
g.pySend=lambda prefix,msg,chan:self.send(name,prefix,msg,chan)
g.pyConnected=lambda unit:self.online.get(g.pyUnitName(unit),False)
g.pyNative=lambda unit:self.cast(name,unit)
L.execute(r'''
function GetTime() return now end
function UnitName(u) return pyUnitName(u) end
function UnitClass(u) return "Warlock","WARLOCK" end
function UnitIsConnected(u) return pyConnected(u) end
function UnitIsDeadOrGhost() return false end
function UnitAffectingCombat() return false end
function UnitIsVisible() return false end
function GetLockPortVersion() return "1.2.1" end
function LockPortNativeSummon(u) return pyNative(u) end
function CastSpellByName() return pyNative(LockPort.pending.unit) end
function TargetUnit() end
function GetUnitSpeed() return moving and 1 or 0 end
C_Spell={UnitCastingInfo=function() return nil end,UnitChannelInfo=function() return channel end}
function CreateFrame() return {RegisterEvent=function() end,SetScript=function() end} end
function SendAddonMessage(p,m,c) pySend(p,m,c) end
function SendChatMessage() chatCount=(chatCount or 0)+1 end
function GetZoneText() return "Test Zone" end
function GetSubZoneText() return "" end
function PlaySound() end
function GetAddOnMetadata() return "3.8.0" end
function GetNumRaidMembers() return inParty and 0 or memberCount end
function GetNumPartyMembers() return inParty and (memberCount-1) or 0 end
function IsInGuild() return false end
SlashCmdList={}
LockPortOptions={announceOpen=false,askHelpers=false,autoRemove=false,sound=false,
readySound=false,flash=false,whisperStatus=false,chatMuted=true,triggers="123"}
''')
g.inParty=self.party;g.memberCount=len(self.members)
L.execute(source if source is not None else (LEGACY if legacy else CORE))
L.execute(r'''
LP=LockPort;LP.mods={}
LP.Print=function(self,msg) lastPrint=msg end
LP.UpdateUI=function() end;LP.RefreshOptions=function() end
LP.ScanRoster=function(self) self.roster=pyRoster();return self.roster end
LP.CountShards=function() return noShards and 0 or 5 end
LP.CountHelpers=function() return noHelpers and 0 or 2 end
LP.IsNearby=function() return false end
LP.KnowsRitual=function() return true end
LP.InstanceBlock=function() return nil end
LP:ScanRoster()
''')
self.clients[name]=L
for target in self.targets:L.globals().LP.AddRequest(L.globals().LP,target,False)
return L
def enable(self,name):
self.clients[name].execute('LP:SetRaidAutoSummon(true)')
def cast(self,name,unit):
L=self.clients[name];target=L.globals().LP.pending.name
if name in self.reject:return False
self.casts.append((round(self.now,2),name,target))
if name not in self.no_start:
L.globals().channel='Ritual of Summoning';L.globals().arg1='Ritual of Summoning'
L.execute('LP:OnEvent("SPELLCAST_START")')
self.later(3,lambda:self.stop(name))
return True
def stop(self,name):
if not self.online.get(name):return
L=self.clients[name];L.globals().channel=None
L.execute('LP:OnEvent("SPELLCAST_CHANNEL_STOP")')
def run(self,seconds,order=None):
end=self.now+seconds
while self.now<end-0.001:
self.now=round(self.now+0.1,4)
for L in self.clients.values():L.globals().now=self.now
while self.events and self.events[0][0]<=self.now+0.00001:
_,_,fn=heapq.heappop(self.events);fn()
for name in order or self.clients:
if self.online.get(name):self.clients[name].execute('LP:OnUpdate(0.1)')
def unique(self,expected=None):
targets=[c[2] for c in self.casts]
assert len(targets)==len(set(targets)),self.casts
if expected is not None:assert len(targets)==expected,self.casts
def suite():
# Demonstrate the actual old bug before applying the new protocol.
w=World(names=('Alpha','Bravo'),targets=1,delay=0.3)
for n in w.names:w.client(n,legacy=True)
for n in w.names:w.enable(n)
w.run(6)
assert len(w.casts)==2 and w.casts[0][2]==w.casts[1][2],w.casts
print('PASS reproduced duplicate cast with two uncoordinated 3.7.8 clients')
for count in (2,3):
for party in (False,True):
targets=min(count,5-count) if party else count
w=World(names=('Alpha','Bravo','Charlie')[:count],targets=targets,party=party)
for n in w.names:w.client(n)
for n in w.names:w.enable(n)
w.run(16);w.unique(targets)
assert len(set(c[1] for c in w.casts))==min(count,targets),w.casts
print('PASS',count,'warlocks split requests in', 'party' if party else 'raid',w.casts)
for seed in range(40):
w=World(targets=5,delay=lambda rng:rng.uniform(0.05,1.4),seed=seed)
for n in w.names:w.client(n)
for n in reversed(w.names):w.enable(n)
w.run(35,order=list(reversed(w.names)));w.unique(5)
print('PASS 40 randomized delayed/reordered three-client runs, five requests each')
w=World(targets=1)
for n in w.names:w.client(n)
for n in w.names:w.enable(n)
w.extra_delay=lambda s,r,p,m:2.6 if p=='LPCoord' and ':A:' in m else 0
w.run(14);w.unique(1)
assert w.casts[0][0]>=104.0,w.casts
print('PASS delayed acknowledgements prevent early cast')
w=World(targets=1)
for n in w.names:w.client(n)
for n in w.names:w.enable(n)
w.drop=lambda s,r,p,m:p=='LPCoord' and ':A:' in m
w.run(15);assert not w.casts,w.casts
w.drop=lambda *a:False;w.run(15);w.unique(1)
print('PASS lost acknowledgements hold casting; recovery after delivery resumes')
for reason in ('moving','noHelpers','noShards'):
w=World(targets=2)
for n in w.names:w.client(n)
w.clients['Alpha'].globals()[reason]=True
for n in w.names:w.enable(n)
w.run(18);w.unique(2);assert all(c[1]!='Alpha' for c in w.casts)
print('PASS unready warlock excluded:',reason)
for action in ('off','disconnect','movement'):
w=World(names=('Alpha','Bravo'),targets=1)
for n in w.names:w.client(n)
for n in w.names:w.enable(n)
w.run(1.2)
if action=='off':w.clients['Alpha'].execute('LP:SetRaidAutoSummon(false)')
elif action=='disconnect':w.online['Alpha']=False
else:w.clients['Alpha'].globals().moving=True
w.run(14);w.unique(1);assert w.casts[0][1]=='Bravo',w.casts
print('PASS reservation handoff after',action)
for kind in ('reject','no-start'):
w=World(names=('Alpha','Bravo'),targets=1)
for n in w.names:w.client(n)
for n in w.names:w.enable(n)
if kind=='reject':w.reject.add('Alpha')
else:w.no_start.add('Alpha')
w.run(15)
if kind=='reject':w.unique(1);assert w.casts[0][1]=='Bravo'
else:
assert len(w.casts)==2 and w.casts[0][1]=='Alpha' and w.casts[1][1]=='Bravo',w.casts
assert w.casts[1][0]-w.casts[0][0]>4,w.casts
print('PASS handoff after native',kind)
w=World(names=('Alpha','Bravo'),targets=1)
w.client('Alpha');w.client('Bravo',legacy=True)
w.enable('Alpha');w.clients['Bravo'].execute('LP:Summon("Target1")')
w.run(10);w.unique(1);assert w.casts[0][1]=='Bravo'
print('PASS old manual summon claim prevents new automatic duplicate')
# Verify real legacy list add/remove without substituting legacy OnEvent.
w=World(names=('Alpha','Bravo'),targets=1)
for n in w.names:w.client(n,legacy=n=='Bravo');w.clients[n].execute('LP.requests={}')
w.clients['Alpha'].execute('LP:AddRequest("Target1",true)');w.run(1)
assert w.clients['Bravo'].eval('table.getn(LP.requests)')==1
w.clients['Bravo'].execute('LP:RemoveRequest("Target1",true)');w.run(1)
assert w.clients['Alpha'].eval('table.getn(LP.requests)')==0
print('PASS bidirectional list synchronization with 3.7.8')
# Old auto peers cannot ACK: explicitly demonstrate that this is NOT guaranteed.
w=World(names=('Alpha','Bravo'),targets=1,delay=0.3)
w.client('Alpha');w.client('Bravo',legacy=True)
for n in w.names:w.enable(n)
w.run(7)
assert len(w.casts)==2,w.casts
print('KNOWN LIMIT mixed new/3.7.8 simultaneous auto can still duplicate:',w.casts)
# A partition during initial discovery is unknowable; do not claim consensus.
w=World(names=('Alpha','Bravo'),targets=1)
for n in w.names:w.client(n)
w.drop=lambda *a:True
for n in w.names:w.enable(n)
w.run(7);assert len(w.casts)==2,w.casts
print('KNOWN LIMIT total message loss before peer discovery can duplicate')
if __name__=='__main__':suite()
def extended_suite():
# ACKs from a previous transaction may never authorize a replacement.
w=World(names=('Alpha','Bravo'),targets=1)
for n in w.names:w.client(n)
for n in w.names:w.enable(n)
w.run(1.2)
L=w.clients['Alpha'];old=L.globals().LP.coordReservation.id
L.execute('LP:CoordRelease();LP:CoordReserve("Target1",now)')
fresh=L.globals().LP.coordReservation.id
assert fresh>old
session=L.globals().LP.coordSession
w.deliver('Alpha','LPCoord',f'1:A:{session}:{old}:Target1:Alpha','RAID','Bravo')
assert not L.eval('LP.coordReservation.acks.Bravo')
print('PASS delayed ACK cannot authorize a newer reservation')
# A release arriving before its reserve leaves a tombstone.
w=World(names=('Alpha','Bravo'),targets=1)
for n in w.names:w.client(n)
w.deliver('Bravo','LPCoord','1:X:100000:5:Target1:','RAID','Alpha')
w.deliver('Bravo','LPCoord','1:R:100000:5:Target1:','RAID','Alpha')
assert not w.clients['Bravo'].eval('LP.coordPeers.Alpha.reservation')
print('PASS out-of-order release prevents a stale reservation from returning')
w=World(names=('Alpha','Bravo'),targets=2)
for n in w.names:w.client(n)
w.deliver('Bravo','LPCoord','1:S:100000:1:Target1:','RAID','Alpha')
w.deliver('Bravo','LPCoord','1:C:100000:2:Target2:','RAID','Alpha')
L=w.clients['Bravo'];assert L.eval('LP:CoordBlocked("Target1",now)')
w.now+=131;L.globals().now=w.now
assert not L.eval('LP:CoordBlocked("Target1",now)')
print('PASS acceptance hold survives owner starting another target and later expires')
w=World(names=('Alpha','Bravo'),targets=1)
for n in w.names:w.client(n)
for n in w.names:w.enable(n)
w.run(1.2);w.client('Alpha');w.enable('Alpha');w.run(14);w.unique(1)
print('PASS reload during reservation re-handshakes without duplicate cast')
w=World(names=('Alpha','Bravo'),targets=1)
for n in w.names:w.client(n)
for n in w.names:w.enable(n)
w.run(1.6)
w.clients['Bravo'].execute('LP:Summon("Target1")')
assert not w.casts
w.run(10);w.unique(1)
print('PASS updated manual click respects another updated warlock reservation')
w=World(names=('Alpha','Bravo'),targets=1)
for n in w.names:w.client(n)
for sender,chan,msg in [('Outsider','RAID','1:R:100000:1:Target1:'),
('HelperOne','RAID','1:R:100000:1:Target1:'),
('Alpha','WHISPER','1:R:100000:1:Target1:'),
('Alpha','RAID','2:R:100000:1:Target1:'),
('Alpha','RAID','broken')]:
w.deliver('Bravo','LPCoord',msg,chan,sender)
assert not w.clients['Bravo'].eval('LP.coordPeers and next(LP.coordPeers)')
print('PASS malformed, non-warlock, outsider and wrong-channel messages ignored')
# A private request initially exists on just one client; real RSAdd shares it.
w=World(targets=1)
for n in w.names:w.client(n);w.clients[n].execute('LP.requests={}')
for n in w.names:w.enable(n)
w.clients['Charlie'].execute('LP:AddRequest("Target1",true)')
w.run(14);w.unique(1)
print('PASS request initially visible to one warlock is shared and cast once')
for seed in range(60):
w=World(targets=6,delay=lambda rng:rng.uniform(0,1.8),seed=seed+100)
for n in w.names:w.client(n)
for n in w.names:w.enable(n)
order=w.names[:];w.rng.shuffle(order)
w.run(55,order=order);w.unique(6)
print('PASS 60 additional randomized three-client runs, six requests each')
if __name__=='__main__':extended_suite()
def original_compatibility():
source=(PACKAGE/'tests/fixtures/LockPort-original.lua').read_text(encoding='utf-8')
w=World(names=('Alpha','Bravo'),targets=1)
w.client('Alpha');w.client('Bravo',source=source)
for L in w.clients.values():L.execute('LP.requests={}')
w.clients['Alpha'].execute('LP:AddRequest("Target1",true)');w.run(1)
assert w.clients['Bravo'].eval('table.getn(LP.requests)')==1
w.clients['Bravo'].execute('LP:RemoveRequest("Target1",true)');w.run(1)
assert w.clients['Alpha'].eval('table.getn(LP.requests)')==0
for L in w.clients.values():L.execute('LP:AddRequest("Target1",false)')
w.enable('Alpha');w.clients['Bravo'].execute('LP:Summon("Target1")')
w.run(10);w.unique(1);assert w.casts[0][1]=='Bravo'
print('PASS original repository addon: list sync both directions and manual cast claim')
if __name__=='__main__':original_compatibility()
def raid_group_suite():
# Different assignments share one synchronized queue but each caster only
# reserves its own raid subgroups. No 123 requests are needed.
w=World(targets=6)
for n in w.names:w.client(n);w.clients[n].execute('LP.requests={}')
assignments={'Alpha':(1,2),'Bravo':(3,4),'Charlie':(5,6)}
for n,groups in assignments.items():
w.clients[n].execute('LP:SetSummonGroups({'+','.join('['+str(g)+']=true' for g in groups)+'})')
w.enable(n)
assert w.clients[n].eval('LP:QueueSelectedGroups()')==2
w.run(30);w.unique(6)
for _,caster,target in w.casts:assert w.subgroups[target] in assignments[caster],w.casts
print('PASS three warlocks queue assigned raid groups without requests and split six targets')
w=World(names=('Alpha','Bravo'),targets=2)
for n in w.names:w.client(n);w.clients[n].execute('LP:SetSummonGroups({[1]=true,[2]=true})');w.enable(n)
w.run(14);w.unique(2)
print('PASS overlapping group assignments still coordinate without duplicate casts')
w=World(names=('Alpha','Bravo'),targets=2)
for n in w.names:w.client(n);w.clients[n].execute('LP:SetSummonGroups({[1]=true})');w.enable(n)
w.run(12);w.unique(1);assert w.casts[0][2]=='Target1'
assert all(w.clients[n].eval('LP:FindRequest("Target2")') for n in w.names)
print('PASS unassigned-group requests stay queued and are not summoned')
w=World(names=('Alpha','Bravo'),targets=2)
for n in w.names:w.client(n);w.enable(n)
w.run(1.6);w.clients['Alpha'].execute('LP:SetSummonGroups({[2]=true})');w.clients['Bravo'].execute('LP:SetSummonGroups({[1]=true})')
w.run(18);w.unique(2)
assert {caster:target for _,caster,target in w.casts}=={'Alpha':'Target2','Bravo':'Target1'},w.casts
print('PASS assignment change during reservation releases and reassigns targets')
w=World(names=('Alpha',),targets=2)
L=w.client('Alpha');L.execute('LP.requests={};LP:SetSummonGroups({[1]=true,[2]=true})')
w.online['Target2']=False
assert L.eval('LP:QueueSelectedGroups()')==1
assert L.eval('LP:QueueSelectedGroups()')==0
w.online['Target2']=True
assert L.eval('LP:QueueSelectedGroups()')==1
L.execute('LP:SetSummonGroups({})');assert L.eval('LP:QueueSelectedGroups()')==0
print('PASS group queue skips offline players, is idempotent, and handles no selection')
w=World(names=('Alpha',),targets=2)
L=w.client('Alpha');L.execute('LP:SetSummonGroups({[2]=true})')
w.subgroups['Target1']=2;w.subgroups['Target2']=1
w.enable('Alpha');w.run(10);w.unique(1);assert w.casts[0][2]=='Target1'
print('PASS current raid subgroup moves are honored before casting')
if __name__=='__main__':raid_group_suite()