From 9ba223a4dae4289c3ba5efa0bca2bacccae93177 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:08:23 -0400 Subject: [PATCH] Shared timers over a hidden channel; trip lengths measured over two long server runs; offsets from the latest run Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WyQKCL92ZLwPi9qmYQtNdc --- CHANGELOG.md | 4 + OctoBus.lua | 180 ++++++++++++++++++++++++++++++++++++++++++--- OctoBusOptions.lua | 17 +++-- README.md | 5 +- 4 files changed, 188 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e3af4..9ef0bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,13 @@ - **Settings window**: `/ob options`, or right-click the minimap icon. Background opacity, window opacity, text size, text opacity, the gap between names and times, place colour vibrance, text outline (thin, thick or shadow), window size, lock, which corner stays put as the list grows or shrinks, auto-show, which routes to show, and bell alerts. Changes apply at once. +- **Shared timers.** Copies with the DLL share the docking times they see over a hidden chat channel; copies without it use them, so one DLL user online keeps everyone exact. A copy that logs in asks for the current times. Can be turned off in the settings. + ### Changed - The "no DLL: estimates only" note is gone from the panel: without the DLL the timers are now accurate. `/ob debug` still shows whether the DLL is loaded. - The footer no longer names the route with the oldest data (the restart correction makes it unnecessary). It only appears after a server restart, while a route in the window has not been seen since. +- New trip lengths, measured against the restart time over two long server runs (15 and 43 hours). The old ones drifted by up to 1.5 seconds an hour (Thunder Bluff), so DLL-free timers were up to a minute off late in a long run. Existing copies are updated once. +- Restart offsets updated to the latest server run. They turned out to shift at each restart, by 3-18 seconds for most routes and by minutes for Grom'gol and Ratchet, so they are now only a first guess until shared or seen. ### Fixed - The 1.3.4 one-time repair of saved trip lengths never actually ran (it ran before the game loaded the saved data), so copies used before 1.3.4 kept wrong trip lengths and drifted by up to a minute, e.g. Grom'gol about 45 seconds slow after 11 hours. diff --git a/OctoBus.lua b/OctoBus.lua index 94bc17e..c3d75a4 100644 --- a/OctoBus.lua +++ b/OctoBus.lua @@ -72,28 +72,30 @@ local OB_OK, OB_ERR = pcall(function () -- ~5 h (origin = first sighting, anchor = latest), stay at the dock, and where measured -- on a ride, the far end (flipOffset = seconds after docking here that it docks there). -- A new copy refines all of this from its own sightings. - -- period = trip length measured over 3 days (400-900 trips). bootPhase = seconds into - -- each trip at which it docks here, counted from the server's last restart (measured - -- 12 Oct 2026): with the server uptime this gives every timer without any sighting. + -- period = trip length, measured against the restart time over two server runs of + -- 15-43 hours (13-14 Oct 2026). bootPhase = seconds into each trip at which it docks + -- here, counted from the server's restart, as measured in the run that began 14 Oct + -- 2026. It shifts by seconds (or, for some routes, minutes) at each restart, so it is + -- only a first guess; shared sightings (below) correct it. { entry = 164871, homeZ = ORG, awayZ = { "Tirisfal Glades", "Undercity" }, name = "Undercity", kind = "zeppelin", x = 1318.1, y = -4658.0, - period = 356.2894, bootPhase = 211.9, dwell = 64.0, origin = 1791471571.34, anchor = 1791489385.64, + period = 356.2806, bootPhase = 220.3, dwell = 64.0, origin = 1791471571.34, anchor = 1791489385.64, flipFrom = "Undercity", flipTo = "Orgrimmar", flipOffset = 191.1, flipDwell = 64.0 }, { entry = 175080, homeZ = ORG, awayZ = { "Stranglethorn Vale" }, name = "Grom'gol", kind = "zeppelin", x = 1360.8, y = -4631.3, - period = 303.4377, bootPhase = 55.0, dwell = 64.1, origin = 1791471600.26, anchor = 1791489504.28, + period = 303.4592, bootPhase = 227.8, dwell = 64.1, origin = 1791471600.26, anchor = 1791489504.28, flipFrom = "Grom'gol", flipTo = "Orgrimmar", flipOffset = 138.2, flipDwell = 64.1 }, { entry = 190552, homeZ = ORG, awayZ = { "Badlands" }, name = "Kargath", kind = "zeppelin", x = 1206.1, y = -4158.7, - period = 373.9861, bootPhase = 227.4, dwell = 63.9, origin = 1791471857.57, anchor = 1791489438.99, + period = 374.0474, bootPhase = 235.3, dwell = 63.9, origin = 1791471857.57, anchor = 1791489438.99, flipFrom = "Kargath", flipTo = "Orgrimmar", flipOffset = 198.0, flipDwell = 64.2 }, { entry = 190549, homeZ = ORG, awayZ = { "Mulgore", "Thunder Bluff" }, name = "Thunder Bluff", kind = "zeppelin", x = 1125.4, y = -4135.0, - period = 566.9955, bootPhase = 334.5, dwell = 64.2, origin = 1791472003.14, anchor = 1791490140.28, + period = 566.7573, bootPhase = 350.5, dwell = 64.2, origin = 1791472003.14, anchor = 1791490140.28, flipFrom = "Thunder Bluff", flipTo = "Orgrimmar", flipOffset = 275.8, flipDwell = 64.0 }, { entry = 190550, homeZ = ORG, awayZ = { "The Hinterlands" }, name = "Sparkwater", kind = "boat", x = 876.1, y = -5204.1, boat = true, - period = 245.0354, bootPhase = 168.6, dwell = 44.1, origin = 1791471573.69, anchor = 1791489460.91, + period = 245.0314, bootPhase = 187.5, dwell = 44.1, origin = 1791471573.69, anchor = 1791489460.91, flipFrom = "Revantusk", flipTo = "Sparkwater", flipOffset = 123.2, flipDwell = 44.0 }, -- Grom'gol <-> Undercity zeppelin, timed at the Grom'gol dock (the only end measured). -- Seen leaving Grom'gol 1791480459.89 and arriving 1791486390.25: 18 cycles of 333.02 s @@ -101,7 +103,7 @@ local OB_OK, OB_ERR = pcall(function () -- is learned on the first ride from Grom'gol. { entry = 176495, homeZ = { "Stranglethorn Vale" }, awayZ = { "Tirisfal Glades", "Undercity" }, name = "Undercity", kind = "zeppelin", x = -12406.9, y = 211.8, - period = 333.0015, bootPhase = 57.2, dwell = 64.0, origin = 1791480395.89, anchor = 1791486390.25, + period = 333.0015, bootPhase = 69.0, dwell = 64.0, origin = 1791480395.89, anchor = 1791486390.25, flipFrom = "Undercity", flipTo = "Grom'gol" }, -- Ratchet -> Booty Bay boat, timed at the Ratchet dock. Never visible from Orgrimmar, so -- the row shows when to fly out ("fly") rather than when it docks. Ratchet arrivals @@ -109,7 +111,7 @@ local OB_OK, OB_ERR = pcall(function () -- two days' rides: docks 178.6 s after Ratchet, stays 64.2 s. { entry = 20808, homeZ = { "Ratchet" }, awayZ = { "Stranglethorn Vale" }, planZ = ORG, name = "Ratchet", kind = "boat", x = -1005.6, y = -3841.6, far = true, - period = 364.0298, bootPhase = 234.0, dwell = 63.7, origin = 1791479926.67, anchor = 1791490117.34, + period = 364.0298, bootPhase = 51.8, dwell = 63.7, origin = 1791479926.67, anchor = 1791490117.34, flipFrom = "Booty Bay", flipTo = "Ratchet", flipOffset = 178.6, flipDwell = 64.2 }, } @@ -162,6 +164,8 @@ local OB_OK, OB_ERR = pcall(function () t.pW0 = t.pW0 or {} t.pP0 = t.pP0 or {} t.pK = t.pK or {} + t.src = t.src or {} + if c.share == nil then c.share = true end if c.ver ~= 2 then -- v2: alerts are opt-in per route (bell) and chat-only by default c.ver = 2 c.chat = true @@ -209,6 +213,25 @@ local OB_OK, OB_ERR = pcall(function () DB.fix134 = true end + -- One-time update (1.3.6): new trip lengths measured against the restart time over two + -- long server runs; offsets learned with the old lengths are dropped. + local function Repair136() + if DB.fix136 then return end + for i = 1, table.getn(ROUTES) do + local r = ROUTES[i] + local e = r.entry + DB.period[e] = r.period + DB.pP0[e] = r.period + DB.pW0[e] = 700 + if DB.origin[e] and DB.anchor[e] then + DB.pK[e] = math.floor((DB.anchor[e] - DB.origin[e]) / r.period + 0.5) + end + DB.bootPhase[e] = nil + DB.bootPhaseBoot[e] = nil + end + DB.fix136 = true + end + local function Say(msg) DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccOctoBus:|r " .. msg) end @@ -572,6 +595,7 @@ local OB_OK, OB_ERR = pcall(function () -- drift can explain (10 s + 0.5 s per hour since it was last seen) means the server -- restarted: every transport's schedule starts over. Routes not seen since are flagged -- until they are seen again. newAnchor = the home arrival time this sighting implies. + local ShareSighting -- set further down (sharing with other OctoBus users) local function NoteSighting(r, newAnchor) local e = r.entry local P = Period(r) @@ -600,6 +624,13 @@ local OB_OK, OB_ERR = pcall(function () DB.seenAt[e] = time() DB.corrected[e] = nil LearnPhase(r, newAnchor) + -- sighting log for measuring drift over long server runs (route, home arrival time + -- this sighting implies, time seen, server start); the last 3000 are kept + if type(DB.slog) ~= "table" then DB.slog = {} end + table.insert(DB.slog, e .. "," .. string.format("%.2f", newAnchor) .. "," .. time() .. "," .. (DB.boot or 0)) + if table.getn(DB.slog) > 3000 then table.remove(DB.slog, 1) end + DB.src[e] = "dll" + if ShareSighting then ShareSighting() end end local function Stale(r) @@ -1816,6 +1847,7 @@ local OB_OK, OB_ERR = pcall(function () f:RegisterEvent("VARIABLES_LOADED") f:SetScript("OnEvent", function() Repair134() + Repair136() panel:SetScale(CFG.scale or 1) ApplyLook() if CFG.locked then grip:Hide() else grip:Show() end @@ -1824,6 +1856,134 @@ local OB_OK, OB_ERR = pcall(function () end) end + ------------------------------------------------------------------------ + -- Shared sightings. Every OctoBus joins a hidden chat channel. A copy with the DLL + -- posts the docking times it has seen in this server run; copies without it take them + -- and are exact too, instead of relying on the restart offsets (which shift by seconds + -- or minutes at each restart). Times are sent as seconds since the server restart, so + -- differences between players' PC clocks do not matter. Nothing shows in chat. + -- Message: "OB1 S : ..." (sightings), "OB1 R" (request). + ------------------------------------------------------------------------ + do + local CHANNEL = "OctoBusSync" + local byEntry = {} + for i = 1, table.getn(ROUTES) do byEntry[ROUTES[i].entry] = ROUTES[i] end + local lastSent, replyAt, joinAt, asked = 0, nil, nil, false + + local function ChannelId() + local id = GetChannelName(CHANNEL) + if id and id > 0 then return id end + return nil + end + local function IsOurs(name) + return name and string.find(string.lower(name), string.lower(CHANNEL), 1, true) ~= nil + end + + -- this run's docking times, as seconds since the restart; own = only DLL sightings + local function Payload(own) + if not DB.boot then return nil end + local parts = {} + for i = 1, table.getn(ROUTES) do + local e = ROUTES[i].entry + local a = DB.anchor[e] + if a and a > DB.boot and (DB.seenAt[e] or 0) > DB.boot and (DB.src[e] == "dll" or (not own and DB.src[e] == "shared")) then + table.insert(parts, e .. ":" .. string.format("%.1f", a - DB.boot)) + end + end + if table.getn(parts) == 0 then return nil end + return "OB1 S " .. table.concat(parts, " ") + end + + local function Send(msg) + local id = ChannelId() + if id and msg and CFG.share then SendChatMessage(msg, "CHANNEL", nil, id) end + end + + -- own sighting: post at most once a minute + ShareSighting = function() + if GetTime() - lastSent < 60 then return end + local msg = Payload(true) + if msg then lastSent = GetTime() Send(msg) end + end + + local function Take(e, rel) + local r = byEntry[e] + if not r or not DB.boot or not CFG.share then return end + if rel < 0 or rel > time() - DB.boot + 600 then return end + -- our own DLL sighting in this run wins + if DB.src[e] == "dll" and (DB.seenAt[e] or 0) > DB.boot then return end + local a = DB.boot + rel + if DB.src[e] == "shared" and DB.anchor[e] and math.abs(DB.anchor[e] - a) < 0.5 then return end + local P = Period(r) + DB.anchor[e] = a + if not DB.origin[e] or DB.origin[e] < DB.boot then + DB.pW0[e] = (DB.pW0[e] or 700) + (DB.pK[e] or 0) + DB.pP0[e] = P + DB.pK[e] = 0 + DB.origin[e] = a + DB.pending[e] = nil + DB.lastObs[e] = nil + end + DB.seenAt[e] = time() + DB.corrected[e] = nil + DB.src[e] = "shared" + end + + local f = CreateFrame("Frame") + f:RegisterEvent("PLAYER_ENTERING_WORLD") + f:RegisterEvent("CHAT_MSG_CHANNEL") + f:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" then + if not joinAt and not ChannelId() then joinAt = GetTime() + 8 end + return + end + if not IsOurs(arg9) and not IsOurs(arg4) then return end + if arg2 == UnitName("player") then return end + local msg = arg1 or "" + if string.sub(msg, 1, 6) == "OB1 S " then + for e, rel in gfind(msg, "(%d+):([%d%.]+)") do Take(tonumber(e), tonumber(rel)) end + replyAt = nil -- someone else answered + elseif msg == "OB1 R" then + if Payload(false) then replyAt = GetTime() + 2 + math.random() * 6 end + end + end) + f:SetScript("OnUpdate", function() + local now = GetTime() + if joinAt and now >= joinAt then + joinAt = nil + if CFG.share then JoinChannelByName(CHANNEL) end + for i = 1, (NUM_CHAT_WINDOWS or 7) do + local cf = getglobal("ChatFrame" .. i) + if cf and ChatFrame_RemoveChannel then pcall(ChatFrame_RemoveChannel, cf, CHANNEL) end + end + end + -- once the restart time is known, ask for this run's sightings + if not asked and DB.boot and ChannelId() then + asked = true + Send("OB1 R") + end + if replyAt and now >= replyAt then + replyAt = nil + Send(Payload(false)) + end + -- copies with the DLL repeat what they have seen every 5 minutes + if dllOk and now - lastSent > 300 then + local msg = Payload(true) + if msg then lastSent = now Send(msg) end + end + end) + + -- keep the channel and its join / leave notices out of every chat window + local prevChat = ChatFrame_OnEvent + ChatFrame_OnEvent = function(ev) + if (ev == "CHAT_MSG_CHANNEL" or ev == "CHAT_MSG_CHANNEL_NOTICE" or ev == "CHAT_MSG_CHANNEL_JOIN" or ev == "CHAT_MSG_CHANNEL_LEAVE") + and (IsOurs(arg9) or IsOurs(arg4)) then + return + end + return prevChat(ev) + end + end + -- for the settings window (OctoBusOptions.lua) OctoBus = { cfg = CFG, diff --git a/OctoBusOptions.lua b/OctoBusOptions.lua index 9fec17f..f8e0359 100644 --- a/OctoBusOptions.lua +++ b/OctoBusOptions.lua @@ -83,7 +83,7 @@ local function Build() local C = OB.cfg frame = CreateFrame("Frame", "OctoBusOptions", UIParent) frame:SetWidth(W) - frame:SetHeight(676) + frame:SetHeight(700) frame:SetPoint("CENTER", UIParent, "CENTER", 0, 60) frame:SetFrameStrata("DIALOG") frame:SetClampedToScreen(true) @@ -159,13 +159,18 @@ local function Build() function(v) C.full = v OB.Refresh() end) Check("Show the Sparkwater Port boat", 14, -506, function() return not C.hideBoat end, function(v) C.hideBoat = not v OB.Refresh() end) + Check("Share timers with other OctoBus users", 14, -530, function() return C.share end, + function(v) + C.share = v + if v then JoinChannelByName("OctoBusSync") else LeaveChannelByName("OctoBusSync") end + end) - Heading("Alerts (click a row to put a bell on it)", -538) - Slider("Warn before arrival", -568, 10, 120, 5, function() return C.alert or 45 end, + Heading("Alerts (click a row to put a bell on it)", -562) + Slider("Warn before arrival", -592, 10, 120, 5, function() return C.alert or 45 end, function(v) C.alert = v end, function(v) return math.floor(v + 0.5) .. " s" end) - Check("Chat message", 14, -590, function() return C.chat end, function(v) C.chat = v end) - Check("Sound", 120, -590, function() return C.sound end, function(v) C.sound = v end) - Check("Flash the minimap icon", 14, -614, function() return C.flash end, function(v) C.flash = v end) + Check("Chat message", 14, -614, function() return C.chat end, function(v) C.chat = v end) + Check("Sound", 120, -614, function() return C.sound end, function(v) C.sound = v end) + Check("Flash the minimap icon", 14, -638, function() return C.flash end, function(v) C.flash = v end) local reset = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") reset:SetWidth(150) diff --git a/README.md b/README.md index 5ac9900..8f27c9c 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Switch with a click on the window title, or `/ob all`. ## Requirements -1. **`ZepSense.dll`**, a small client DLL that exposes one Lua function, `ZepTransports()`, which returns the position of every zeppelin/boat the client currently has loaded. The addon cannot see transports without it. Without the DLL, every timer is still set from the server's restart time and the built-in offsets, which is accurate, but OctoBus cannot re-sync or re-learn from sightings, so times stay marked `~`. +1. **`ZepSense.dll`**, a small client DLL that exposes one Lua function, `ZepTransports()`, which returns the position of every zeppelin/boat the client currently has loaded. The addon cannot see transports without it. Without the DLL, every timer is set from the server's restart time and the built-in offsets, which is close (usually within 20 seconds, marked `~`), and becomes exact as soon as a DLL user shares the current times (see Shared timers below). 2. A client setup that loads DLLs listed in `dlls.txt`. ## Install @@ -92,7 +92,8 @@ Each route runs on a fixed cycle. Once the addon sees one transport arrive, it c - **`/ob calibrate`** (optional): watches every transport on purpose. Stand between the two towers where you can see the docks, do not `/reload`, and wait; rows are marked complete as each route is confirmed. A transport being out of view for a while is normal. If nothing at all has been seen after about 10 minutes, move closer to the towers. `/ob calibrate stop` cancels. - Transports are not reported from deep inside the city (bank / auction house area), so calibrate near the towers. - **Far ends are learned by riding.** When a transport stands still for 20 s away from its home dock, it is docked at the far end: the addon stores when it docked there (relative to its schedule) and how long it stayed. This needs the home end to have been seen earlier in the same session (for example, it leaving Orgrimmar with you on board), and the transport to be seen arriving. No recording is needed. -- **Server restarts.** A restart starts every transport's schedule over. When you log in, OctoBus quietly asks the server how long it has been up (`.server info`) and so knows exactly when it restarted. Each route docks a fixed number of seconds into its trip after a restart. These offsets are built in, so even without the DLL every route is set from the restart time at each login; with the DLL, OctoBus also re-learns them from its own sightings. Until that has been seen to hold across one restart, corrected routes keep the `?` mark. A sighting that jumps by more than drift can explain also counts as a restart. +- **Server restarts.** A restart starts every transport's schedule over. When you log in, OctoBus quietly asks the server how long it has been up (`.server info`) and so knows exactly when it restarted. Each route docks a set number of seconds into its trip after a restart. These offsets are built in, so even without the DLL every route is set from the restart time at each login. They shift a little at each restart (seconds for most routes, minutes for some), so on their own they are a first guess, shown with `~`. +- **Shared timers.** Every copy of OctoBus joins a hidden chat channel (`OctoBusSync`; nothing from it shows in your chat). A copy with the DLL posts the docking times it has seen since the last restart, and every copy without the DLL takes them, so one DLL user online makes everyone's timers exact for the rest of that server run. Times are sent as seconds since the restart, so differences between players' PC clocks do not matter. Turn it off in the settings window ("Share timers with other OctoBus users"). Until that has been seen to hold across one restart, corrected routes keep the `?` mark. A sighting that jumps by more than drift can explain also counts as a restart. - **Both ends keep the timers in sync.** Seeing a transport arrive at, or leave, either end of its route re-syncs that route. At the far end this uses the measured far-end timing; if the home end was already seen in the same session, a far-end arrival sharpens the far-end timing instead. - **One odd sighting does not wipe a route's history.** A sighting that does not fit the schedule is set aside; the schedule only starts over (for example after a server restart) when a second sighting agrees with it.