diff --git a/README.md b/README.md
index 8cc92a2..014b367 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@
· Explore Loremaster
· Choose a layout
· 4K scaling
+ · Texture upgrades
@@ -237,6 +238,12 @@ Choose the exact profile for your display when possible, or the nearest validate
Download SpinFOURKAYYY separately from its [latest GitHub release](https://github.com/itsspin/SPINFOURKAYYY/releases/latest), extract the complete ZIP into a normal user-writable folder, choose the percentage **before** launching, click **Start EverQuest for me**, and keep the scaler open until the game exits. EverQuest, SpinFOURKAYYY, and companion overlays should run at the same Windows privilege level.
+## Sharper world textures with SpinTexture
+
+[**SpinTexture**](https://itsspin.github.io/spintexture/) is a separate companion project for improving EverQuest Legends textures. SpinUI modernizes the native interface while SpinTexture enhances the world around it, so players can use either project independently or combine them for a more complete visual upgrade.
+
+Visit the [SpinTexture project site](https://itsspin.github.io/spintexture/) for previews, downloads, and installation guidance.
+
## A compact tour of the rest
| Feature | Distinguishing behavior |
diff --git a/loremaster-desktop/README.md b/loremaster-desktop/README.md
index 81e76cc..2612057 100644
--- a/loremaster-desktop/README.md
+++ b/loremaster-desktop/README.md
@@ -24,6 +24,34 @@ health. The parser engine remains authoritative for combat attribution,
charmed pets, mez/lull evidence and D0–D4 weekly raid events. The narrow preload
exposes only versioned state, folder selection, reset, and window commands.
+## Native Loremaster themes
+
+Loremaster includes two complete presentation systems using the same parser,
+layout and accessibility behavior:
+
+- **Vellum & Ember** is the default and matches `spinui_reloaded` with oiled
+ leather surfaces, parchment text, brass edges and restrained spirit-blue
+ selections.
+- **Midnight Frost Glass** matches `spinui_glass` with deep translucent panes,
+ ice-blue edges, mint actions and violet selections.
+
+The theme picker in Settings applies immediately to the Seed, expanded HUD,
+Settings, alerts, crowd-control timers and Combat Archive. The selection is
+stored with the other desktop settings and restored before windows are shown,
+so changing themes never restarts the parser or moves an overlay.
+
+Alert Sound Studio includes four locally generated cues plus Silent, with a
+separate selection for charm breaks, tells, summons, deaths, big hits, name
+calls, mez warnings and lull warnings. Each event may instead use a local WAV,
+MP3, OGG or M4A file selected through the native file picker. Custom audio is
+validated and size-limited by Electron's main process; the sandboxed renderer
+never receives general filesystem access.
+
+Weekly D0–D4 progress comes from explicit raid difficulty plus combat-log boss
+evidence, with a confirmation prompt when the difficulty is not known and a
+manual correction grid. Loremaster deliberately does not scrape EverQuest's
+Instance Information window or reserve a global lockout-screen hotkey.
+
The Gear Path surface imports EQ Legends Tools' version-1 character-sheet JSON
and EverQuest's `/outputfile inventory` TXT locally. It identifies goal items
already equipped or held in bags/bank and groups missing pieces by source zone.
diff --git a/loremaster-desktop/electron/main.ts b/loremaster-desktop/electron/main.ts
index d03abb3..df0b177 100644
--- a/loremaster-desktop/electron/main.ts
+++ b/loremaster-desktop/electron/main.ts
@@ -1,4 +1,4 @@
-import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeImage, screen, shell, Tray } from "electron";
+import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, screen, shell, Tray } from "electron";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
@@ -62,6 +62,10 @@ const screenshotControls = [
},
] as const;
type AlertAnchor = "auto" | "above" | "below" | "left" | "right";
+type AlertSoundKind = "default" | "charmBreak" | "tell" | "summon" | "death" | "bigHit" | "nameCalled" | "mez" | "lull";
+type AlertSoundPreset = "rune" | "crystal" | "ember" | "bell" | "custom" | "silent";
+interface AlertSoundProfile { preset: AlertSoundPreset; customPath: string }
+type AlertSoundProfiles = Record;
export interface AlertSettings {
alertsEnabled: boolean;
@@ -81,6 +85,7 @@ export interface AlertSettings {
lullTimersEnabled: boolean;
lullTimerSound: boolean;
lullWarningSeconds: number;
+ soundProfiles: AlertSoundProfiles;
}
interface DesktopSettings {
@@ -88,6 +93,7 @@ interface DesktopSettings {
raidDifficulty: number | null;
bisBuildPath: string;
inventoryPath: string;
+ uiTheme: "vellum" | "glass";
alwaysOnTop: boolean;
fontScale: number;
composition: string;
@@ -115,6 +121,35 @@ const defaultHealth: EngineHealth = {
server: "?",
};
+const ALERT_SOUND_KINDS: readonly AlertSoundKind[] = [
+ "default", "charmBreak", "tell", "summon", "death", "bigHit", "nameCalled", "mez", "lull",
+];
+const ALERT_SOUND_PRESETS: readonly AlertSoundPreset[] = ["rune", "crystal", "ember", "bell", "custom", "silent"];
+const defaultSoundProfiles: AlertSoundProfiles = {
+ default: { preset: "rune", customPath: "" },
+ charmBreak: { preset: "ember", customPath: "" },
+ tell: { preset: "crystal", customPath: "" },
+ summon: { preset: "ember", customPath: "" },
+ death: { preset: "ember", customPath: "" },
+ bigHit: { preset: "rune", customPath: "" },
+ nameCalled: { preset: "crystal", customPath: "" },
+ mez: { preset: "rune", customPath: "" },
+ lull: { preset: "bell", customPath: "" },
+};
+
+function normalizeSoundProfiles(value: unknown): AlertSoundProfiles {
+ const source = value && typeof value === "object" ? value as Record : {};
+ return Object.fromEntries(ALERT_SOUND_KINDS.map((kind) => {
+ const candidate = source[kind] && typeof source[kind] === "object"
+ ? source[kind] as Partial : {};
+ const preset = ALERT_SOUND_PRESETS.includes(candidate.preset as AlertSoundPreset)
+ ? candidate.preset as AlertSoundPreset : defaultSoundProfiles[kind].preset;
+ const customPath = typeof candidate.customPath === "string" && candidate.customPath.length <= 4096
+ ? candidate.customPath : "";
+ return [kind, { preset, customPath }];
+ })) as AlertSoundProfiles;
+}
+
const defaultAlertSettings: AlertSettings = {
alertsEnabled: true,
alertSound: true,
@@ -133,6 +168,7 @@ const defaultAlertSettings: AlertSettings = {
lullTimersEnabled: true,
lullTimerSound: false,
lullWarningSeconds: 12,
+ soundProfiles: defaultSoundProfiles,
};
const defaultSettings: DesktopSettings = {
@@ -140,6 +176,7 @@ const defaultSettings: DesktopSettings = {
raidDifficulty: null,
bisBuildPath: "",
inventoryPath: "",
+ uiTheme: "vellum",
alwaysOnTop: true,
fontScale: 1.15,
composition: "",
@@ -178,6 +215,7 @@ function readSettings(): DesktopSettings {
raidDifficulty,
bisBuildPath: typeof value.bisBuildPath === "string" ? value.bisBuildPath : "",
inventoryPath: typeof value.inventoryPath === "string" ? value.inventoryPath : "",
+ uiTheme: value.uiTheme === "glass" ? "glass" : "vellum",
alwaysOnTop: boolean(value.alwaysOnTop, true),
fontScale: clampInteger(value.fontScale === undefined ? 115 : Number(value.fontScale) * 100, 115, 90, 160) / 100,
composition: typeof value.composition === "string" ? value.composition.slice(0, 48) : "",
@@ -202,10 +240,11 @@ function readSettings(): DesktopSettings {
lullTimersEnabled: boolean(alertValue.lullTimersEnabled, true),
lullTimerSound: boolean(alertValue.lullTimerSound, false),
lullWarningSeconds: clampInteger(alertValue.lullWarningSeconds, 12, 3, 30),
+ soundProfiles: normalizeSoundProfiles(alertValue.soundProfiles),
},
};
} catch {
- return { ...defaultSettings, alerts: { ...defaultAlertSettings } };
+ return { ...defaultSettings, alerts: { ...defaultAlertSettings, soundProfiles: normalizeSoundProfiles(null) } };
}
}
@@ -445,10 +484,16 @@ class EngineSupervisor {
this.send({ type: "engine.set-raid-difficulty", raidDifficulty });
}
- updateDesktopSettings(patch: Partial> & {
+ updateDesktopSettings(patch: Partial> & {
alerts?: Partial;
}): DesktopSettings {
- const nextAlerts = patch.alerts ? { ...this.settings.alerts, ...patch.alerts } : this.settings.alerts;
+ const nextAlerts = patch.alerts ? {
+ ...this.settings.alerts,
+ ...patch.alerts,
+ soundProfiles: patch.alerts.soundProfiles
+ ? normalizeSoundProfiles(patch.alerts.soundProfiles)
+ : this.settings.alerts.soundProfiles,
+ } : this.settings.alerts;
const previousScale = this.settings.fontScale;
const nextScale = typeof patch.fontScale === "number" ? clamp(patch.fontScale, 0.9, 1.6) : previousScale;
const nextSeedPosition = nextScale !== previousScale
@@ -456,6 +501,7 @@ class EngineSupervisor {
: this.settings.seedPosition;
this.settings = {
...this.settings,
+ ...(patch.uiTheme === "vellum" || patch.uiTheme === "glass" ? { uiTheme: patch.uiTheme } : {}),
...(typeof patch.alwaysOnTop === "boolean" ? { alwaysOnTop: patch.alwaysOnTop } : {}),
fontScale: nextScale,
seedPosition: nextSeedPosition,
@@ -488,10 +534,6 @@ class EngineSupervisor {
this.send({ type: "engine.set-raid-completion", target, difficulty, completed });
}
- scanAltZLockouts(): void {
- this.send({ type: "engine.scan-alt-z-lockouts" });
- }
-
private catalogCachePath(): string {
return path.join(app.getPath("userData"), "eq-legends-tools-gear-cache.json");
}
@@ -1041,6 +1083,12 @@ function setAnalysisMode(active: boolean, preserveAnchor = false): void {
setImmediate(() => { movingWindowProgrammatically = false; });
}
+function rendererUrl(base: string, query: Record): string {
+ const url = new URL(base);
+ for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value);
+ return url.toString();
+}
+
function createAlertWindow(): void {
const settings = engine?.getState().settings ?? defaultSettings;
const alertSize = scaledSize(ALERT_SIZE, settings.fontScale);
@@ -1066,9 +1114,10 @@ function createAlertWindow(): void {
alertWindow.setIgnoreMouseEvents(true);
applyAlwaysOnTop(settings.alwaysOnTop);
const developmentUrl = process.env.VITE_DEV_SERVER_URL;
+ const rendererQuery = { alert: "1", theme: settings.uiTheme };
const rendererReady = developmentUrl
- ? alertWindow.loadURL(`${developmentUrl}?alert=1`)
- : alertWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: { alert: "1" } });
+ ? alertWindow.loadURL(rendererUrl(developmentUrl, rendererQuery))
+ : alertWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: rendererQuery });
void rendererReady.then(() => {
alertWindow?.webContents.setZoomFactor(settings.fontScale);
positionAlertWindow();
@@ -1117,9 +1166,10 @@ function createControlWindow(): void {
controlWindow.setIgnoreMouseEvents(true);
applyAlwaysOnTop(settings.alwaysOnTop);
const developmentUrl = process.env.VITE_DEV_SERVER_URL;
+ const rendererQuery = { controls: "1", theme: settings.uiTheme };
const rendererReady = developmentUrl
- ? controlWindow.loadURL(`${developmentUrl}?controls=1`)
- : controlWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: { controls: "1" } });
+ ? controlWindow.loadURL(rendererUrl(developmentUrl, rendererQuery))
+ : controlWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: rendererQuery });
void rendererReady.then(() => {
controlWindow?.webContents.setZoomFactor(settings.fontScale);
syncControlWindow();
@@ -1202,9 +1252,10 @@ function createWindow(): void {
});
const developmentUrl = process.env.VITE_DEV_SERVER_URL;
+ const rendererQuery = { theme: settings.uiTheme };
const rendererReady = developmentUrl
- ? mainWindow.loadURL(developmentUrl)
- : mainWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"));
+ ? mainWindow.loadURL(rendererUrl(developmentUrl, rendererQuery))
+ : mainWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: rendererQuery });
void rendererReady.then(() => {
mainWindow?.webContents.on("will-navigate", (event) => event.preventDefault());
mainWindow?.webContents.setZoomFactor(settings.fontScale);
@@ -1289,9 +1340,9 @@ function createWindow(): void {
? mainWindow?.webContents.executeJavaScript(
"document.querySelector('.rune-seed')?.classList.add('attacking')")
: undefined)
- .then(() => screenshotView === "settings"
+ .then(() => screenshotView === "settings" || screenshotView?.startsWith("sounds")
? mainWindow?.webContents.executeJavaScript(
- "document.querySelector('.masthead-actions button')?.click()")
+ "document.querySelector('button[aria-label=\"Open settings\"]')?.click()")
: screenshotView === "analysis"
? mainWindow?.webContents.executeJavaScript(
"document.querySelector('button[aria-label=\"Open full combat breakdown\"]')?.click()")
@@ -1303,6 +1354,15 @@ function createWindow(): void {
`)
: undefined)
.then(() => new Promise((resolve) => setTimeout(resolve, 250)))
+ .then(() => screenshotView?.startsWith("sounds")
+ ? mainWindow?.webContents.executeJavaScript(
+ "document.querySelector('.sound-studio')?.scrollIntoView({ block: 'start' })")
+ : undefined)
+ .then(() => screenshotView === "sounds-menu"
+ ? mainWindow?.webContents.executeJavaScript(
+ "document.querySelector('.sound-preset-trigger')?.click()")
+ : undefined)
+ .then(() => new Promise((resolve) => setTimeout(resolve, screenshotView?.startsWith("sounds") ? 180 : 0)))
.then(() => mainWindow?.webContents.capturePage())
.then((image) => {
if (image) writeFileSync(screenshotPath, image.toPNG());
@@ -1386,10 +1446,67 @@ ipcMain.on("alerts:test", () => {
});
});
+const CUSTOM_SOUND_EXTENSIONS = new Set([".wav", ".mp3", ".ogg", ".m4a"]);
+const CUSTOM_SOUND_MAX_BYTES = 8 * 1024 * 1024;
+
+function validatedCustomSoundPath(kind: AlertSoundKind): string | null {
+ const profile = engine?.getState().settings.alerts.soundProfiles[kind];
+ if (!profile?.customPath) return null;
+ const candidate = path.resolve(profile.customPath);
+ try {
+ const stats = statSync(candidate);
+ return stats.isFile()
+ && stats.size > 0
+ && stats.size <= CUSTOM_SOUND_MAX_BYTES
+ && CUSTOM_SOUND_EXTENSIONS.has(path.extname(candidate).toLowerCase())
+ ? candidate : null;
+ } catch {
+ return null;
+ }
+}
+
+ipcMain.handle("alerts:choose-sound", async (_event, value: unknown) => {
+ if (!mainWindow || !engine || !ALERT_SOUND_KINDS.includes(value as AlertSoundKind)) return null;
+ const kind = value as AlertSoundKind;
+ const result = await dialog.showOpenDialog(mainWindow, {
+ title: "Choose a Loremaster alert sound",
+ properties: ["openFile"],
+ filters: [{ name: "Audio", extensions: ["wav", "mp3", "ogg", "m4a"] }],
+ });
+ if (result.canceled || result.filePaths.length !== 1) return null;
+ const candidate = path.resolve(result.filePaths[0]);
+ try {
+ const stats = statSync(candidate);
+ if (!stats.isFile() || stats.size <= 0 || stats.size > CUSTOM_SOUND_MAX_BYTES
+ || !CUSTOM_SOUND_EXTENSIONS.has(path.extname(candidate).toLowerCase())) return null;
+ } catch {
+ return null;
+ }
+ const profiles = normalizeSoundProfiles(engine.getState().settings.alerts.soundProfiles);
+ profiles[kind] = { preset: "custom", customPath: candidate };
+ return engine.updateDesktopSettings({ alerts: { soundProfiles: profiles } });
+});
+
+ipcMain.handle("alerts:read-sound", (_event, value: unknown) => {
+ if (!ALERT_SOUND_KINDS.includes(value as AlertSoundKind)) return null;
+ const candidate = validatedCustomSoundPath(value as AlertSoundKind);
+ if (!candidate) return null;
+ try {
+ return {
+ bytes: readFileSync(candidate),
+ extension: path.extname(candidate).toLowerCase(),
+ name: path.basename(candidate),
+ };
+ } catch {
+ return null;
+ }
+});
+
ipcMain.handle("settings:update", (_event, value: unknown) => {
if (!value || typeof value !== "object" || !engine) return null;
const raw = value as Record;
const patch: Parameters[0] = {};
+ if (raw.uiTheme === "vellum" || raw.uiTheme === "glass") patch.uiTheme = raw.uiTheme;
if (typeof raw.alwaysOnTop === "boolean") patch.alwaysOnTop = raw.alwaysOnTop;
if (Number.isFinite(Number(raw.fontScale))) patch.fontScale = clamp(Number(raw.fontScale), 0.9, 1.6);
if (typeof raw.composition === "string") patch.composition = raw.composition.slice(0, 48);
@@ -1404,6 +1521,9 @@ ipcMain.handle("settings:update", (_event, value: unknown) => {
"lullTimersEnabled", "lullTimerSound",
];
for (const key of booleanKeys) if (typeof candidate[key] === "boolean") Object.assign(alerts, { [key]: candidate[key] });
+ if (candidate.soundProfiles && typeof candidate.soundProfiles === "object") {
+ alerts.soundProfiles = normalizeSoundProfiles(candidate.soundProfiles);
+ }
if (["auto", "above", "below", "left", "right"].includes(String(candidate.alertAnchor))) {
alerts.alertAnchor = candidate.alertAnchor as AlertAnchor;
}
@@ -1506,9 +1626,6 @@ app.whenReady().then(() => {
engine.start();
createWindow();
ensureTray();
- if (!globalShortcut.register("CommandOrControl+Shift+Z", () => engine?.scanAltZLockouts())) {
- console.error("Could not register the Ctrl+Shift+Z Alt+Z lockout scan hotkey");
- }
startTopmostHeartbeat();
screen.on("display-metrics-changed", scheduleTopmostReassertion);
const smokeExitMs = Number(process.env.LOREMASTER_SMOKE_EXIT_MS || 0);
@@ -1517,7 +1634,6 @@ app.whenReady().then(() => {
}
});
app.on("before-quit", () => {
- globalShortcut.unregisterAll();
clearTopmostReassertions();
if (topmostHeartbeatTimer) clearInterval(topmostHeartbeatTimer);
topmostHeartbeatTimer = null;
diff --git a/loremaster-desktop/electron/preload.ts b/loremaster-desktop/electron/preload.ts
index 8764922..c9cc4b1 100644
--- a/loremaster-desktop/electron/preload.ts
+++ b/loremaster-desktop/electron/preload.ts
@@ -16,6 +16,8 @@ contextBridge.exposeInMainWorld("loremasterDesktop", {
checkForUpdates: () => ipcRenderer.invoke("updates:check"),
resetEngine: () => ipcRenderer.send("engine:reset"),
testAlert: () => ipcRenderer.send("alerts:test"),
+ chooseAlertSound: (kind: string) => ipcRenderer.invoke("alerts:choose-sound", kind),
+ readAlertSound: (kind: string) => ipcRenderer.invoke("alerts:read-sound", kind),
onSnapshot: (callback: (event: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, value: unknown) => callback(value);
ipcRenderer.on("engine:snapshot", listener);
diff --git a/loremaster-desktop/index.html b/loremaster-desktop/index.html
index 9e8a0f5..61d43d4 100644
--- a/loremaster-desktop/index.html
+++ b/loremaster-desktop/index.html
@@ -1,10 +1,10 @@
-
+
diff --git a/loremaster-desktop/src/App.tsx b/loremaster-desktop/src/App.tsx
index bde456b..6131512 100644
--- a/loremaster-desktop/src/App.tsx
+++ b/loremaster-desktop/src/App.tsx
@@ -12,6 +12,9 @@ import {
type EngineHealth,
type EngineSnapshotEvent,
type GearPlanView,
+ type LoremasterTheme,
+ type AlertSoundKind,
+ type AlertSoundPreset,
} from "./protocol";
import { CombatArchive } from "./CombatArchive";
@@ -20,9 +23,46 @@ const raidDifficulties = [0, 1, 2, 3, 4] as const;
const cogSource = "./loremaster-cog.png";
const eqToolsUrl = "https://eqlegendstools.com/";
const eqToolsCharSheetUrl = "https://eqlegendstools.com/char-sheet/";
+const soundKinds: readonly { id: AlertSoundKind; label: string; detail: string }[] = [
+ { id: "default", label: "General alerts", detail: "Raid prompts and alert previews" },
+ { id: "charmBreak", label: "Charm breaks", detail: "Urgent recharm warning" },
+ { id: "tell", label: "Incoming tells", detail: "Direct player messages" },
+ { id: "summon", label: "Summoned", detail: "Boss summon warning" },
+ { id: "death", label: "Death", detail: "Character death" },
+ { id: "bigHit", label: "Big hits", detail: "Damage threshold warning" },
+ { id: "nameCalled", label: "Name called", detail: "Group, raid, or guild mention" },
+ { id: "mez", label: "Mez urgent", detail: "Safe window closing" },
+ { id: "lull", label: "Lull urgent", detail: "Safe window closing" },
+];
+const soundPresets: readonly { id: AlertSoundPreset; label: string }[] = [
+ { id: "rune", label: "Rune Pulse" },
+ { id: "crystal", label: "Crystal Chime" },
+ { id: "ember", label: "Ember Alarm" },
+ { id: "bell", label: "Temple Bell" },
+ { id: "custom", label: "Custom File" },
+ { id: "silent", label: "Silent" },
+];
+const defaultSoundProfiles: DesktopSettings["alerts"]["soundProfiles"] = {
+ default: { preset: "rune", customPath: "" }, charmBreak: { preset: "ember", customPath: "" },
+ tell: { preset: "crystal", customPath: "" }, summon: { preset: "ember", customPath: "" },
+ death: { preset: "ember", customPath: "" }, bigHit: { preset: "rune", customPath: "" },
+ nameCalled: { preset: "crystal", customPath: "" }, mez: { preset: "rune", customPath: "" },
+ lull: { preset: "bell", customPath: "" },
+};
+
+function normalizeTheme(value: unknown): LoremasterTheme {
+ return value === "glass" ? "glass" : "vellum";
+}
+
+function applyTheme(value: unknown): LoremasterTheme {
+ const theme = normalizeTheme(value);
+ document.documentElement.dataset.theme = theme;
+ return theme;
+}
const defaultDesktopSettings: DesktopSettings = {
logPath: "", raidDifficulty: null, bisBuildPath: "", inventoryPath: "",
+ uiTheme: "vellum",
alwaysOnTop: true, fontScale: 1.15, composition: "", splitCharmedPetDps: false,
stanceAdvisorEnabled: false, seedPosition: null,
alerts: {
@@ -31,6 +71,7 @@ const defaultDesktopSettings: DesktopSettings = {
alertBigHit: true, alertNameCalled: true, bigHitThreshold: 800,
mezTimersEnabled: true, mezTimerSound: false, mezWarningSeconds: 10,
lullTimersEnabled: true, lullTimerSound: false, lullWarningSeconds: 12,
+ soundProfiles: defaultSoundProfiles,
},
};
@@ -86,16 +127,6 @@ function formatDuration(value: number): string {
return minutes > 0 ? `${minutes}:${remainder}` : `${seconds}s`;
}
-function formatLockout(value: number): string {
- const seconds = Math.max(0, Math.floor(value));
- const days = Math.floor(seconds / 86400);
- const hours = Math.floor((seconds % 86400) / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- if (days > 0) return `${days}d ${hours}h ${minutes}m`;
- if (hours > 0) return `${hours}h ${minutes}m`;
- return `${minutes}m ${seconds % 60}s`;
-}
-
function spellLabel(control: ControlTimerView): string {
const rank = control.rank > 0 ? roman[control.rank] ?? String(control.rank) : "";
return `${control.spell}${rank ? ` ${rank}` : ""}`;
@@ -197,13 +228,18 @@ function SeedControlSurface() {
if (!desktop) return () => document.body.classList.remove("control-window");
void desktop.getEngineState().then((state) => {
if (isEngineSnapshotEvent(state.snapshot)) setEvent(state.snapshot);
+ applyTheme(state.settings.uiTheme);
setSettings(state.settings);
});
const removeSnapshot = desktop.onSnapshot((value) => {
if (isEngineSnapshotEvent(value)) setEvent(value);
});
const removeSettings = desktop.onSettings((value) => {
- if (value && typeof value === "object") setSettings(value as DesktopSettings);
+ if (value && typeof value === "object") {
+ const next = value as DesktopSettings;
+ applyTheme(next.uiTheme);
+ setSettings(next);
+ }
});
return () => {
removeSnapshot();
@@ -263,8 +299,17 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif
}) {
const [manualPath, setManualPath] = useState(health.configuredPath);
const [draft, setDraft] = useState(settings);
+ const [activeSoundMenu, setActiveSoundMenu] = useState(null);
const [updateInfo, setUpdateInfo] = useState["checkForUpdates"]>> | null>(null);
const [checkingUpdate, setCheckingUpdate] = useState(false);
+ useEffect(() => {
+ if (!activeSoundMenu) return;
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setActiveSoundMenu(null);
+ };
+ window.addEventListener("keydown", closeOnEscape);
+ return () => window.removeEventListener("keydown", closeOnEscape);
+ }, [activeSoundMenu]);
const chooseFolder = async () => {
const selected = await window.loremasterDesktop?.chooseLogFolder();
if (selected) setManualPath(selected);
@@ -286,8 +331,37 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif
const patchAlerts = (value: Partial) => setDraft((current) => ({
...current, alerts: { ...current.alerts, ...value },
}));
+ const patchSoundProfile = (kind: AlertSoundKind, preset: AlertSoundPreset) => {
+ setDraft((current) => ({
+ ...current,
+ alerts: {
+ ...current.alerts,
+ soundProfiles: {
+ ...current.alerts.soundProfiles,
+ [kind]: { ...current.alerts.soundProfiles[kind], preset },
+ },
+ },
+ }));
+ };
+ const chooseCustomSound = async (kind: AlertSoundKind) => {
+ const synchronized = await window.loremasterDesktop?.updateSettings({ alerts: draft.alerts });
+ if (synchronized) setDraft(synchronized);
+ const saved = await window.loremasterDesktop?.chooseAlertSound(kind);
+ if (!saved) return;
+ setDraft(saved);
+ onSettings(saved);
+ };
+ const selectSoundPreset = async (kind: AlertSoundKind, preset: AlertSoundPreset) => {
+ setActiveSoundMenu(null);
+ if (preset === "custom" && !draft.alerts.soundProfiles[kind].customPath) {
+ await chooseCustomSound(kind);
+ return;
+ }
+ patchSoundProfile(kind, preset);
+ };
const savePreferences = async () => {
const saved = await window.loremasterDesktop?.updateSettings({
+ uiTheme: draft.uiTheme,
alwaysOnTop: draft.alwaysOnTop,
fontScale: draft.fontScale,
composition: draft.composition,
@@ -297,6 +371,16 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif
});
if (saved) onSettings(saved);
};
+ const selectTheme = async (uiTheme: LoremasterTheme) => {
+ applyTheme(uiTheme);
+ patchDraft({ uiTheme });
+ const saved = await window.loremasterDesktop?.updateSettings({ uiTheme });
+ if (saved) {
+ setDraft(saved);
+ applyTheme(saved.uiTheme);
+ onSettings(saved);
+ }
+ };
const changeFontScale = async (delta: number) => {
const fontScale = Math.max(0.9, Math.min(1.6, Math.round((draft.fontScale + delta) * 20) / 20));
setDraft((current) => ({ ...current, fontScale }));
@@ -306,6 +390,27 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif
return (
+
+ APPEARANCE
+ Match Loremaster to your active SpinUI skin. The choice applies immediately to the HUD, Rune Seed, timers, and alerts.
+
+ {([
+ { id: "vellum", name: "VELLUM & EMBER", detail: "Matches SpinUI Reloaded" },
+ { id: "glass", name: "MIDNIGHT FROST GLASS", detail: "Matches SpinUI Glass" },
+ ] as const).map((option) => void selectTheme(option.id)}
+ >
+
+ {option.name} {option.detail}
+ {draft.uiTheme === option.id ? "ACTIVE" : "SELECT"}
+ )}
+
+
EVERQUEST DIRECTORY
Choose the game folder or its Logs folder. Loremaster automatically follows the newest character log.
@@ -387,6 +492,41 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif
window.loremasterDesktop?.testAlert()}>TEST ALERT
void savePreferences()}>SAVE HUD + ALERT SETTINGS
+
+ ALERT SOUND STUDIO
+ Give each alert a distinct cue. Presets are generated locally; custom WAV, MP3, OGG, or M4A files stay on this computer.
+
+ {soundKinds.map((kind) => {
+ const profile = draft.alerts.soundProfiles[kind.id];
+ const customName = profile.customPath.split(/[\\/]/).pop() || "Choose an audio file";
+ const presetLabel = soundPresets.find((preset) => preset.id === profile.preset)?.label ?? "Rune Pulse";
+ const menuOpen = activeSoundMenu === kind.id;
+ return
+ {kind.label} {kind.detail}
+ setActiveSoundMenu((current) => current === kind.id ? null : kind.id)}>
+ {presetLabel}
+
+ {profile.preset === "custom" && void chooseCustomSound(kind.id)}>{customName} }
+ void previewConfiguredSound(kind.id, profile, kind.id === "tell" || kind.id === "nameCalled" ? "info" : kind.id === "bigHit" || kind.id === "mez" || kind.id === "lull" ? "warn" : "danger")}>▶
+ {menuOpen &&
+ {soundPresets.map((preset) => void selectSoundPreset(kind.id, preset.id)}>
+ {preset.label}
+ )}
+
}
+ ;
+ })}
+
+ Custom files are validated at playback and limited to 8 MB. If a file moves or cannot be decoded, Loremaster falls back to the matching preset cue.
+
ACTIVE RAID DIFFICULTY
The EQ text log names the defeated boss but not its D0–D4 tier. Select the tier before a raid so automatic lockouts stay accurate.
@@ -429,25 +569,84 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif
);
}
-function playSignal(severity: "danger" | "warn" | "info") {
+function presetFallback(severity: "danger" | "warn" | "info"): "rune" | "crystal" | "ember" {
+ return severity === "danger" ? "ember" : severity === "warn" ? "rune" : "crystal";
+}
+
+function playPresetSignal(preset: "rune" | "crystal" | "ember" | "bell", severity: "danger" | "warn" | "info") {
try {
const context = new AudioContext();
- const oscillator = context.createOscillator();
- const gain = context.createGain();
- oscillator.type = "sine";
- oscillator.frequency.value = severity === "danger" ? 740 : severity === "warn" ? 560 : 440;
- gain.gain.setValueAtTime(0.0001, context.currentTime);
- gain.gain.exponentialRampToValueAtTime(0.16, context.currentTime + 0.015);
- gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.28);
- oscillator.connect(gain).connect(context.destination);
- oscillator.start();
- oscillator.stop(context.currentTime + 0.3);
- oscillator.addEventListener("ended", () => void context.close());
+ const master = context.createGain();
+ master.gain.value = .72;
+ master.connect(context.destination);
+ const notes = preset === "crystal"
+ ? [{ at: 0, hz: 880, length: .32, type: "triangle" as OscillatorType, volume: .11 }, { at: .08, hz: 1320, length: .44, type: "sine" as OscillatorType, volume: .08 }]
+ : preset === "ember"
+ ? [{ at: 0, hz: 760, length: .18, type: "sawtooth" as OscillatorType, volume: .12 }, { at: .19, hz: 520, length: .24, type: "square" as OscillatorType, volume: .08 }]
+ : preset === "bell"
+ ? [{ at: 0, hz: 523.25, length: .7, type: "sine" as OscillatorType, volume: .11 }, { at: 0, hz: 1046.5, length: .5, type: "sine" as OscillatorType, volume: .05 }]
+ : [{ at: 0, hz: severity === "danger" ? 620 : 440, length: .25, type: "sine" as OscillatorType, volume: .12 }, { at: .13, hz: severity === "danger" ? 820 : 660, length: .3, type: "triangle" as OscillatorType, volume: .09 }];
+ let endAt = context.currentTime;
+ for (const note of notes) {
+ const starts = context.currentTime + note.at;
+ const ends = starts + note.length;
+ endAt = Math.max(endAt, ends);
+ const oscillator = context.createOscillator();
+ const gain = context.createGain();
+ oscillator.type = note.type;
+ oscillator.frequency.value = note.hz;
+ gain.gain.setValueAtTime(.0001, starts);
+ gain.gain.exponentialRampToValueAtTime(note.volume, starts + .012);
+ gain.gain.exponentialRampToValueAtTime(.0001, ends);
+ oscillator.connect(gain).connect(master);
+ oscillator.start(starts);
+ oscillator.stop(ends);
+ }
+ setTimeout(() => void context.close(), Math.ceil((endAt - context.currentTime + .08) * 1000));
} catch {
// The visual alert remains authoritative if an audio device is absent.
}
}
+async function previewConfiguredSound(kind: AlertSoundKind, profile: DesktopSettings["alerts"]["soundProfiles"][AlertSoundKind], severity: "danger" | "warn" | "info") {
+ if (profile.preset === "silent") return;
+ if (profile.preset === "custom") {
+ try {
+ const custom = await window.loremasterDesktop?.readAlertSound(kind);
+ if (custom?.bytes?.byteLength) {
+ const mime = custom.extension === ".wav" ? "audio/wav"
+ : custom.extension === ".mp3" ? "audio/mpeg"
+ : custom.extension === ".ogg" ? "audio/ogg" : "audio/mp4";
+ const blob = new Blob([new Uint8Array(custom.bytes)], { type: mime });
+ const url = URL.createObjectURL(blob);
+ const audio = new Audio(url);
+ audio.volume = .82;
+ const release = () => URL.revokeObjectURL(url);
+ audio.addEventListener("ended", release, { once: true });
+ audio.addEventListener("error", release, { once: true });
+ await audio.play();
+ return;
+ }
+ } catch {
+ // Moved or unsupported custom files fall back to a clear preset cue.
+ }
+ playPresetSignal(presetFallback(severity), severity);
+ return;
+ }
+ playPresetSignal(profile.preset, severity);
+}
+
+function soundKindForAlert(kind: string, title = ""): AlertSoundKind {
+ if (kind === "charmBreak") return "charmBreak";
+ if (title.includes("CALLED YOU")) return "nameCalled";
+ if (kind === "tell_in" || kind.startsWith("tell")) return "tell";
+ if (kind === "summoned") return "summon";
+ if (kind === "death_you") return "death";
+ if (["melee_in", "nuke_in", "dot_in", "nonmelee_in"].includes(kind)) return "bigHit";
+ if (kind === "mez" || kind === "lull") return kind;
+ return "default";
+}
+
function AlertSurface() {
const [event, setEvent] = useState(emptyEvent);
const [settings, setSettings] = useState(defaultDesktopSettings);
@@ -459,13 +658,18 @@ function AlertSurface() {
if (!desktop) return () => document.body.classList.remove("alert-window");
void desktop.getEngineState().then((state) => {
if (isEngineSnapshotEvent(state.snapshot)) setEvent(state.snapshot);
+ applyTheme(state.settings.uiTheme);
setSettings(state.settings);
});
const removeSnapshot = desktop.onSnapshot((value) => {
if (isEngineSnapshotEvent(value)) setEvent(value);
});
const removeSettings = desktop.onSettings((value) => {
- if (value && typeof value === "object") setSettings(value as DesktopSettings);
+ if (value && typeof value === "object") {
+ const next = value as DesktopSettings;
+ applyTheme(next.uiTheme);
+ setSettings(next);
+ }
});
const removeTest = desktop.onTestAlert((value) => {
if (!value || typeof value !== "object") return;
@@ -487,6 +691,7 @@ function AlertSurface() {
const signal = testAlert ? {
id: testAlert.id, severity: testAlert.severity, eyebrow: "ALERT PREVIEW",
title: testAlert.title, detail: testAlert.target, kind: "test",
+ soundKind: "default" as AlertSoundKind,
shouldSound: settings.alerts.alertSound,
} : explicit ? {
id: explicit.id,
@@ -495,6 +700,7 @@ function AlertSurface() {
title: explicit.title,
detail: explicit.target,
kind: explicit.kind,
+ soundKind: soundKindForAlert(explicit.kind, explicit.title),
shouldSound: settings.alerts.alertSound,
} : pendingRaid ? {
id: `raid-${pendingRaid}`,
@@ -503,6 +709,7 @@ function AlertSurface() {
title: pendingRaid,
detail: "Open Loremaster and confirm D0–D4 to record this lockout.",
kind: "raid",
+ soundKind: "default" as AlertSoundKind,
shouldSound: settings.alerts.alertSound,
} : urgentControl ? {
id: `${urgentControl.kind}-${urgentControl.landedAt}-${urgentControl.urgency}`,
@@ -511,14 +718,16 @@ function AlertSurface() {
title: urgentControl.target,
detail: `${Math.ceil(urgentControl.safeRemainingSeconds)}s safe · ${spellLabel(urgentControl)}`,
kind: urgentControl.kind,
+ soundKind: urgentControl.kind as AlertSoundKind,
shouldSound: urgentControl.kind === "mez" ? settings.alerts.mezTimerSound : settings.alerts.lullTimerSound,
} : null;
useEffect(() => {
if (!signal || !signal.shouldSound || sounded.current.has(signal.id)) return;
sounded.current.add(signal.id);
- playSignal(signal.severity);
- }, [signal]);
+ const profile = settings.alerts.soundProfiles?.[signal.soundKind] ?? defaultSoundProfiles[signal.soundKind];
+ void previewConfiguredSound(signal.soundKind, profile, signal.severity);
+ }, [signal, settings.alerts.soundProfiles]);
if (!signal) return
;
return
@@ -644,6 +853,7 @@ function MainApp() {
if (isEngineHealth(state.health)) setHealth(state.health);
if (isEngineSnapshotEvent(state.snapshot)) setEvent(state.snapshot);
setRaidDifficulty(state.settings.raidDifficulty);
+ applyTheme(state.settings.uiTheme);
setSettings(state.settings);
if (isGearPlanView(state.gearPlan)) setGearPlan(state.gearPlan);
});
@@ -657,7 +867,11 @@ function MainApp() {
if (isGearPlanView(value)) setGearPlan(value);
});
const removeSettings = desktop.onSettings((value) => {
- if (value && typeof value === "object") setSettings(value as DesktopSettings);
+ if (value && typeof value === "object") {
+ const next = value as DesktopSettings;
+ applyTheme(next.uiTheme);
+ setSettings(next);
+ }
});
return () => { removeSnapshot(); removeHealth(); removeGearPlan(); removeSettings(); };
}, []);
@@ -845,17 +1059,6 @@ function MainApp() {
{weekly.pendingRaidTarget &&
{weekly.pendingRaidTarget} is awaiting the D0–D4 confirmation shown above.
}
- {weekly.altZScan &&
-
- {weekly.altZScan.detail}
- {(weekly.altZLockouts?.length ?? 0) > 0 &&
- {weekly.altZLockouts?.map((lockout) =>
- {lockout.target} D{lockout.difficulty} · {lockout.instanceName}
- {formatLockout(lockout.remainingSeconds)}
- )}
-
}
- Open Instance Information with Alt+Z, point inside the timer table, then use the hotkey. Scroll and repeat to merge another visible page.
- }
RAID TARGET {raidDifficulties.map((difficulty) => D{difficulty} )}
{weekly.raids.map((raid) =>
diff --git a/loremaster-desktop/src/global.d.ts b/loremaster-desktop/src/global.d.ts
index 433d848..f1848fc 100644
--- a/loremaster-desktop/src/global.d.ts
+++ b/loremaster-desktop/src/global.d.ts
@@ -1,4 +1,4 @@
-import type { DesktopSettings, EngineHealth, EngineSnapshotEvent, GearPlanView } from "./protocol";
+import type { AlertSoundKind, DesktopSettings, EngineHealth, EngineSnapshotEvent, GearPlanView } from "./protocol";
export {};
@@ -35,6 +35,12 @@ declare global {
}>;
resetEngine: () => void;
testAlert: () => void;
+ chooseAlertSound: (kind: AlertSoundKind) => Promise
;
+ readAlertSound: (kind: AlertSoundKind) => Promise<{
+ bytes: Uint8Array;
+ extension: string;
+ name: string;
+ } | null>;
onSnapshot: (callback: (event: unknown) => void) => () => void;
onHealth: (callback: (health: unknown) => void) => () => void;
onGearPlan: (callback: (gearPlan: unknown) => void) => () => void;
diff --git a/loremaster-desktop/src/main.tsx b/loremaster-desktop/src/main.tsx
index 693ac2e..c0e2e11 100644
--- a/loremaster-desktop/src/main.tsx
+++ b/loremaster-desktop/src/main.tsx
@@ -2,6 +2,10 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
+import "./themes.css";
+
+const requestedTheme = new URLSearchParams(window.location.search).get("theme");
+document.documentElement.dataset.theme = requestedTheme === "glass" ? "glass" : "vellum";
createRoot(document.getElementById("root")!).render(
diff --git a/loremaster-desktop/src/protocol.ts b/loremaster-desktop/src/protocol.ts
index 57000f9..21d184c 100644
--- a/loremaster-desktop/src/protocol.ts
+++ b/loremaster-desktop/src/protocol.ts
@@ -158,24 +158,6 @@ export interface WeeklyRaidRowView {
bestSeconds: readonly (number | null)[];
}
-export interface AltZLockoutView {
- target: string;
- difficulty: number;
- remainingSeconds: number;
- instanceName: string;
- eventName: string;
- expiresAt: string;
-}
-
-export interface AltZScanView {
- status: "idle" | "scanning" | "success" | "error";
- detail: string;
- scannedAt: string;
- importedCount: number;
- timedCount?: number;
- hotkey: string;
-}
-
export interface WeeklyProgressView {
weekStart: string;
nextReset: string;
@@ -187,8 +169,6 @@ export interface WeeklyProgressView {
raids: readonly WeeklyRaidRowView[];
activeDifficulty?: number | null;
pendingRaidTarget?: string;
- altZLockouts?: readonly AltZLockoutView[];
- altZScan?: AltZScanView;
}
export interface GearGoalView {
@@ -246,6 +226,16 @@ export interface EngineHealth {
}
export type AlertAnchor = "auto" | "above" | "below" | "left" | "right";
+export type LoremasterTheme = "vellum" | "glass";
+export type AlertSoundKind = "default" | "charmBreak" | "tell" | "summon" | "death" | "bigHit" | "nameCalled" | "mez" | "lull";
+export type AlertSoundPreset = "rune" | "crystal" | "ember" | "bell" | "custom" | "silent";
+
+export interface AlertSoundProfile {
+ preset: AlertSoundPreset;
+ customPath: string;
+}
+
+export type AlertSoundProfiles = Record;
export interface AlertSettings {
alertsEnabled: boolean;
@@ -265,6 +255,7 @@ export interface AlertSettings {
lullTimersEnabled: boolean;
lullTimerSound: boolean;
lullWarningSeconds: number;
+ soundProfiles: AlertSoundProfiles;
}
export interface DesktopSettings {
@@ -272,6 +263,7 @@ export interface DesktopSettings {
raidDifficulty: number | null;
bisBuildPath: string;
inventoryPath: string;
+ uiTheme: LoremasterTheme;
alwaysOnTop: boolean;
fontScale: number;
composition: string;
diff --git a/loremaster-desktop/src/styles.css b/loremaster-desktop/src/styles.css
index 61c1c20..89f96e6 100644
--- a/loremaster-desktop/src/styles.css
+++ b/loremaster-desktop/src/styles.css
@@ -262,23 +262,6 @@ body.control-window { background: transparent; }
.weekly-card summary > span.tier-needed { color: #f2c96f; }
.weekly-card > p { color: var(--dim); font-size: 9px; line-height: 1.4; margin: 8px 0 1px; }
.weekly-card .raid-pending { color: #f2c96f; border-left: 2px solid #f2c96f; padding: 5px 7px; background: rgba(242,201,111,.06); }
-.altz-sync { display: grid; gap: 6px; margin-top: 9px; border: 1px solid rgba(97,215,208,.2); border-radius: 8px; padding: 8px; background: linear-gradient(135deg, rgba(17,35,43,.58), rgba(9,13,19,.68)); }
-.altz-sync.error { border-color: rgba(255,118,89,.34); background: rgba(62,25,23,.2); }
-.altz-sync.scanning { border-color: rgba(242,201,111,.42); box-shadow: inset 0 0 18px rgba(242,201,111,.04); }
-.altz-sync header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
-.altz-sync header > div { display: grid; gap: 2px; min-width: 0; }
-.altz-sync header small { color: var(--cyan); font-size: 7px; letter-spacing: .11em; }
-.altz-sync.error header small { color: var(--ember); }
-.altz-sync header b { color: #e9f1f4; font: 700 10px Georgia, serif; letter-spacing: .04em; }
-.altz-sync kbd { flex: 0 0 auto; border: 1px solid rgba(214,173,103,.34); border-radius: 5px; padding: 4px 6px; color: var(--gold); background: rgba(8,10,14,.78); font: 700 8px Georgia, serif; box-shadow: inset 0 1px rgba(255,255,255,.04); }
-.weekly-card .altz-sync > p { margin: 0; color: #91a5b4; font-size: 8px; line-height: 1.4; }
-.altz-lockouts { display: grid; gap: 3px; }
-.altz-lockouts article { display: flex; align-items: center; gap: 8px; border-top: 1px solid rgba(91,123,151,.18); padding-top: 5px; }
-.altz-lockouts article > span { display: grid; gap: 1px; min-width: 0; }
-.altz-lockouts article b { color: #dce9f1; font-size: 9px; }
-.altz-lockouts article small { color: var(--dim); font-size: 7px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.altz-lockouts article strong { margin-left: auto; flex: 0 0 auto; color: #45e3a1; font: 700 9px Georgia, serif; }
-.altz-sync footer { color: #617b8e; font-size: 7px; line-height: 1.35; }
.raid-grid { display: grid; gap: 3px; margin-top: 9px; }
.raid-grid-head, .raid-grid-row { display: grid; grid-template-columns: minmax(116px, 1fr) repeat(5, 30px); align-items: center; gap: 3px; }
.raid-grid-head { color: var(--dim); font-size: 8px; letter-spacing: .08em; padding: 0 2px 2px; }
@@ -359,6 +342,26 @@ body.control-window { background: transparent; }
.settings-panel button:disabled { opacity: .55; cursor: wait; }
.settings-card.update-card .update-ready { color: #08110f; border-color: #45e3a1; background: #45e3a1; }
+.sound-studio { display:grid; gap:8px; }
+.sound-profile-list { display:grid; gap:5px; }
+.sound-profile { display:grid; grid-template-columns:minmax(0,1fr) 125px 28px; align-items:center; gap:7px; min-height:42px; border-top:1px solid var(--soft-line); padding:6px 0; }
+.sound-profile>span { min-width:0; display:grid; gap:2px; }
+.sound-profile>span b { color:#dce9f1; font:600 10px "Segoe UI",sans-serif; }
+.sound-profile>span small,.sound-note { color:var(--dim); font:400 8px/1.35 "Segoe UI",sans-serif; }
+.sound-preset-trigger { min-width:0; width:100%; height:28px; display:flex; align-items:center; justify-content:space-between; gap:6px; border:1px solid var(--line); border-radius:6px; padding:0 7px; color:#dce9f1; background:#080c12; font:600 9px "Segoe UI",sans-serif; text-align:left; }
+.sound-preset-trigger span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.sound-preset-trigger>i { width:6px; height:6px; flex:0 0 auto; border-right:1px solid currentColor; border-bottom:1px solid currentColor; transform:translateY(-2px) rotate(45deg); transition:transform .14s ease; }
+.sound-preset-trigger.open>i { transform:translateY(2px) rotate(225deg); }
+.sound-profile .sound-preview { width:28px; height:28px; padding:0; color:var(--cyan); }
+.sound-profile .sound-file { grid-column:2/4; min-width:0; overflow:hidden; padding:5px 7px; color:var(--gold); font-size:8px; text-align:left; text-overflow:ellipsis; white-space:nowrap; }
+.sound-preset-menu { grid-column:1/4; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:5px; border:1px solid var(--line); border-radius:8px; padding:6px; background:#080c12; box-shadow:0 9px 22px rgba(0,0,0,.38),inset 0 1px rgba(255,255,255,.04); }
+.sound-preset-menu button { min-width:0; min-height:27px; display:flex; align-items:center; gap:6px; padding:5px 7px; border-color:var(--soft-line); text-align:left; }
+.sound-preset-menu button>i { width:6px; height:6px; flex:0 0 auto; border:1px solid currentColor; border-radius:50%; opacity:.48; }
+.sound-preset-menu button>span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.sound-preset-menu button.selected { color:var(--gold); border-color:var(--line); background:rgba(214,173,103,.09); }
+.sound-preset-menu button.selected>i { border-color:currentColor; background:currentColor; box-shadow:0 0 6px currentColor; opacity:1; }
+.sound-note { display:block; padding-top:2px; }
+
.settings-toggle { position: relative; display: flex; align-items: center; gap: 12px; min-height: 39px; border-top: 1px solid rgba(91,123,151,.13); cursor: pointer; }
.settings-toggle > span { min-width: 0; display: grid; gap: 2px; }
.settings-toggle > span b { color: #dce9f1; font: 600 10px "Segoe UI", sans-serif; letter-spacing: 0; }
@@ -572,7 +575,7 @@ body.alert-window { background: transparent; }
.archive-table>article>span b { font-size:11px; }.archive-table>article>span small { font-size:8px; }.archive-table>article>strong { font-size:10px; }
.archive-insights article small { font-size:8px; }.archive-insights article b { font-size:11px; }.archive-insights article span,.archive-insights footer { font-size:9px; }
.archive-timeline header,.archive-timeline footer { font-size:9px; }
-.encounter-nav>span small,.fight-facts small,.actor-tabs button small,.actor-tabs button span,.actor-drilldown>header small,.actor-facts small,.actor-identities small,.breakdown-columns article small,.stance-advisor small,.stance-advisor>span,.altz-sync header small,.altz-lockouts article small,.altz-sync footer,.seed-control-time small,.number-setting b { font-family:"Segoe UI Variable Text","Segoe UI",sans-serif; font-size:8px; }
+.encounter-nav>span small,.fight-facts small,.actor-tabs button small,.actor-tabs button span,.actor-drilldown>header small,.actor-facts small,.actor-identities small,.breakdown-columns article small,.stance-advisor small,.stance-advisor>span,.seed-control-time small,.number-setting b { font-family:"Segoe UI Variable Text","Segoe UI",sans-serif; font-size:8px; }
@media (max-width:920px) {
.archive-layout { grid-template-columns:236px minmax(0,1fr); }
diff --git a/loremaster-desktop/src/themes.css b/loremaster-desktop/src/themes.css
new file mode 100644
index 0000000..cf42b9b
--- /dev/null
+++ b/loremaster-desktop/src/themes.css
@@ -0,0 +1,1170 @@
+/*
+ * Loremaster material themes
+ * --------------------------
+ * Vellum & Ember is the default companion for SpinUI Reloaded.
+ * Midnight Frost Glass is the companion for SpinUI Glass.
+ *
+ * Keep state colors semantic: danger, warning and success never inherit a
+ * decorative theme accent. All window surfaces remain opaque enough for EQ
+ * readability and deliberately avoid live blur effects.
+ */
+
+:root,
+:root[data-theme="vellum"] {
+ color-scheme: dark;
+
+ --lm-void: #090704;
+ --lm-surface-0: #0c0906;
+ --lm-panel: #130e09;
+ --lm-raised: #1e160e;
+ --lm-surface-3: #2e2215;
+ --lm-line-soft: #342819;
+ --lm-line: #685030;
+ --lm-line-bright: #a68252;
+ --lm-text: #f1e7d4;
+ --lm-text-strong: #fff8e9;
+ --lm-dim: #ac9a7e;
+ --lm-muted: #7f705b;
+ --lm-heading: #f8d68c;
+ --lm-primary: #d0a254;
+ --lm-primary-deep: #705222;
+ --lm-secondary: #7eaaf4;
+ --lm-secondary-deep: #304a84;
+ --lm-action: #f2762c;
+ --lm-success: #42cf8b;
+ --lm-danger: #de3e48;
+ --lm-warning: #f2c96f;
+ --lm-meter: #2e1c10;
+ --lm-meter-edge: #9a5a24;
+
+ --lm-primary-rgb: 208, 162, 84;
+ --lm-secondary-rgb: 126, 170, 244;
+ --lm-action-rgb: 242, 118, 44;
+ --lm-success-rgb: 66, 207, 139;
+ --lm-danger-rgb: 222, 62, 72;
+ --lm-warning-rgb: 242, 201, 111;
+ --lm-line-rgb: 104, 80, 48;
+ --lm-line-bright-rgb: 166, 130, 82;
+ --lm-meter-edge-rgb: 154, 90, 36;
+ --lm-text-rgb: 241, 231, 212;
+
+ --lm-shell-bg:
+ radial-gradient(circle at 11% -8%, rgba(112, 82, 34, .32), transparent 35%),
+ radial-gradient(circle at 94% 4%, rgba(76, 48, 20, .19), transparent 29%),
+ linear-gradient(152deg, rgba(19, 14, 9, .995), rgba(9, 7, 4, .998));
+ --lm-seed-bg:
+ radial-gradient(circle at 28% 0%, rgba(166, 130, 82, .17), transparent 45%),
+ linear-gradient(145deg, rgba(30, 22, 14, .995), rgba(9, 7, 4, .998));
+ --lm-header-bg: linear-gradient(105deg, rgba(38, 29, 18, .995), rgba(15, 11, 7, .997));
+ --lm-card-bg: linear-gradient(145deg, rgba(30, 22, 14, .94), rgba(15, 11, 7, .96));
+ --lm-card-raised-bg: linear-gradient(145deg, rgba(46, 34, 21, .9), rgba(20, 14, 9, .95));
+ --lm-inset-bg: rgba(9, 7, 4, .82);
+ --lm-selected-bg: linear-gradient(145deg, rgba(112, 82, 34, .48), rgba(26, 19, 12, .9));
+ --lm-shadow: 0 14px 38px rgba(0, 0, 0, .48), inset 0 1px rgba(255, 248, 233, .055);
+}
+
+:root[data-theme="glass"] {
+ --lm-void: #02060b;
+ --lm-surface-0: #03080e;
+ --lm-panel: #060f18;
+ --lm-raised: #0a1b28;
+ --lm-surface-3: #123042;
+ --lm-line-soft: #163646;
+ --lm-line: #30798f;
+ --lm-line-bright: #99effa;
+ --lm-text: #e8f8fc;
+ --lm-text-strong: #f4fdff;
+ --lm-dim: #8bb4be;
+ --lm-muted: #617f89;
+ --lm-heading: #cff7ff;
+ --lm-primary: #69e1f2;
+ --lm-primary-deep: #1c6677;
+ --lm-secondary: #ab80ff;
+ --lm-secondary-deep: #3f2770;
+ --lm-action: #55f2be;
+ --lm-success: #55f2be;
+ --lm-danger: #f25567;
+ --lm-warning: #d2aa5e;
+ --lm-meter: #0b2430;
+ --lm-meter-edge: #3eaac2;
+
+ --lm-primary-rgb: 105, 225, 242;
+ --lm-secondary-rgb: 171, 128, 255;
+ --lm-action-rgb: 85, 242, 190;
+ --lm-success-rgb: 85, 242, 190;
+ --lm-danger-rgb: 242, 85, 103;
+ --lm-warning-rgb: 210, 170, 94;
+ --lm-line-rgb: 48, 121, 143;
+ --lm-line-bright-rgb: 153, 239, 250;
+ --lm-meter-edge-rgb: 62, 170, 194;
+ --lm-text-rgb: 232, 248, 252;
+
+ --lm-shell-bg:
+ radial-gradient(circle at 10% -8%, rgba(62, 170, 194, .24), transparent 36%),
+ radial-gradient(circle at 94% 0%, rgba(112, 75, 188, .15), transparent 30%),
+ linear-gradient(152deg, rgba(6, 15, 24, .995), rgba(2, 6, 11, .998));
+ --lm-seed-bg:
+ radial-gradient(circle at 25% 0%, rgba(105, 225, 242, .16), transparent 46%),
+ linear-gradient(145deg, rgba(10, 27, 40, .995), rgba(2, 6, 11, .998));
+ --lm-header-bg: linear-gradient(105deg, rgba(10, 29, 42, .995), rgba(3, 9, 15, .997));
+ --lm-card-bg: linear-gradient(145deg, rgba(10, 27, 40, .94), rgba(4, 11, 18, .97));
+ --lm-card-raised-bg: linear-gradient(145deg, rgba(18, 48, 66, .82), rgba(6, 16, 25, .95));
+ --lm-inset-bg: rgba(2, 6, 11, .84);
+ --lm-selected-bg: linear-gradient(145deg, rgba(36, 97, 116, .5), rgba(8, 24, 35, .92));
+ --lm-shadow: 0 14px 38px rgba(0, 0, 0, .5), inset 0 1px rgba(232, 248, 252, .065);
+}
+
+/* Backwards-compatible aliases used by the existing component stylesheet. */
+:root {
+ --void: var(--lm-void);
+ --panel: rgba(var(--lm-text-rgb), .01);
+ --raised: var(--lm-raised);
+ --line: var(--lm-line);
+ --soft-line: rgba(var(--lm-line-rgb), .35);
+ --cyan: var(--lm-primary);
+ --blue: var(--lm-secondary);
+ --gold: var(--lm-primary);
+ --ember: var(--lm-danger);
+ --dim: var(--lm-dim);
+ --danger: var(--lm-danger);
+ --warning: var(--lm-warning);
+ --success: var(--lm-success);
+}
+
+html,
+body,
+#root {
+ color: var(--lm-text);
+}
+
+body {
+ text-rendering: geometricPrecision;
+}
+
+:is(button, input, select, summary, [tabindex]):focus-visible {
+ outline: 2px solid var(--lm-line-bright);
+ outline-offset: 2px;
+}
+
+::selection {
+ color: var(--lm-text-strong);
+ background: rgba(var(--lm-secondary-rgb), .42);
+}
+
+/* Seed and its compact companion surfaces. */
+.rune-seed {
+ color: var(--lm-text-strong);
+ border-color: rgba(var(--lm-line-bright-rgb), .74);
+ background: var(--lm-seed-bg);
+ box-shadow: var(--lm-shadow);
+}
+
+.rune-seed::after {
+ border-color: rgba(var(--lm-primary-rgb), .22);
+}
+
+.brand-cog {
+ border-color: rgba(var(--lm-line-bright-rgb), .45);
+ background:
+ radial-gradient(circle, rgba(var(--lm-primary-rgb), .13), transparent 64%),
+ var(--lm-inset-bg);
+ box-shadow: inset 0 1px rgba(var(--lm-text-rgb), .07), 0 0 14px rgba(var(--lm-primary-rgb), .09);
+}
+
+.brand-cog img {
+ filter: drop-shadow(0 0 5px rgba(var(--lm-primary-rgb), .24));
+}
+
+.seed-metric small,
+.seed-group-row > strong small {
+ color: var(--lm-primary);
+}
+
+.seed-alert,
+.rune-seed.urgent {
+ color: var(--lm-danger);
+}
+
+.rune-seed.urgent {
+ border-color: rgba(var(--lm-danger-rgb), .82);
+}
+
+.seed-companion-surface {
+ background: rgba(2, 4, 7, .2);
+}
+
+.seed-group-surface,
+.seed-control-surface {
+ color: var(--lm-text);
+ border-color: rgba(var(--lm-line-bright-rgb), .52);
+ background: var(--lm-card-bg);
+ box-shadow: var(--lm-shadow);
+}
+
+.seed-group-surface > header,
+.seed-control-surface > header {
+ border-bottom-color: rgba(var(--lm-line-rgb), .38);
+ background: var(--lm-header-bg);
+}
+
+.seed-group-surface > header span,
+.seed-control-surface > header span,
+.seed-control-time strong {
+ color: var(--lm-primary);
+}
+
+.seed-group-surface > header span i,
+.seed-control-surface > header span i,
+.masthead small b,
+.alerts-rail i,
+.engine-status.live > i,
+.archive-health i {
+ background: var(--lm-success);
+ box-shadow: 0 0 9px rgba(var(--lm-success-rgb), .68);
+}
+
+.seed-group-surface > header strong,
+.seed-group-row > span small,
+.seed-group-row > em,
+.seed-control-copy small,
+.seed-control-time small {
+ color: var(--lm-dim);
+}
+
+.seed-group-row,
+.seed-control-row {
+ border-bottom-color: rgba(var(--lm-line-rgb), .2);
+ background: rgba(3, 5, 7, .18);
+}
+
+.seed-group-row > span b,
+.seed-control-copy strong,
+.seed-group-row > strong {
+ color: var(--lm-text-strong);
+}
+
+.seed-control-accent,
+.control-accent {
+ background: var(--lm-secondary);
+ box-shadow: 0 0 12px rgba(var(--lm-secondary-rgb), .44);
+}
+
+.seed-control-row.lull .seed-control-accent,
+.control-row.lull .control-accent {
+ background: var(--lm-primary);
+ box-shadow: 0 0 12px rgba(var(--lm-primary-rgb), .43);
+}
+
+.seed-control-row.warning .seed-control-accent,
+.control-row.warning .control-accent,
+.control-row.ambiguous .control-accent,
+.control-row.unconfirmed .control-accent {
+ background: var(--lm-warning);
+}
+
+.seed-control-row.critical .seed-control-accent,
+.control-row.critical .control-accent,
+.control-row.failed .control-accent {
+ background: var(--lm-danger);
+ box-shadow: 0 0 13px rgba(var(--lm-danger-rgb), .5);
+}
+
+.seed-control-row.warning .seed-control-time strong,
+.control-row.warning .control-time strong,
+.control-row.unconfirmed .control-time strong,
+.control-row.ambiguous .control-time strong {
+ color: var(--lm-warning);
+}
+
+.seed-control-row.critical .seed-control-time strong,
+.control-row.critical .control-time strong,
+.control-row.failed .control-time strong {
+ color: var(--lm-danger);
+ text-shadow: 0 0 10px rgba(var(--lm-danger-rgb), .28);
+}
+
+.seed-control-meter,
+.control-meter,
+.hero-rule,
+.archive-share-track {
+ background: var(--lm-meter);
+ box-shadow: inset 0 0 0 1px rgba(var(--lm-meter-edge-rgb), .12);
+}
+
+.seed-control-meter > i,
+.control-meter span,
+.hero-rule i {
+ background: linear-gradient(90deg, var(--lm-secondary), var(--lm-primary));
+}
+
+.seed-control-row.lull .seed-control-meter > i,
+.control-row.lull .control-meter span {
+ background: linear-gradient(90deg, var(--lm-primary-deep), var(--lm-primary));
+}
+
+/* Expanded HUD shell and reusable cards. */
+.loremaster-shell {
+ color: var(--lm-text);
+ border-color: rgba(var(--lm-line-bright-rgb), .68);
+ background: var(--lm-shell-bg);
+ box-shadow: inset 0 1px rgba(var(--lm-text-rgb), .06);
+}
+
+.masthead {
+ border-bottom-color: rgba(var(--lm-line-rgb), .44);
+ background: var(--lm-header-bg);
+ box-shadow: 0 8px 18px rgba(0, 0, 0, .32), inset 0 1px rgba(var(--lm-text-rgb), .055);
+}
+
+.masthead::before {
+ background: var(--lm-panel);
+}
+
+.masthead p,
+.settings-panel h2,
+.archive-masthead h1,
+.archive-report-head h2 {
+ color: var(--lm-heading);
+}
+
+.masthead small,
+.context-line,
+.health-banner span,
+.quiet-state,
+.health-line {
+ color: var(--lm-dim);
+}
+
+.masthead button,
+.health-banner button,
+.encounter-nav button,
+.settings-panel button,
+.gear-credit button,
+.alerts-rail button,
+.replay-controls button {
+ color: var(--lm-primary);
+ border-color: rgba(var(--lm-line-rgb), .46);
+ background: var(--lm-inset-bg);
+}
+
+:is(.masthead, .settings-panel, .encounter-nav, .gear-credit, .alerts-rail, .replay-controls) button:hover:not(:disabled) {
+ color: var(--lm-text-strong);
+ border-color: rgba(var(--lm-line-bright-rgb), .8);
+ background: var(--lm-selected-bg);
+}
+
+.context-line > span:first-child b,
+.control-deck h2,
+.breakdown-card h2,
+.weekly-card h2,
+.gear-card h2,
+.settings-toggle > span b,
+.font-scale-setting > span b {
+ color: var(--lm-text);
+}
+
+.context-line em {
+ color: var(--lm-text-strong);
+ border-color: rgba(var(--lm-secondary-rgb), .4);
+ background: rgba(var(--lm-secondary-rgb), .16);
+}
+
+.context-line span:last-child,
+.hero-card > p,
+.encounter-nav > span b,
+.weekly-card summary small,
+.gear-card summary > span,
+.bag-upgrades > small,
+.farm-routes > small,
+.settings-card label,
+.font-scale-setting strong {
+ color: var(--lm-primary);
+}
+
+.health-banner {
+ border-color: rgba(var(--lm-primary-rgb), .34);
+ background: rgba(var(--lm-primary-rgb), .11);
+}
+
+.health-banner > i,
+.engine-status > i {
+ background: var(--lm-primary);
+ box-shadow: 0 0 10px rgba(var(--lm-primary-rgb), .62);
+}
+
+.health-banner.error {
+ border-color: rgba(var(--lm-danger-rgb), .48);
+ background: rgba(var(--lm-danger-rgb), .12);
+}
+
+.health-banner.error > i,
+.engine-status.error > i,
+.masthead small b.error,
+.archive-health.error i {
+ background: var(--lm-danger);
+ box-shadow: 0 0 10px rgba(var(--lm-danger-rgb), .7);
+}
+
+.danger-toast {
+ border-color: rgba(var(--lm-danger-rgb), .68);
+ background: linear-gradient(100deg, rgba(var(--lm-danger-rgb), .24), rgba(12, 7, 8, .96));
+ box-shadow: inset 0 1px rgba(var(--lm-text-rgb), .05), 0 0 22px rgba(var(--lm-danger-rgb), .14);
+}
+
+.danger-toast > span,
+.danger-toast small {
+ color: var(--lm-danger);
+ border-color: var(--lm-danger);
+}
+
+.danger-toast strong {
+ color: var(--lm-text-strong);
+}
+
+.raid-confirmation {
+ border-color: rgba(var(--lm-success-rgb), .5);
+ background: linear-gradient(115deg, rgba(var(--lm-success-rgb), .2), var(--lm-inset-bg));
+ box-shadow: inset 0 1px rgba(var(--lm-text-rgb), .06), 0 0 24px rgba(var(--lm-success-rgb), .1);
+}
+
+.raid-confirmation-glyph,
+.raid-confirmation small {
+ color: var(--lm-success);
+ border-color: rgba(var(--lm-success-rgb), .76);
+}
+
+.raid-confirmation strong {
+ color: var(--lm-text-strong);
+}
+
+.raid-confirmation p {
+ color: var(--lm-dim);
+}
+
+.raid-confirmation-tiers button {
+ color: var(--lm-success);
+ border-color: rgba(var(--lm-success-rgb), .4);
+ background: var(--lm-inset-bg);
+}
+
+.raid-confirmation-tiers button:hover {
+ color: var(--lm-void);
+ background: var(--lm-success);
+}
+
+.hero-card,
+.stat-grid article,
+.breakdown-card,
+.weekly-card,
+.gear-card,
+.settings-card {
+ color: var(--lm-text);
+ border-color: rgba(var(--lm-line-rgb), .4);
+ background: var(--lm-card-bg);
+ box-shadow: inset 0 1px rgba(var(--lm-text-rgb), .04);
+}
+
+.hero-metric > strong,
+.hero-metric aside b,
+.stat-grid strong,
+.fight-facts b,
+.actor-tabs button b,
+.actor-drilldown h3,
+.actor-facts b,
+.actor-identities b,
+.breakdown-columns article b,
+.stance-advisor strong,
+.raid-grid-row > span > b,
+.farm-routes strong,
+.engine-status b {
+ color: var(--lm-text-strong);
+}
+
+.hero-metric > span,
+.dps-split b,
+.control-deck header small,
+.breakdown-card summary small,
+.breakdown-columns > section > small,
+.actor-drilldown > header small,
+.stance-advisor small,
+.gear-card summary small,
+.settings-panel header small {
+ color: var(--lm-primary);
+}
+
+.dps-split span,
+.actor-facts > span,
+.actor-identities,
+.breakdown-columns article,
+.number-setting,
+.font-scale-setting,
+.settings-toggle {
+ border-color: rgba(var(--lm-line-rgb), .25);
+}
+
+.control-deck {
+ border-color: rgba(var(--lm-line-rgb), .43);
+ background: var(--lm-inset-bg);
+}
+
+.control-deck > header {
+ border-bottom-color: rgba(var(--lm-line-rgb), .36);
+ background: var(--lm-card-raised-bg);
+}
+
+.control-row {
+ border-bottom-color: rgba(var(--lm-line-rgb), .23);
+}
+
+.control-copy strong,
+.actor-tabs button,
+.breakdown-columns article b {
+ color: var(--lm-text);
+}
+
+.control-copy small,
+.control-time small,
+.stat-grid small,
+.fight-facts small,
+.actor-tabs button small,
+.actor-facts small,
+.actor-identities small,
+.actor-estimate,
+.breakdown-columns article small,
+.stance-advisor p,
+.weekly-card > p,
+.gear-card > p,
+.settings-card > p,
+.settings-toggle > span small,
+.font-scale-setting > span small {
+ color: var(--lm-dim);
+}
+
+.actor-tabs button,
+.fight-facts span,
+.actor-drilldown {
+ border-color: rgba(var(--lm-line-rgb), .34);
+ background: var(--lm-inset-bg);
+}
+
+.actor-tabs button.selected {
+ color: var(--lm-text-strong);
+ border-color: rgba(var(--lm-primary-rgb), .7);
+ background: var(--lm-selected-bg);
+ box-shadow: inset 0 1px rgba(var(--lm-text-rgb), .05), 0 0 13px rgba(var(--lm-primary-rgb), .09);
+}
+
+.actor-drilldown {
+ background: var(--lm-card-raised-bg);
+}
+
+.stance-advisor {
+ border-color: rgba(var(--lm-primary-rgb), .34);
+ border-left-color: var(--lm-primary);
+ background: rgba(var(--lm-primary-rgb), .1);
+}
+
+.stance-advisor.defense {
+ border-color: rgba(var(--lm-danger-rgb), .34);
+ border-left-color: var(--lm-danger);
+ background: rgba(var(--lm-danger-rgb), .1);
+}
+
+.stance-advisor.defense small {
+ color: var(--lm-danger);
+}
+
+.stance-advisor-enable {
+ color: var(--lm-primary);
+ border-color: rgba(var(--lm-primary-rgb), .36);
+ background: rgba(var(--lm-primary-rgb), .09);
+}
+
+.stance-advisor-enable:hover {
+ background: rgba(var(--lm-primary-rgb), .17);
+}
+
+/* Raid reset, gear planning and passive controls. */
+.weekly-card {
+ border-color: rgba(var(--lm-primary-rgb), .36);
+}
+
+.weekly-card summary > span,
+.gear-card summary::after,
+.weekly-card summary::after {
+ color: var(--lm-primary);
+}
+
+.weekly-card summary > span.tier-needed,
+.weekly-card .raid-pending {
+ color: var(--lm-warning);
+}
+
+.weekly-card .raid-pending {
+ border-left-color: var(--lm-warning);
+ background: rgba(var(--lm-warning-rgb), .08);
+}
+
+.raid-grid-head,
+.raid-grid-row > span > small,
+.weekly-card .raid-reset,
+.gear-credit span {
+ color: var(--lm-dim);
+}
+
+.raid-grid-head b {
+ color: var(--lm-primary);
+}
+
+.raid-grid-row button {
+ color: var(--lm-muted);
+ border-color: rgba(var(--lm-line-rgb), .42);
+ background: var(--lm-inset-bg);
+}
+
+.raid-grid-row button:hover {
+ color: var(--lm-primary);
+ border-color: var(--lm-primary);
+}
+
+.raid-grid-row button.done {
+ color: var(--lm-success);
+ border-color: rgba(var(--lm-success-rgb), .55);
+ background: rgba(var(--lm-success-rgb), .12);
+ box-shadow: inset 0 0 8px rgba(var(--lm-success-rgb), .09);
+}
+
+.gear-card {
+ border-color: rgba(var(--lm-secondary-rgb), .34);
+}
+
+.gear-card.error {
+ border-color: rgba(var(--lm-danger-rgb), .5);
+}
+
+.bag-upgrades button {
+ color: var(--lm-success);
+ border-color: rgba(var(--lm-success-rgb), .42);
+ background: rgba(var(--lm-success-rgb), .1);
+}
+
+.farm-routes article {
+ border-color: rgba(var(--lm-line-rgb), .38);
+ background: var(--lm-inset-bg);
+}
+
+.farm-routes article > b {
+ color: var(--lm-void);
+ background: var(--lm-primary);
+}
+
+.alerts-rail {
+ border-color: rgba(var(--lm-line-rgb), .42);
+ background: var(--lm-card-bg);
+}
+
+.alerts-rail button.active {
+ color: var(--lm-success);
+ border-color: rgba(var(--lm-success-rgb), .46);
+ background: rgba(var(--lm-success-rgb), .1);
+}
+
+.health-line,
+.gear-credit,
+.engine-status,
+.settings-card .source-credit {
+ border-color: rgba(var(--lm-line-rgb), .32);
+}
+
+/* Settings and theme chooser. */
+.path-row input,
+.settings-card > input,
+.number-setting input,
+.archive-search input,
+.archive-search select,
+.archive-table-tools input,
+.archive-table-tools select {
+ color: var(--lm-text);
+ border-color: rgba(var(--lm-line-rgb), .64);
+ background: var(--lm-inset-bg);
+ caret-color: var(--lm-primary);
+}
+
+:is(.path-row input, .settings-card > input, .number-setting input, .archive-search input, .archive-search select, .archive-table-tools input, .archive-table-tools select):focus {
+ border-color: var(--lm-primary);
+ box-shadow: 0 0 0 2px rgba(var(--lm-primary-rgb), .12);
+}
+
+.difficulty-picker button.selected,
+.settings-card.update-card .update-ready {
+ color: var(--lm-void);
+ border-color: var(--lm-success);
+ background: var(--lm-success);
+ box-shadow: 0 0 12px rgba(var(--lm-success-rgb), .18);
+}
+
+.difficulty-picker button.unset.selected {
+ color: var(--lm-void);
+ border-color: var(--lm-warning);
+ background: var(--lm-warning);
+}
+
+.sound-profile { border-color: rgba(var(--lm-line-rgb), .25); }
+.sound-profile > span b { color: var(--lm-text); }
+.sound-profile > span small,
+.sound-note { color: var(--lm-dim); }
+.sound-preset-trigger,
+.sound-preset-menu {
+ color: var(--lm-text);
+ border-color: rgba(var(--lm-line-rgb), .52);
+ background: var(--lm-inset-bg);
+}
+.sound-preset-trigger:hover,
+.sound-preset-trigger.open,
+.sound-preset-trigger:focus-visible {
+ border-color: var(--lm-line-bright);
+ box-shadow: 0 0 0 2px rgba(var(--lm-primary-rgb), .12);
+}
+.sound-preset-menu button { color: var(--lm-dim); }
+.sound-preset-menu button:hover,
+.sound-preset-menu button:focus-visible { color: var(--lm-text); border-color: var(--lm-line); background: rgba(var(--lm-primary-rgb), .07); }
+.sound-preset-menu button.selected { color: var(--lm-primary); border-color: rgba(var(--lm-primary-rgb), .54); background: rgba(var(--lm-primary-rgb), .11); }
+.sound-profile .sound-preview { color: var(--lm-primary); }
+.sound-profile .sound-file { color: var(--lm-secondary); }
+
+.settings-toggle > i {
+ border-color: var(--lm-line);
+ background: var(--lm-void);
+}
+
+.settings-toggle > i::after {
+ background: var(--lm-dim);
+}
+
+.settings-toggle input:checked + i {
+ border-color: rgba(var(--lm-success-rgb), .72);
+ background: rgba(var(--lm-success-rgb), .22);
+}
+
+.settings-toggle input:checked + i::after {
+ background: var(--lm-success);
+ box-shadow: 0 0 7px rgba(var(--lm-success-rgb), .58);
+}
+
+.settings-toggle input:focus-visible + i {
+ outline-color: var(--lm-line-bright);
+}
+
+.anchor-picker button.selected {
+ color: var(--lm-primary);
+ border-color: var(--lm-primary);
+ background: rgba(var(--lm-primary-rgb), .13);
+}
+
+.save-preferences {
+ color: var(--lm-success) !important;
+ border-color: rgba(var(--lm-success-rgb), .52) !important;
+}
+
+.theme-picker {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.theme-option {
+ position: relative;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 54px minmax(0, 1fr);
+ align-items: center;
+ gap: 10px;
+ min-height: 76px;
+ padding: 9px !important;
+ color: var(--lm-text) !important;
+ border: 1px solid rgba(var(--lm-line-rgb), .46) !important;
+ border-radius: 10px !important;
+ background: var(--lm-inset-bg) !important;
+ text-align: left;
+}
+
+.theme-option:hover,
+.theme-option[aria-checked="true"],
+.theme-option[aria-pressed="true"],
+.theme-option.selected {
+ color: var(--lm-text-strong) !important;
+ border-color: rgba(var(--lm-line-bright-rgb), .86) !important;
+ background: var(--lm-selected-bg) !important;
+ box-shadow: inset 0 1px rgba(var(--lm-text-rgb), .06), 0 0 16px rgba(var(--lm-primary-rgb), .09);
+}
+
+.theme-option > span:not(.theme-preview) {
+ min-width: 0;
+ display: grid;
+ gap: 3px;
+}
+
+.theme-option b {
+ overflow: hidden;
+ color: var(--lm-heading);
+ font: 700 9px Georgia, serif;
+ letter-spacing: .08em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.theme-option small {
+ color: var(--lm-dim);
+ font: 400 8px/1.3 "Segoe UI Variable Text", "Segoe UI", sans-serif;
+}
+
+.theme-option em {
+ grid-column: 2;
+ color: var(--lm-success);
+ font: normal 7px "Segoe UI Variable Text", "Segoe UI", sans-serif;
+ letter-spacing: .11em;
+}
+
+.theme-preview {
+ width: 54px;
+ height: 48px;
+ display: grid;
+ grid-template-rows: 9px 1fr 7px;
+ gap: 4px;
+ padding: 5px;
+ overflow: hidden;
+ border-radius: 8px;
+ box-shadow: inset 0 1px rgba(255, 255, 255, .08), 0 6px 14px rgba(0, 0, 0, .32);
+}
+
+.theme-preview > i {
+ display: block;
+ border-radius: 3px;
+}
+
+.theme-preview > i:nth-child(2) {
+ width: 75%;
+}
+
+.theme-preview.vellum,
+.theme-option.vellum .theme-preview,
+.theme-option[data-theme-option="vellum"] .theme-preview {
+ border: 1px solid #a68252;
+ background: linear-gradient(145deg, #1e160e, #090704);
+}
+
+.theme-preview.vellum > i,
+.theme-option.vellum .theme-preview > i,
+.theme-option[data-theme-option="vellum"] .theme-preview > i {
+ background: #d0a254;
+}
+
+.theme-preview.vellum > i:nth-child(2),
+.theme-option.vellum .theme-preview > i:nth-child(2),
+.theme-option[data-theme-option="vellum"] .theme-preview > i:nth-child(2) {
+ background: #7eaaf4;
+}
+
+.theme-preview.glass,
+.theme-option.glass .theme-preview,
+.theme-option[data-theme-option="glass"] .theme-preview {
+ border: 1px solid #99effa;
+ background: linear-gradient(145deg, #0a1b28, #02060b);
+}
+
+.theme-preview.glass > i,
+.theme-option.glass .theme-preview > i,
+.theme-option[data-theme-option="glass"] .theme-preview > i {
+ background: #69e1f2;
+}
+
+.theme-preview.glass > i:nth-child(2),
+.theme-option.glass .theme-preview > i:nth-child(2),
+.theme-option[data-theme-option="glass"] .theme-preview > i:nth-child(2) {
+ background: #ab80ff;
+}
+
+/* Alert window: urgency remains semantic in both themes. */
+.alert-surface {
+ color: var(--lm-text-strong);
+ border-color: rgba(var(--lm-danger-rgb), .84);
+ background: linear-gradient(105deg, rgba(var(--lm-danger-rgb), .28), rgba(5, 7, 10, .985));
+ box-shadow: inset 0 1px rgba(var(--lm-text-rgb), .08), 0 12px 35px rgba(0, 0, 0, .52), 0 0 22px rgba(var(--lm-danger-rgb), .17);
+}
+
+.alert-surface.warn {
+ border-color: rgba(var(--lm-warning-rgb), .86);
+ background: linear-gradient(105deg, rgba(var(--lm-warning-rgb), .23), rgba(5, 7, 10, .985));
+ box-shadow: inset 0 1px rgba(var(--lm-text-rgb), .08), 0 12px 35px rgba(0, 0, 0, .52), 0 0 22px rgba(var(--lm-warning-rgb), .14);
+}
+
+.alert-surface.info {
+ border-color: rgba(var(--lm-primary-rgb), .82);
+ background: linear-gradient(105deg, rgba(var(--lm-primary-rgb), .2), rgba(5, 7, 10, .985));
+}
+
+.alert-glyph,
+.alert-surface small {
+ color: var(--lm-danger);
+}
+
+.alert-surface.warn .alert-glyph,
+.alert-surface.warn small {
+ color: var(--lm-warning);
+}
+
+.alert-surface.info .alert-glyph,
+.alert-surface.info small {
+ color: var(--lm-primary);
+}
+
+.alert-surface strong,
+.alert-surface.tell-alert p {
+ color: var(--lm-text-strong);
+}
+
+.alert-surface p {
+ color: var(--lm-text);
+}
+
+/* Full Combat Archive. */
+.archive-shell {
+ color: var(--lm-text);
+ border-color: rgba(var(--lm-line-bright-rgb), .68);
+ background: var(--lm-shell-bg);
+ box-shadow: var(--lm-shadow);
+}
+
+.archive-masthead {
+ border-bottom-color: rgba(var(--lm-line-rgb), .42);
+ background: var(--lm-header-bg);
+ box-shadow: 0 8px 28px rgba(0, 0, 0, .36), inset 0 1px rgba(var(--lm-text-rgb), .055);
+}
+
+.archive-masthead > img {
+ filter: drop-shadow(0 0 8px rgba(var(--lm-primary-rgb), .2));
+}
+
+.archive-masthead small,
+.archive-fights > header > span,
+.archive-kpis article.primary small,
+.archive-tabs button:hover,
+.archive-tabs button.active {
+ color: var(--lm-primary);
+}
+
+.archive-masthead button,
+.archive-filter-pills button,
+.archive-scope button {
+ color: var(--lm-dim);
+ border-color: rgba(var(--lm-line-rgb), .42);
+ background: var(--lm-inset-bg);
+}
+
+.archive-masthead button:hover,
+.archive-filter-pills button:hover,
+.archive-filter-pills button.active,
+.archive-scope button:hover,
+.archive-scope button.active {
+ color: var(--lm-text-strong);
+ border-color: rgba(var(--lm-primary-rgb), .68);
+ background: var(--lm-selected-bg);
+}
+
+.archive-fights {
+ border-right-color: rgba(var(--lm-line-rgb), .4);
+ background: linear-gradient(180deg, rgba(var(--lm-line-rgb), .1), var(--lm-inset-bg));
+}
+
+.archive-fights > header b,
+.archive-fight-list button > span b,
+.archive-report-head h2,
+.archive-kpis b,
+.archive-contribution header b,
+.archive-history header b,
+.archive-table > article > span b,
+.archive-insights header b,
+.archive-insights article b,
+.archive-timeline-tooltip strong {
+ color: var(--lm-text-strong);
+}
+
+.archive-fight-list > button {
+ color: var(--lm-text);
+ background: rgba(var(--lm-line-rgb), .08);
+}
+
+.archive-fight-list > button:hover {
+ border-color: rgba(var(--lm-line-rgb), .52);
+ background: rgba(var(--lm-line-rgb), .16);
+}
+
+.archive-fight-list > button.selected {
+ border-color: rgba(var(--lm-primary-rgb), .58);
+ background: var(--lm-selected-bg);
+ box-shadow: inset 0 1px rgba(var(--lm-text-rgb), .035), 0 0 14px rgba(var(--lm-primary-rgb), .07);
+}
+
+.archive-fight-list > button.selected::before {
+ background: var(--lm-primary);
+ box-shadow: 0 0 8px rgba(var(--lm-primary-rgb), .62);
+}
+
+.archive-fight-list > button.live::after {
+ color: var(--lm-success);
+}
+
+.archive-report {
+ background: rgba(var(--lm-text-rgb), .01);
+}
+
+.archive-kpis article,
+.archive-contribution,
+.archive-history,
+.archive-table-wrap,
+.archive-insights,
+.archive-timeline,
+.archive-timeline-tooltip {
+ border-color: rgba(var(--lm-line-rgb), .34);
+ background: var(--lm-card-bg);
+}
+
+.archive-kpis article.primary {
+ border-color: rgba(var(--lm-primary-rgb), .52);
+ background: var(--lm-selected-bg);
+}
+
+.archive-kpis article.primary b {
+ color: var(--lm-heading);
+}
+
+.archive-contribution header strong,
+.archive-history header strong,
+.archive-tabs button.active small,
+.archive-table > header button:hover,
+.archive-table > header button.active,
+.archive-insights header small,
+.archive-timeline header b,
+.archive-timeline-tooltip > b,
+.archive-empty-chart b {
+ color: var(--lm-primary);
+}
+
+.archive-share-track .self,
+.archive-contribution footer i.self {
+ background: var(--lm-secondary);
+}
+
+.archive-share-track .charmed,
+.archive-contribution footer i.charmed {
+ background: var(--lm-primary);
+}
+
+.archive-share-track .summoned,
+.archive-contribution footer i.summoned,
+.archive-timeline header i.heal,
+.archive-timeline-tooltip i.heal {
+ background: var(--lm-success);
+}
+
+.archive-history > div {
+ border-bottom-color: rgba(var(--lm-line-rgb), .4);
+}
+
+.archive-history > div button {
+ background: linear-gradient(180deg, rgba(var(--lm-primary-rgb), .92), rgba(var(--lm-secondary-rgb), .42));
+}
+
+.archive-tabs {
+ border-bottom-color: rgba(var(--lm-line-rgb), .4);
+}
+
+.archive-tabs button.active {
+ border-bottom-color: var(--lm-primary);
+ background: linear-gradient(0deg, rgba(var(--lm-primary-rgb), .09), transparent);
+}
+
+.archive-table-wrap {
+ background: var(--lm-inset-bg);
+}
+
+.archive-table-tools {
+ border-bottom-color: rgba(var(--lm-line-rgb), .34);
+ background: var(--lm-card-raised-bg);
+}
+
+.archive-table > header,
+.archive-table > article,
+.archive-insights article {
+ border-color: rgba(var(--lm-line-rgb), .22);
+}
+
+.archive-table > header button:hover,
+.archive-table > header button.active {
+ background: rgba(var(--lm-primary-rgb), .08);
+}
+
+.archive-table > article > i {
+ background: linear-gradient(90deg, rgba(var(--lm-secondary-rgb), .16), transparent 74%);
+}
+
+.archive-table > article > strong:first-of-type {
+ color: var(--lm-heading);
+}
+
+.archive-timeline header i.out,
+.archive-timeline-tooltip i.out {
+ background: var(--lm-primary);
+}
+
+.archive-timeline header i.inc,
+.archive-timeline-tooltip i.inc {
+ background: var(--lm-danger);
+}
+
+.archive-timeline polyline.outgoing {
+ stroke: var(--lm-primary);
+ filter: drop-shadow(0 0 3px rgba(var(--lm-primary-rgb), .22));
+}
+
+.archive-timeline polyline.incoming {
+ stroke: var(--lm-danger);
+}
+
+.archive-timeline polyline.healing {
+ stroke: var(--lm-success);
+}
+
+.archive-timeline line.grid {
+ stroke: rgba(var(--lm-line-rgb), .24);
+}
+
+.archive-timeline line.cursor {
+ stroke: rgba(var(--lm-line-bright-rgb), .9);
+}
+
+.archive-timeline-tooltip em {
+ color: var(--lm-danger);
+}
+
+.archive-empty-chart {
+ color: var(--lm-dim);
+ border-color: rgba(var(--lm-line-rgb), .42);
+}
+
+@media (max-width: 520px) {
+ .theme-picker {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .brand-cog,
+ .rune-seed,
+ .loremaster-shell,
+ .archive-shell,
+ .alert-surface {
+ scroll-behavior: auto;
+ }
+}
diff --git a/loremaster/desktop_worker.py b/loremaster/desktop_worker.py
index a1926de..62f558c 100644
--- a/loremaster/desktop_worker.py
+++ b/loremaster/desktop_worker.py
@@ -44,9 +44,6 @@
normalize_composition = runtime_module.normalize_composition
from lull_timer import LullTracker
from mez_timer import MezTracker
-from hover_ocr import HoverOcrService
-from instance_lockout_ocr import (
- ParsedRaidLockout, parse_instance_character, parse_instance_lockouts)
from weekly_tracker import DIFFICULTIES, WeeklyBossTracker
@@ -70,19 +67,6 @@ def __init__(self, *, log_path: str = "", data_dir: str | Path | None = None,
"LOREMASTER_APP_DATA_DIR", Path.cwd()))
self.weekly = WeeklyBossTracker(
storage_path=self.data_dir / "weekly_boss_kills.json")
- self.instance_lockout_path = self.data_dir / "alt_z_lockouts.json"
- self.instance_lockouts: list[dict] = []
- self.lockout_scan = {
- "status": "idle",
- "detail": "Open Alt+Z, point at Outstanding Instance Timers, then press Ctrl+Shift+Z.",
- "scannedAt": "",
- "importedCount": 0,
- "timedCount": 0,
- "hotkey": "Ctrl+Shift+Z",
- }
- self._lockout_request_id = 0
- self.lockout_ocr = HoverOcrService()
- self._load_instance_lockouts()
self.raid_difficulty: int | None = None
self.configured_composition = ""
self.pending_raid_target = ""
@@ -118,175 +102,6 @@ def __init__(self, *, log_path: str = "", data_dir: str | Path | None = None,
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.astimezone()
- @staticmethod
- def _parse_stamp(value: str) -> datetime | None:
- try:
- parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
- return parsed if parsed.tzinfo is not None else parsed.astimezone()
- except (TypeError, ValueError):
- return None
-
- def _load_instance_lockouts(self) -> None:
- try:
- payload = json.loads(self.instance_lockout_path.read_text(encoding="utf-8"))
- rows = payload.get("lockouts", []) if isinstance(payload, dict) else []
- self.instance_lockouts = [row for row in rows if (
- isinstance(row, dict)
- and isinstance(row.get("target"), str)
- and row.get("difficulty") in DIFFICULTIES
- and self._parse_stamp(str(row.get("expiresAt", ""))) is not None
- )]
- scan = payload.get("lastScan", {}) if isinstance(payload, dict) else {}
- if isinstance(scan, dict) and isinstance(scan.get("scannedAt"), str):
- self.lockout_scan.update({
- "status": "success" if self.instance_lockouts else "idle",
- "detail": str(scan.get("detail") or self.lockout_scan["detail"])[:240],
- "scannedAt": scan.get("scannedAt", ""),
- "importedCount": int(scan.get("importedCount", 0) or 0),
- "timedCount": int(scan.get("timedCount", 0) or 0),
- })
- except (OSError, ValueError, TypeError, json.JSONDecodeError):
- self.instance_lockouts = []
-
- def _save_instance_lockouts(self) -> None:
- self.instance_lockout_path.parent.mkdir(parents=True, exist_ok=True)
- temporary = self.instance_lockout_path.with_suffix(".json.tmp")
- temporary.write_text(json.dumps({
- "schemaVersion": 1,
- "lockouts": self.instance_lockouts,
- "lastScan": self.lockout_scan,
- }, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
- os.replace(temporary, self.instance_lockout_path)
-
- def request_instance_lockout_scan(self) -> None:
- self.lockout_scan.update({
- "status": "scanning",
- "detail": "Reading the visible Alt+Z Outstanding Instance Timers…",
- "importedCount": 0,
- "timedCount": 0,
- })
- self._lockout_request_id = self.lockout_ocr.submit()
-
- def import_instance_lockouts(self, rows: list[ParsedRaidLockout], *,
- scanned_at: datetime | None = None,
- character_hint: str = "") -> int:
- observed_at = self._aware(scanned_at or datetime.now())
- character = (character_hint or self.stats.character or "?").strip() or "?"
- by_key = {
- (str(row.get("character", "?")).casefold(),
- str(row.get("target", "")).casefold(), row.get("difficulty")): row
- for row in self.instance_lockouts
- }
- timed_count = 0
- for lockout in rows:
- self.weekly.set_completion(
- observed_at, lockout.target, lockout.difficulty,
- character=character, completed=True)
- # A recognized boss/difficulty is sufficient weekly evidence, but
- # never fabricate an expiry when the tiny timer glyphs were not
- # actually read by Windows OCR.
- if lockout.remaining_seconds is None:
- continue
- if character != "?":
- by_key.pop(("?", lockout.target.casefold(), lockout.difficulty), None)
- by_key.pop(("", lockout.target.casefold(), lockout.difficulty), None)
- expires_at = observed_at + timedelta(seconds=lockout.remaining_seconds)
- stored = {
- "target": lockout.target,
- "difficulty": lockout.difficulty,
- "instanceName": lockout.instance_name,
- "eventName": lockout.event_name,
- "character": character,
- "scannedAt": observed_at.isoformat(timespec="seconds"),
- "expiresAt": expires_at.isoformat(timespec="seconds"),
- }
- by_key[(character.casefold(), lockout.target.casefold(),
- lockout.difficulty)] = stored
- timed_count += 1
- self.instance_lockouts = list(by_key.values())
- stamp = observed_at.isoformat(timespec="seconds")
- count = len(rows)
- if timed_count == count:
- detail = (f"Marked {count} visible raid completion"
- f"{'s' if count != 1 else ''} and read every timer. ")
- elif timed_count:
- detail = (f"Marked {count} visible raid completion"
- f"{'s' if count != 1 else ''}; read {timed_count} timer"
- f"{'s' if timed_count != 1 else ''}. ")
- else:
- detail = (f"Marked {count} visible raid completion"
- f"{'s' if count != 1 else ''}. Compact timer text was unreadable, "
- "so no expiry was guessed. ")
- self.lockout_scan.update({
- "status": "success",
- "detail": detail + "Scroll Alt+Z and scan again to merge more rows.",
- "scannedAt": stamp,
- "importedCount": count,
- "timedCount": timed_count,
- })
- self._save_instance_lockouts()
- if self.alert_config.get("alerts_enabled", True):
- self.alerts.append({
- "id": f"lockoutSync-{observed_at.timestamp():.3f}",
- "kind": "lockoutSync",
- "severity": "info",
- "title": "LOCKOUTS SYNCED",
- "target": f"{count} visible D0–D4 raid completion{'s' if count != 1 else ''}",
- "occurredAt": stamp,
- "expiresAt": (observed_at + timedelta(
- seconds=self.alert_config["alert_seconds"])).isoformat(
- timespec="milliseconds"),
- })
- return count
-
- def _poll_instance_lockout_scan(self) -> None:
- for result in self.lockout_ocr.poll():
- if result.request_id != self._lockout_request_id:
- continue
- if result.error:
- self.lockout_scan.update({
- "status": "error",
- "detail": result.error,
- "importedCount": 0,
- })
- continue
- rows = parse_instance_lockouts(result.lines)
- if not rows:
- self.lockout_scan.update({
- "status": "error",
- "detail": ("No D0–D4 raid rows were recognized. Keep EverQuest focused, "
- "point inside the timer table, and scan the visible rows again."),
- "importedCount": 0,
- })
- continue
- self.import_instance_lockouts(
- rows, character_hint=parse_instance_character(result.lines))
-
- def _instance_lockout_snapshot(self, now: datetime) -> list[dict]:
- observed_at = self._aware(now)
- character = (self.stats.character or "?").strip().casefold()
- visible = []
- for row in self.instance_lockouts:
- expires_at = self._parse_stamp(str(row.get("expiresAt", "")))
- if expires_at is None:
- continue
- remaining = max(0, int((expires_at - observed_at).total_seconds()))
- row_character = str(row.get("character", "?")).strip().casefold()
- if remaining <= 0 or (
- character not in ("", "?")
- and row_character not in ("", "?", character)):
- continue
- visible.append({
- "target": row.get("target", ""),
- "difficulty": row.get("difficulty", 0),
- "remainingSeconds": remaining,
- "instanceName": row.get("instanceName", ""),
- "eventName": row.get("eventName", ""),
- "expiresAt": row.get("expiresAt", ""),
- })
- return sorted(visible, key=lambda row: (
- int(row["remainingSeconds"]), str(row["target"])))
-
def set_log_path(self, value: str) -> None:
old = getattr(self, "watcher", None)
if old is not None:
@@ -452,7 +267,6 @@ def process_line(self, line: str) -> bool:
return True
def poll(self) -> tuple[int, bool]:
- self._poll_instance_lockout_scan()
lines, switched = self.watcher.poll()
if switched:
self._switch_character()
@@ -503,8 +317,6 @@ def snapshot_event(self, now: datetime | None = None) -> dict:
character="" if weekly_character in ("", "?") else weekly_character)
weekly["activeDifficulty"] = self.raid_difficulty
weekly["pendingRaidTarget"] = self.pending_raid_target
- weekly["altZLockouts"] = self._instance_lockout_snapshot(observed_at)
- weekly["altZScan"] = dict(self.lockout_scan)
event["snapshot"]["weekly"] = weekly
self.alerts = [alert for alert in self.alerts
if self._aware(datetime.fromisoformat(alert["expiresAt"]))
@@ -513,7 +325,6 @@ def snapshot_event(self, now: datetime | None = None) -> dict:
return event
def close(self) -> None:
- self.lockout_ocr.close()
self.watcher.close()
@@ -566,8 +377,6 @@ def _handle(self, command: dict) -> None:
str(command.get("target") or ""),
int(command.get("difficulty", -1)),
bool(command.get("completed")))
- elif kind == "engine.scan-alt-z-lockouts":
- self.engine.request_instance_lockout_scan()
elif kind == "engine.reset":
self.engine.reset()
elif kind == "engine.shutdown":
diff --git a/loremaster/instance_lockout_ocr.py b/loremaster/instance_lockout_ocr.py
deleted file mode 100644
index 5a4bbf8..0000000
--- a/loremaster/instance_lockout_ocr.py
+++ /dev/null
@@ -1,233 +0,0 @@
-"""Conservative OCR parsing for EverQuest's Alt+Z lockout table.
-
-The game does not write Outstanding Instance Timers to the text log. This
-module turns a frozen, user-requested screen capture into reviewable D0-D4 raid
-lockouts without reading process memory or injecting into EverQuest.
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from difflib import SequenceMatcher
-import re
-from typing import Iterable
-
-from hover_ocr import OcrLine
-from weekly_tracker import RAID_TARGETS, normalize_target
-
-
-_TIMER_RE = re.compile(
- r"(?P[0-9Oo]{1,3})\s*d\s*[-:;.,|]?\s*"
- r"(?P[0-9Oo]{1,2})\s*h\s*[-:;.,|]?\s*"
- r"(?P[0-9Oo]{1,2})\s*m\s*[-:;.,|]?\s*"
- r"(?P[0-9Oo]{1,2})\s*s\b", re.I)
-_DIFFICULTY_RE = re.compile(r"\b(?:solo|group)\s*([0-4])\b", re.I)
-_VARIANT_RE = re.compile(
- r"^[\s([{<]*(?:adaptive|normal|fused|refined)[\s)\]}>:;-]*", re.I)
-_OCR_EVENT_ALIASES = {
- "cauc i nule": "Cazic-Thule",
- "cauc l nule": "Cazic-Thule",
- "cazic tnule": "Cazic-Thule",
-}
-
-
-@dataclass(frozen=True, slots=True)
-class ParsedRaidLockout:
- target: str
- difficulty: int
- remaining_seconds: int | None
- instance_name: str
- event_name: str
- raw_text: str
-
-
-def _number(value: str) -> int:
- return int(value.replace("O", "0").replace("o", "0"))
-
-
-def _row_groups(lines: Iterable[OcrLine]) -> list[list[OcrLine]]:
- useful = [line for line in lines if line.text.strip()]
- useful.sort(key=lambda line: (line.y + line.height / 2.0, line.x))
- groups: list[list[OcrLine]] = []
- centers: list[float] = []
- for line in useful:
- center = line.y + line.height / 2.0
- if groups:
- tolerance = max(7.0, line.height * 0.72)
- if abs(center - centers[-1]) <= tolerance:
- groups[-1].append(line)
- count = len(groups[-1])
- centers[-1] = ((centers[-1] * (count - 1)) + center) / count
- continue
- groups.append([line])
- centers.append(center)
- return groups
-
-
-def _ocr_name(value: str) -> str:
- value = value.replace("|", " ").replace("—", "-").replace("–", "-")
- return re.sub(r"[^a-z0-9]+", " ", value.casefold()).strip()
-
-
-def _target_from_event(value: str) -> str:
- candidate = _ocr_name(value)
- if not candidate:
- return ""
- # The compact red Cazic-Thule row in Legends is consistently mangled by
- # Windows OCR even at high capture scale. Keep this deliberately narrow:
- # a broad fuzzy threshold would risk crediting the wrong weekly boss.
- if candidate in _OCR_EVENT_ALIASES:
- return _OCR_EVENT_ALIASES[candidate]
- direct: dict[str, str] = {}
- for target in RAID_TARGETS:
- direct[_ocr_name(target.name)] = target.name
- for alias in target.aliases:
- direct[_ocr_name(alias)] = target.name
- if candidate in direct:
- return direct[candidate]
-
- # A merged OCR line may retain the variant before the event. Prefer an
- # explicit canonical name contained in that suffix before fuzzy matching.
- for target in sorted(RAID_TARGETS, key=lambda item: len(item.name), reverse=True):
- canonical = _ocr_name(target.name)
- if re.search(rf"(?:^|\s){re.escape(canonical)}(?:$|\s)", candidate):
- return target.name
-
- best_name = ""
- best_ratio = 0.0
- for target in RAID_TARGETS:
- canonical = _ocr_name(target.name)
- ratio = SequenceMatcher(None, candidate, canonical).ratio()
- if ratio > best_ratio:
- best_name, best_ratio = target.name, ratio
- return best_name if best_ratio >= 0.84 else ""
-
-
-def _parse_row(parts: list[OcrLine]) -> ParsedRaidLockout | None:
- ordered = sorted(parts, key=lambda line: line.x)
- raw = " ".join(line.text.strip() for line in ordered if line.text.strip())
- raw = re.sub(r"\s+", " ", raw).strip()
- timer = _TIMER_RE.search(raw)
- difficulty = _DIFFICULTY_RE.search(raw)
- if difficulty is None:
- return None
-
- tail = raw[difficulty.end():].strip()
- if tail.startswith("(") and ")" in tail:
- tail = tail.split(")", 1)[1].strip()
- else:
- tail = _VARIANT_RE.sub("", tail).strip()
- target = _target_from_event(tail)
-
- # Windows OCR sometimes emits each table cell separately. The rightmost
- # cell is authoritative when the combined suffix is imperfect.
- if not target:
- for part in reversed(ordered):
- target = _target_from_event(part.text)
- if target:
- tail = part.text.strip()
- break
- if not target:
- return None
-
- prefix_start = timer.end() if timer is not None else 0
- prefix = raw[prefix_start:difficulty.start()].strip(" -|:")
- instance_name = re.sub(r"\s*-\s*$", "", prefix).strip()
- remaining = None if timer is None else (
- _number(timer.group("days")) * 86400
- + _number(timer.group("hours")) * 3600
- + _number(timer.group("minutes")) * 60
- + _number(timer.group("seconds"))
- )
- return ParsedRaidLockout(
- target=target,
- difficulty=int(difficulty.group(1)),
- remaining_seconds=remaining,
- instance_name=instance_name,
- event_name=tail,
- raw_text=raw,
- )
-
-
-def parse_instance_lockouts(lines: Iterable[OcrLine]) -> list[ParsedRaidLockout]:
- """Return recognized raid rows, deduplicated by boss and difficulty."""
-
- groups = _row_groups(lines)
- timer_lines = [
- line for parts in groups for line in parts
- if _TIMER_RE.search(line.text)
- ]
- found: dict[tuple[str, int], ParsedRaidLockout] = {}
- for parts in groups:
- raw = " ".join(line.text.strip() for line in parts)
- if (_TIMER_RE.search(raw) is None
- and _DIFFICULTY_RE.search(raw) is not None
- and any(_target_from_event(line.text) for line in parts)
- and timer_lines):
- center = sum(
- line.y + line.height / 2.0 for line in parts) / len(parts)
- nearest = min(
- timer_lines,
- key=lambda line: abs(center - (line.y + line.height / 2.0)))
- distance = abs(center - (nearest.y + nearest.height / 2.0))
- # Legends rows are approximately 15 physical pixels apart in the
- # compact table. Recover only an immediately adjacent timer cell;
- # wider gaps could cross into a different lockout duration.
- if distance <= 18.0:
- parts = [OcrLine(
- nearest.text,
- min(line.x for line in parts) - max(1.0, nearest.width),
- min(line.y for line in parts),
- nearest.width,
- nearest.height,
- ), *parts]
- lockout = _parse_row(parts)
- if lockout is None:
- continue
- key = (normalize_target(lockout.target), lockout.difficulty)
- previous = found.get(key)
- should_replace = previous is None or (
- lockout.remaining_seconds is not None
- and (previous.remaining_seconds is None
- or lockout.remaining_seconds > previous.remaining_seconds)
- )
- if should_replace:
- found[key] = lockout
- return sorted(found.values(), key=lambda row: (row.target, row.difficulty))
-
-
-def parse_instance_character(lines: Iterable[OcrLine]) -> str:
- """Read the optional ``Leader: Name`` evidence shown in Alt+Z."""
-
- excluded = {"option", "options", "targeted"}
- for parts in _row_groups(lines):
- ordered = sorted(parts, key=lambda item: item.x)
- for index, part in enumerate(ordered):
- text = part.text.strip()
- # Handle one OCR line containing both label and value.
- match = re.fullmatch(
- r"Leader\s*(?:[:;]\s*|\s+)([A-Za-z][A-Za-z'-]{1,31})",
- text, re.I)
- if match and match.group(1).casefold() not in excluded:
- return match.group(1)
-
- # If OCR split the cells, require the value to be physically next
- # to Leader. This prevents blank Leader fields from borrowing
- # distant labels such as Min Players or the bottom Invite button.
- if not re.fullmatch(r"Leader\s*[:;]?", text, re.I):
- continue
- if index + 1 >= len(ordered):
- continue
- candidate = ordered[index + 1]
- gap = candidate.x - (part.x + part.width)
- value = candidate.text.strip()
- if (gap <= 120.0
- and re.fullmatch(r"[A-Za-z][A-Za-z'-]{1,31}", value)
- and value.casefold() not in excluded):
- return value
- return ""
-
-
-__all__ = [
- "ParsedRaidLockout", "parse_instance_character", "parse_instance_lockouts",
-]
diff --git a/loremaster/tests/test_desktop_worker.py b/loremaster/tests/test_desktop_worker.py
index b7855d5..2ce0f52 100644
--- a/loremaster/tests/test_desktop_worker.py
+++ b/loremaster/tests/test_desktop_worker.py
@@ -9,7 +9,6 @@
sys.path.insert(0, str(LOREMASTER_DIR))
from desktop_worker import HeadlessEngine # noqa: E402
-from instance_lockout_ocr import ParsedRaidLockout # noqa: E402
class DesktopWorkerTests(unittest.TestCase):
@@ -69,64 +68,6 @@ def test_manual_composition_setting_is_validated_and_applied(self):
self.assertEqual(event["snapshot"]["character"]["composition"],
"PAL / MNK / ENC")
- def test_alt_z_lockouts_credit_weekly_ledger_and_survive_restart(self):
- scanned_at = datetime(2026, 8, 7, 20, 0, 0)
- with tempfile.TemporaryDirectory() as root:
- engine = HeadlessEngine(data_dir=root)
- try:
- engine.stats.character = "Spin"
- count = engine.import_instance_lockouts([
- ParsedRaidLockout(
- target="Lord Nagafen", difficulty=3,
- remaining_seconds=2 * 86400,
- instance_name="Nagafen's Lair - Group 3 (Fused)",
- event_name="Lord Nagafen", raw_text="fixture"),
- ], scanned_at=scanned_at)
- event = engine.snapshot_event(datetime(2026, 8, 7, 21, 0, 0))
- finally:
- engine.close()
- self.assertEqual(count, 1)
- weekly = event["snapshot"]["weekly"]
- nagafen = next(row for row in weekly["raids"]
- if row["target"] == "Lord Nagafen")
- self.assertTrue(nagafen["difficulties"][3])
- self.assertEqual(weekly["altZLockouts"][0]["remainingSeconds"], 47 * 3600)
-
- restored = HeadlessEngine(data_dir=root)
- try:
- restored.stats.character = "Spin"
- snapshot = restored.snapshot_event(datetime(2026, 8, 7, 22, 0, 0))
- finally:
- restored.close()
- self.assertEqual(
- snapshot["snapshot"]["weekly"]["altZLockouts"][0]["target"],
- "Lord Nagafen")
-
- def test_alt_z_timerless_row_checks_weekly_without_inventing_expiry(self):
- scanned_at = datetime(2026, 8, 7, 20, 0, 0)
- with tempfile.TemporaryDirectory() as root:
- engine = HeadlessEngine(data_dir=root)
- try:
- engine.stats.character = "Spin"
- count = engine.import_instance_lockouts([
- ParsedRaidLockout(
- target="Cazic-Thule", difficulty=1,
- remaining_seconds=None,
- instance_name="The Plane of Fear",
- event_name="cauc- I nule", raw_text="fixture"),
- ], scanned_at=scanned_at)
- event = engine.snapshot_event(datetime(2026, 8, 7, 21, 0, 0))
- finally:
- engine.close()
- self.assertEqual(count, 1)
- weekly = event["snapshot"]["weekly"]
- cazic = next(row for row in weekly["raids"]
- if row["target"] == "Cazic-Thule")
- self.assertTrue(cazic["difficulties"][1])
- self.assertEqual(weekly["altZLockouts"], [])
- self.assertEqual(weekly["altZScan"]["timedCount"], 0)
- self.assertIn("no expiry was guessed", weekly["altZScan"]["detail"])
-
def test_auto_attack_state_crosses_desktop_boundary_exactly(self):
with tempfile.TemporaryDirectory() as root:
engine = HeadlessEngine(data_dir=root)
@@ -165,6 +106,8 @@ def test_live_snapshot_preserves_damage_and_control_parity(self):
self.assertEqual(event["snapshot"]["controls"][0]["kind"], "mez")
weekly = event["snapshot"]["weekly"]
self.assertEqual(weekly["completedCount"], 1)
+ self.assertNotIn("altZLockouts", weekly)
+ self.assertNotIn("altZScan", weekly)
nagafen = next(row for row in weekly["raids"]
if row["target"] == "Lord Nagafen")
self.assertTrue(nagafen["difficulties"][2])
diff --git a/loremaster/tests/test_instance_lockout_ocr.py b/loremaster/tests/test_instance_lockout_ocr.py
deleted file mode 100644
index 5f00fec..0000000
--- a/loremaster/tests/test_instance_lockout_ocr.py
+++ /dev/null
@@ -1,125 +0,0 @@
-import sys
-import unittest
-from pathlib import Path
-
-
-LOREMASTER_DIR = Path(__file__).resolve().parents[1]
-sys.path.insert(0, str(LOREMASTER_DIR))
-
-from hover_ocr import OcrLine # noqa: E402
-from instance_lockout_ocr import ( # noqa: E402
- parse_instance_character, parse_instance_lockouts)
-
-
-class InstanceLockoutOcrTests(unittest.TestCase):
- def test_reads_character_from_instance_leader_without_confusing_options(self):
- lines = [
- OcrLine("Leader: Spin", 20, 10, 100, 14),
- OcrLine("Leader Options:", 20, 300, 110, 14),
- OcrLine("Player Targeted:", 20, 320, 120, 14),
- ]
- self.assertEqual(parse_instance_character(lines), "Spin")
-
- def test_reads_character_when_windows_ocr_drops_leader_colon(self):
- lines = [
- OcrLine("Leader", 20, 10, 50, 14),
- OcrLine("Spin", 90, 10, 45, 14),
- OcrLine("Leader Options:", 20, 300, 110, 14),
- ]
- self.assertEqual(parse_instance_character(lines), "Spin")
-
- def test_blank_leader_does_not_borrow_distant_interface_text(self):
- lines = [
- OcrLine("Leader", 35, 56, 42, 12),
- OcrLine("Min Players: 1", 402, 56, 92, 12),
- OcrLine("Invite", 36, 727, 48, 12),
- OcrLine("Raid", 292, 727, 35, 12),
- ]
- self.assertEqual(parse_instance_character(lines), "")
-
- def test_parses_only_tracked_difficulty_lockouts_from_merged_rows(self):
- lines = [
- OcrLine("Lockout Time Instance Name Event Name", 20, 10, 520, 14),
- OcrLine("0d:18h:54m:19s The Plane of Fear - Solo 2 (Adaptive) Replay Timer", 20, 40, 520, 14),
- OcrLine("2d:17h:54m:19s Nagafen's Lair - Group 3 (Fused) Magus Rokyl", 20, 60, 520, 14),
- OcrLine("2d:17h:54m:19s Nagafen's Lair - Group 3 (Fused) Lord Nagafen", 20, 80, 520, 14),
- OcrLine("2d:17h:54m:19s The Plane of Hate - Solo 4 (Refined) Innoruuk", 20, 100, 520, 14),
- OcrLine("2d:17h:54m:19s The Plane of Fear - Group 0 (Normal) Cazic-Thule", 20, 120, 520, 14),
- ]
- rows = parse_instance_lockouts(lines)
- self.assertEqual(
- [(row.target, row.difficulty) for row in rows],
- [("Cazic-Thule", 0), ("Innoruuk", 4), ("Lord Nagafen", 3)],
- )
- self.assertEqual(rows[-1].remaining_seconds, 2 * 86400 + 17 * 3600 + 54 * 60 + 19)
-
- def test_joins_separate_table_cells_and_tolerates_ocr_zeroes(self):
- lines = [
- OcrLine("Od:18h:O4m:19s", 10, 50, 100, 13),
- OcrLine("Permafrost Keep - Group 2 (Adaptive)", 130, 51, 250, 13),
- OcrLine("Lady Vox", 405, 50, 90, 13),
- ]
- rows = parse_instance_lockouts(lines)
- self.assertEqual(len(rows), 1)
- self.assertEqual(rows[0].target, "Lady Vox")
- self.assertEqual(rows[0].difficulty, 2)
- self.assertEqual(rows[0].remaining_seconds, 18 * 3600 + 4 * 60 + 19)
-
- def test_accepts_hyphenated_timer_separator_from_compact_red_text(self):
- rows = parse_instance_lockouts([
- OcrLine("2d:15h-41 m:44s", 29, 306, 89, 10),
- OcrLine("Nagafen's Lair - Group 0 (Normal)", 130, 306, 185, 12),
- OcrLine("Lord Nagafen", 392, 306, 74, 12),
- ])
- self.assertEqual([(row.target, row.difficulty) for row in rows],
- [("Lord Nagafen", 0)])
- self.assertEqual(rows[0].remaining_seconds,
- 2 * 86400 + 15 * 3600 + 41 * 60 + 44)
-
- def test_recovers_one_missing_timer_cell_from_immediately_adjacent_row(self):
- lines = [
- OcrLine("2d:15h-41 m:44s", 29, 306, 89, 10),
- OcrLine("Nagafen's Lair - Group 0 (Normal)", 130, 306, 185, 12),
- OcrLine("Lord Nagafen", 392, 306, 74, 12),
- OcrLine("The Permafrost Caverns - Solo 3 (Fused)", 130, 321, 195, 12),
- OcrLine("Lady Vox", 405, 321, 48, 12),
- ]
- rows = parse_instance_lockouts(lines)
- self.assertEqual(
- [(row.target, row.difficulty) for row in rows],
- [("Lady Vox", 3), ("Lord Nagafen", 0)],
- )
- self.assertTrue(all(
- row.remaining_seconds == 2 * 86400 + 15 * 3600 + 41 * 60 + 44
- for row in rows))
-
- def test_keeps_weekly_evidence_without_guessing_a_distant_timer(self):
- rows = parse_instance_lockouts([
- OcrLine("2d:15h-41 m:44s", 29, 306, 89, 10),
- OcrLine("The Permafrost Caverns - Solo 3 (Fused)", 130, 336, 195, 12),
- OcrLine("Lady Vox", 405, 336, 48, 12),
- ])
- self.assertEqual([(row.target, row.difficulty) for row in rows],
- [("Lady Vox", 3)])
- self.assertIsNone(rows[0].remaining_seconds)
-
- def test_screenshot_cazic_d1_survives_missing_timer_and_event_ocr(self):
- rows = parse_instance_lockouts([
- # Exact geometry and OCR text reproduced from the reported Alt+Z
- # capture. Windows OCR omitted the entire compact timer column.
- OcrLine("The Plane of Fear - Group 1", 137.5, 603.5, 181, 11.5),
- OcrLine("cauc- I nule", 405, 605, 70, 8),
- ])
- self.assertEqual([(row.target, row.difficulty) for row in rows],
- [("Cazic-Thule", 1)])
- self.assertIsNone(rows[0].remaining_seconds)
-
- def test_conservative_name_matching_accepts_minor_event_ocr_error(self):
- rows = parse_instance_lockouts([
- OcrLine("2d:01h:02m:03s The Hole - Solo 1 (Normal) Master Yae1", 10, 20, 500, 14),
- ])
- self.assertEqual([(row.target, row.difficulty) for row in rows], [("Master Yael", 1)])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/spinui_glass/AttackIndicator.tga b/spinui_glass/AttackIndicator.tga
index 3419747..fad2a9c 100644
Binary files a/spinui_glass/AttackIndicator.tga and b/spinui_glass/AttackIndicator.tga differ
diff --git a/spinui_glass/EQUI_InventoryWindow.xml b/spinui_glass/EQUI_InventoryWindow.xml
index f327ce2..7cf84ca 100644
--- a/spinui_glass/EQUI_InventoryWindow.xml
+++ b/spinui_glass/EQUI_InventoryWindow.xml
@@ -5629,6 +5629,48 @@
+
+
+
+ 3
+ true
+
+ 189
+ 112
+
+
+ 120
+ 14
+
+
+ 138
+ 163
+ 255
+
+ Pet Illusion:
+ Selects a pet illusion item from your activated items inventory.
+ false
+ false
+ true
+
+
+
+ IWM_PetIllusionComboBox
+ WDT_InnerSolid
+
+ 187
+ 130
+
+
+ 160
+ 24
+
+ 80
+ BDT_Combo
+ true
+ None
+
+
3
true
@@ -7159,6 +7201,8 @@
IWM_ClassLabel
IWM_Class
IWM_PetSlotLabel
+ IWM_PetIllusionComboBox
+ IWM_PetIllusionLabel
IWM_PetInfoLabel
diff --git a/spinui_glass/EQUI_InventoryWindow1.xml b/spinui_glass/EQUI_InventoryWindow1.xml
index eebf5ee..8e05c5a 100644
--- a/spinui_glass/EQUI_InventoryWindow1.xml
+++ b/spinui_glass/EQUI_InventoryWindow1.xml
@@ -5619,6 +5619,48 @@
+
+
+
+ 3
+ true
+
+ 189
+ 112
+
+
+ 120
+ 14
+
+
+ 138
+ 163
+ 255
+
+ Pet Illusion:
+ Selects a pet illusion item from your activated items inventory.
+ false
+ false
+ true
+
+
+
+ IWM_PetIllusionComboBox
+ WDT_InnerSolid
+
+ 187
+ 130
+
+
+ 160
+ 24
+
+ 80
+ BDT_Combo
+ true
+ None
+
+
3
true
@@ -7149,6 +7191,8 @@
IWM_ClassLabel
IWM_Class
IWM_PetSlotLabel
+ IWM_PetIllusionComboBox
+ IWM_PetIllusionLabel
IWM_PetInfoLabel
diff --git a/spinui_glass/EQUI_InventoryWindow2.xml b/spinui_glass/EQUI_InventoryWindow2.xml
index 3dedbf8..8892d56 100644
--- a/spinui_glass/EQUI_InventoryWindow2.xml
+++ b/spinui_glass/EQUI_InventoryWindow2.xml
@@ -7023,6 +7023,48 @@
+
+
+
+ 3
+ true
+
+ 175
+ 14
+
+
+ 120
+ 14
+
+
+ 138
+ 163
+ 255
+
+ Pet Illusion:
+ Selects a pet illusion item from your activated items inventory.
+ false
+ false
+ true
+
+
+
+ IWM_PetIllusionComboBox
+ WDT_InnerSolid
+
+ 245
+ 10
+
+
+ 160
+ 24
+
+ 80
+ BDT_Combo
+ true
+ None
+
+
true
@@ -8256,6 +8298,8 @@
TileLayoutBox:IW_PetInv
TileLayoutBox:IWM_Slots
TileLayoutBox:IWM_PlayerSlots
+ IWM_PetIllusionComboBox
+ IWM_PetIllusionLabel
IWM_NameLabel
IWM_Name
IWM_LevelLabel
diff --git a/spinui_glass/EQUI_InventoryWindow3.xml b/spinui_glass/EQUI_InventoryWindow3.xml
index b905a45..4385125 100644
--- a/spinui_glass/EQUI_InventoryWindow3.xml
+++ b/spinui_glass/EQUI_InventoryWindow3.xml
@@ -5625,6 +5625,48 @@
+
+
+
+ 3
+ true
+
+ 189
+ 112
+
+
+ 120
+ 14
+
+
+ 138
+ 163
+ 255
+
+ Pet Illusion:
+ Selects a pet illusion item from your activated items inventory.
+ false
+ false
+ true
+
+
+
+ IWM_PetIllusionComboBox
+ WDT_InnerSolid
+
+ 187
+ 130
+
+
+ 160
+ 24
+
+ 80
+ BDT_Combo
+ true
+ None
+
+
3
true
@@ -7155,6 +7197,8 @@
IWM_ClassLabel
IWM_Class
IWM_PetSlotLabel
+ IWM_PetIllusionComboBox
+ IWM_PetIllusionLabel
IWM_PetInfoLabel
diff --git a/spinui_glass/EQUI_PlayerWindow.xml b/spinui_glass/EQUI_PlayerWindow.xml
index 60451f4..a0e2bf1 100644
--- a/spinui_glass/EQUI_PlayerWindow.xml
+++ b/spinui_glass/EQUI_PlayerWindow.xml
@@ -35,7 +35,7 @@
128
- 5
+ 2
@@ -50,7 +50,7 @@
128
- 5
+ 2
@@ -64,7 +64,7 @@
0
- 5
+ 2
32
@@ -79,8 +79,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -106,7 +106,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -120,7 +120,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -138,7 +138,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -152,7 +152,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -319,7 +319,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1353,15 +1353,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_glass/EQUI_PlayerWindow1.xml b/spinui_glass/EQUI_PlayerWindow1.xml
index e9ef14b..0504d81 100644
--- a/spinui_glass/EQUI_PlayerWindow1.xml
+++ b/spinui_glass/EQUI_PlayerWindow1.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_glass/EQUI_PlayerWindow2.xml b/spinui_glass/EQUI_PlayerWindow2.xml
index e9ef14b..0504d81 100644
--- a/spinui_glass/EQUI_PlayerWindow2.xml
+++ b/spinui_glass/EQUI_PlayerWindow2.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_glass/EQUI_PlayerWindow3.xml b/spinui_glass/EQUI_PlayerWindow3.xml
index e9ef14b..0504d81 100644
--- a/spinui_glass/EQUI_PlayerWindow3.xml
+++ b/spinui_glass/EQUI_PlayerWindow3.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_glass/EQUI_PlayerWindow4.xml b/spinui_glass/EQUI_PlayerWindow4.xml
index e9ef14b..0504d81 100644
--- a/spinui_glass/EQUI_PlayerWindow4.xml
+++ b/spinui_glass/EQUI_PlayerWindow4.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_glass/EQUI_PlayerWindow5.xml b/spinui_glass/EQUI_PlayerWindow5.xml
index e9ef14b..0504d81 100644
--- a/spinui_glass/EQUI_PlayerWindow5.xml
+++ b/spinui_glass/EQUI_PlayerWindow5.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_glass/EQUI_PlayerWindow6.xml b/spinui_glass/EQUI_PlayerWindow6.xml
index e9ef14b..0504d81 100644
--- a/spinui_glass/EQUI_PlayerWindow6.xml
+++ b/spinui_glass/EQUI_PlayerWindow6.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_reloaded/AttackIndicator.tga b/spinui_reloaded/AttackIndicator.tga
index 3419747..fad2a9c 100644
Binary files a/spinui_reloaded/AttackIndicator.tga and b/spinui_reloaded/AttackIndicator.tga differ
diff --git a/spinui_reloaded/EQUI_InventoryWindow.xml b/spinui_reloaded/EQUI_InventoryWindow.xml
index 14e37ca..9e951a5 100644
--- a/spinui_reloaded/EQUI_InventoryWindow.xml
+++ b/spinui_reloaded/EQUI_InventoryWindow.xml
@@ -5629,6 +5629,48 @@
+
+
+
+ 3
+ true
+
+ 189
+ 112
+
+
+ 120
+ 14
+
+
+ 138
+ 163
+ 255
+
+ Pet Illusion:
+ Selects a pet illusion item from your activated items inventory.
+ false
+ false
+ true
+
+
+
+ IWM_PetIllusionComboBox
+ WDT_InnerSolid
+
+ 187
+ 130
+
+
+ 160
+ 24
+
+ 80
+ BDT_Combo
+ true
+ None
+
+
3
true
@@ -7159,6 +7201,8 @@
IWM_ClassLabel
IWM_Class
IWM_PetSlotLabel
+ IWM_PetIllusionComboBox
+ IWM_PetIllusionLabel
IWM_PetInfoLabel
diff --git a/spinui_reloaded/EQUI_InventoryWindow1.xml b/spinui_reloaded/EQUI_InventoryWindow1.xml
index eebf5ee..8e05c5a 100644
--- a/spinui_reloaded/EQUI_InventoryWindow1.xml
+++ b/spinui_reloaded/EQUI_InventoryWindow1.xml
@@ -5619,6 +5619,48 @@
+
+
+
+ 3
+ true
+
+ 189
+ 112
+
+
+ 120
+ 14
+
+
+ 138
+ 163
+ 255
+
+ Pet Illusion:
+ Selects a pet illusion item from your activated items inventory.
+ false
+ false
+ true
+
+
+
+ IWM_PetIllusionComboBox
+ WDT_InnerSolid
+
+ 187
+ 130
+
+
+ 160
+ 24
+
+ 80
+ BDT_Combo
+ true
+ None
+
+
3
true
@@ -7149,6 +7191,8 @@
IWM_ClassLabel
IWM_Class
IWM_PetSlotLabel
+ IWM_PetIllusionComboBox
+ IWM_PetIllusionLabel
IWM_PetInfoLabel
diff --git a/spinui_reloaded/EQUI_InventoryWindow2.xml b/spinui_reloaded/EQUI_InventoryWindow2.xml
index 3dedbf8..8892d56 100644
--- a/spinui_reloaded/EQUI_InventoryWindow2.xml
+++ b/spinui_reloaded/EQUI_InventoryWindow2.xml
@@ -7023,6 +7023,48 @@
+
+
+
+ 3
+ true
+
+ 175
+ 14
+
+
+ 120
+ 14
+
+
+ 138
+ 163
+ 255
+
+ Pet Illusion:
+ Selects a pet illusion item from your activated items inventory.
+ false
+ false
+ true
+
+
+
+ IWM_PetIllusionComboBox
+ WDT_InnerSolid
+
+ 245
+ 10
+
+
+ 160
+ 24
+
+ 80
+ BDT_Combo
+ true
+ None
+
+
true
@@ -8256,6 +8298,8 @@
TileLayoutBox:IW_PetInv
TileLayoutBox:IWM_Slots
TileLayoutBox:IWM_PlayerSlots
+ IWM_PetIllusionComboBox
+ IWM_PetIllusionLabel
IWM_NameLabel
IWM_Name
IWM_LevelLabel
diff --git a/spinui_reloaded/EQUI_InventoryWindow3.xml b/spinui_reloaded/EQUI_InventoryWindow3.xml
index b905a45..4385125 100644
--- a/spinui_reloaded/EQUI_InventoryWindow3.xml
+++ b/spinui_reloaded/EQUI_InventoryWindow3.xml
@@ -5625,6 +5625,48 @@
+
+
+
+ 3
+ true
+
+ 189
+ 112
+
+
+ 120
+ 14
+
+
+ 138
+ 163
+ 255
+
+ Pet Illusion:
+ Selects a pet illusion item from your activated items inventory.
+ false
+ false
+ true
+
+
+
+ IWM_PetIllusionComboBox
+ WDT_InnerSolid
+
+ 187
+ 130
+
+
+ 160
+ 24
+
+ 80
+ BDT_Combo
+ true
+ None
+
+
3
true
@@ -7155,6 +7197,8 @@
IWM_ClassLabel
IWM_Class
IWM_PetSlotLabel
+ IWM_PetIllusionComboBox
+ IWM_PetIllusionLabel
IWM_PetInfoLabel
diff --git a/spinui_reloaded/EQUI_PlayerWindow.xml b/spinui_reloaded/EQUI_PlayerWindow.xml
index ec6a8f8..26a78b9 100644
--- a/spinui_reloaded/EQUI_PlayerWindow.xml
+++ b/spinui_reloaded/EQUI_PlayerWindow.xml
@@ -35,7 +35,7 @@
128
- 5
+ 2
@@ -50,7 +50,7 @@
128
- 5
+ 2
@@ -64,7 +64,7 @@
0
- 5
+ 2
32
@@ -79,8 +79,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -106,7 +106,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -120,7 +120,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -138,7 +138,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -152,7 +152,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -319,7 +319,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1353,15 +1353,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_reloaded/EQUI_PlayerWindow1.xml b/spinui_reloaded/EQUI_PlayerWindow1.xml
index 1c7dc2c..be90c4c 100644
--- a/spinui_reloaded/EQUI_PlayerWindow1.xml
+++ b/spinui_reloaded/EQUI_PlayerWindow1.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_reloaded/EQUI_PlayerWindow2.xml b/spinui_reloaded/EQUI_PlayerWindow2.xml
index 1c7dc2c..be90c4c 100644
--- a/spinui_reloaded/EQUI_PlayerWindow2.xml
+++ b/spinui_reloaded/EQUI_PlayerWindow2.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_reloaded/EQUI_PlayerWindow3.xml b/spinui_reloaded/EQUI_PlayerWindow3.xml
index 1c7dc2c..be90c4c 100644
--- a/spinui_reloaded/EQUI_PlayerWindow3.xml
+++ b/spinui_reloaded/EQUI_PlayerWindow3.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_reloaded/EQUI_PlayerWindow4.xml b/spinui_reloaded/EQUI_PlayerWindow4.xml
index 1c7dc2c..be90c4c 100644
--- a/spinui_reloaded/EQUI_PlayerWindow4.xml
+++ b/spinui_reloaded/EQUI_PlayerWindow4.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_reloaded/EQUI_PlayerWindow5.xml b/spinui_reloaded/EQUI_PlayerWindow5.xml
index 1c7dc2c..be90c4c 100644
--- a/spinui_reloaded/EQUI_PlayerWindow5.xml
+++ b/spinui_reloaded/EQUI_PlayerWindow5.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/spinui_reloaded/EQUI_PlayerWindow6.xml b/spinui_reloaded/EQUI_PlayerWindow6.xml
index 1c7dc2c..be90c4c 100644
--- a/spinui_reloaded/EQUI_PlayerWindow6.xml
+++ b/spinui_reloaded/EQUI_PlayerWindow6.xml
@@ -37,7 +37,7 @@
128
- 5
+ 2
@@ -52,7 +52,7 @@
128
- 5
+ 2
@@ -66,7 +66,7 @@
0
- 5
+ 2
32
@@ -81,8 +81,8 @@
0
- 5
- 32
+ 128
+ 2
@@ -108,7 +108,7 @@
false
true
70
- 75
+ 78
0
0
true
@@ -122,7 +122,7 @@
A_AttackIndicatorBottom
false
true
- 7
+ 10
2
0
0
@@ -140,7 +140,7 @@
70
2
0
- 5
+ 8
true
false
true
@@ -154,7 +154,7 @@
true
70
2
- 5
+ 8
0
true
false
@@ -321,7 +321,7 @@
PW_CombatStateAnim
true
- true
+ false
false
13
@@ -1354,15 +1354,15 @@
-9
Screen:PlayerSubWindow
- DragBox:PW_DragBox
- DragBox:PW_DragBox2
- DragBox:PWDragBox3
- PW_BuffWindow
A_AttackIndicatorAnimTop
A_AttackIndicatorAnimBottom
A_AttackIndicatorAnimLeft
A_AttackIndicatorAnimRight
A_AttackIndicatorAnimFill
+ DragBox:PW_DragBox
+ DragBox:PW_DragBox2
+ DragBox:PWDragBox3
+ PW_BuffWindow
diff --git a/tools/audit_combat_ui.py b/tools/audit_combat_ui.py
index 0c27891..1e6cf2b 100644
--- a/tools/audit_combat_ui.py
+++ b/tools/audit_combat_ui.py
@@ -13,10 +13,6 @@
# The effects-row grid is authored once in tools/restyle_combat.py; the audit
# imports it so a geometry change can never pass by editing only one side.
-from paint_attack_indicator import (COLOR as ATTACK_INDICATOR_COLOR,
- EDGE_WIDTH as ATTACK_EDGE_WIDTH,
- FRAME_SIZE as ATTACK_FRAME_SIZE,
- SIZE as ATTACK_INDICATOR_SIZE)
from restyle_combat import (EFFECT_CHIP, EFFECT_ICON, EFFECT_NAME_WIDTH,
EFFECT_NAME_X, EFFECT_PLATE_BLEED,
EFFECT_ROW_WIDTH, EFFECT_TIMER_FONT,
@@ -36,8 +32,42 @@
REPO = Path(__file__).resolve().parent.parent
SKIN = REPO / "spinui_reloaded"
+GLASS_SKIN = REPO / "spinui_glass"
+COMBAT_SKINS = (SKIN, GLASS_SKIN)
STOCK = Path(r"C:\EQLegends\uifiles\default")
+# Pin the client-native attack contract independently of the generator. This
+# prevents a matching mistake in restyle_combat.py and paint_attack_indicator.py
+# from approving itself. The source slices and RLE texture envelope mirror the
+# known-working EQ Modern implementation; only destination thickness is SpinUI.
+ATTACK_TEXTURE_SIZE = (128, 32)
+ATTACK_TEXTURE_COLOR = (189, 189, 189, 255)
+ATTACK_EDGE_WIDTH = 8
+ATTACK_SOURCE_SIZES = {
+ "A_AttackIndicator": (128, 32),
+ "A_AttackIndicatorTop": (128, 2),
+ "A_AttackIndicatorBottom": (128, 2),
+ "A_AttackIndicatorLeft": (2, 32),
+ "A_AttackIndicatorRight": (128, 2),
+ "A_AttackIndicatorFill": (128, 32),
+}
+ATTACK_GEOMETRY = {
+ "A_AttackIndicatorAnimTop": (70, 78, 0, 0),
+ "A_AttackIndicatorAnimBottom": (10, 2, 0, 0),
+ "A_AttackIndicatorAnimLeft": (70, 2, 0, 8),
+ "A_AttackIndicatorAnimRight": (70, 2, 8, 0),
+}
+ATTACK_ANCHORS = {
+ "A_AttackIndicatorAnimTop": ("true", "true", "true", "false"),
+ "A_AttackIndicatorAnimBottom": ("false", "false", "true", "false"),
+ "A_AttackIndicatorAnimLeft": ("true", "false", "true", "true"),
+ "A_AttackIndicatorAnimRight": ("true", "false", "false", "false"),
+}
+PLAYER_WINDOW_FILES = tuple(
+ ["EQUI_PlayerWindow.xml"]
+ + [f"EQUI_PlayerWindow{index}.xml" for index in range(1, 7)]
+)
+
TEXT = (241, 231, 212)
TEXT_DIM = (172, 154, 126)
GOLD_BRIGHT = (248, 214, 140)
@@ -82,10 +112,14 @@ def fail(message: str) -> None:
def root_for(name: str) -> ET.Element:
+ return root_for_path(SKIN / name)
+
+
+def root_for_path(path: Path) -> ET.Element:
try:
- return ET.parse(SKIN / name).getroot()
+ return ET.parse(path).getroot()
except ET.ParseError as exc:
- fail(f"invalid XML {name}: {exc}")
+ fail(f"invalid XML {path}: {exc}")
def item(root: ET.Element, tag: str, name: str) -> ET.Element:
@@ -137,72 +171,162 @@ def require_fill(root: ET.Element, name: str,
fail(f"{name} lost canonical fill color")
-def audit_player_and_target() -> None:
- player = root_for("EQUI_PlayerWindow.xml")
- attack_texture = Image.open(SKIN / "AttackIndicator.tga").convert("RGBA")
- if attack_texture.size != ATTACK_INDICATOR_SIZE:
- fail(
- "AttackIndicator.tga size changed: "
- f"{attack_texture.size} != {ATTACK_INDICATOR_SIZE}"
- )
- colors = attack_texture.getcolors(maxcolors=ATTACK_INDICATOR_SIZE[0]
- * ATTACK_INDICATOR_SIZE[1])
- expected_colors = [(
- ATTACK_INDICATOR_SIZE[0] * ATTACK_INDICATOR_SIZE[1],
- ATTACK_INDICATOR_COLOR,
- )]
- if colors != expected_colors:
- fail("AttackIndicator.tga is no longer a solid opaque neutral tint source")
-
- for name, expected in (
- ("A_AttackIndicator", ATTACK_FRAME_SIZE),
- ("A_AttackIndicatorTop", (ATTACK_FRAME_SIZE[0], ATTACK_EDGE_WIDTH)),
- ("A_AttackIndicatorBottom", (ATTACK_FRAME_SIZE[0], ATTACK_EDGE_WIDTH)),
- ("A_AttackIndicatorLeft", (ATTACK_EDGE_WIDTH, ATTACK_FRAME_SIZE[1])),
- ("A_AttackIndicatorRight", (ATTACK_EDGE_WIDTH, ATTACK_FRAME_SIZE[1])),
- ("A_AttackIndicatorFill", ATTACK_FRAME_SIZE)):
- animation = item(player, "Ui2DAnimation", name)
- frames = animation.findall("Frames")
- if len(frames) != 1:
- fail(f"{name} must use the client's single native attack frame")
- frame = frames[0]
- if (child_text(animation, "Cycle") != "false"
- or child_text(frame, "Texture") != "AttackIndicator.tga"
- or dimensions_at(frame, "Size") != expected
- or (child_int(frame, "Location/X"),
- child_int(frame, "Location/Y")) != (0, 0)
- or frame.find("Duration") is not None):
- fail(f"{name} no longer matches the client-native attack contract")
- attack_geometry = {
- "A_AttackIndicatorAnimTop": (70, 70 + ATTACK_EDGE_WIDTH, 0, 0),
- "A_AttackIndicatorAnimBottom": (2 + ATTACK_EDGE_WIDTH, 2, 0, 0),
- "A_AttackIndicatorAnimLeft": (70, 2, 0, ATTACK_EDGE_WIDTH),
- "A_AttackIndicatorAnimRight": (70, 2, ATTACK_EDGE_WIDTH, 0),
- }
- for name, expected in attack_geometry.items():
- node = item(player, "StaticAnimation", name)
- actual = tuple(child_int(node, tag) for tag in (
+def anchored_rect(node: ET.Element, width: int,
+ height: int) -> tuple[int, int, int, int]:
+ """Resolve EQ's edge-anchor offsets into a concrete LTRB rectangle."""
+ top_offset, bottom_offset, left_offset, right_offset = (
+ child_int(node, tag) for tag in (
"TopAnchorOffset", "BottomAnchorOffset",
"LeftAnchorOffset", "RightAnchorOffset",
- ))
- if actual != expected or child_text(node, "AutoDraw") != "false":
- fail(f"{name} no longer follows the native auto-attack state: {actual}")
- attack_fill = item(player, "StaticAnimation", "A_AttackIndicatorAnimFill")
- if (child_text(attack_fill, "ScreenID") != "A_AttackIndicatorAnimFill"
- or attack_fill.find("Animation") is not None
- or attack_fill.find("AutoDraw") is not None):
- fail("native auto-attack fill must remain an unbound placeholder")
+ )
+ )
+ top = (top_offset if child_text(node, "TopAnchorToTop") == "true"
+ else height - top_offset)
+ bottom = (bottom_offset if child_text(node, "BottomAnchorToTop") == "true"
+ else height - bottom_offset)
+ left = (left_offset if child_text(node, "LeftAnchorToLeft") == "true"
+ else width - left_offset)
+ right = (right_offset if child_text(node, "RightAnchorToLeft") == "true"
+ else width - right_offset)
+ return left, top, right, bottom
+
+
+def audit_attack_indicator_contract() -> None:
+ """Prove every theme/variant keeps a visible native attack perimeter."""
+ for skin in COMBAT_SKINS:
+ texture_path = skin / "AttackIndicator.tga"
+ payload = texture_path.read_bytes()
+ if len(payload) < 18:
+ fail(f"{skin.name} AttackIndicator.tga has no complete TGA header")
+ width = int.from_bytes(payload[12:14], "little")
+ height = int.from_bytes(payload[14:16], "little")
+ if ((width, height) != ATTACK_TEXTURE_SIZE
+ or payload[2] != 10
+ or payload[16] != 32
+ or payload[17] & 0x0F != 8):
+ fail(
+ f"{skin.name} AttackIndicator.tga lost the proven 32-bit RLE "
+ f"envelope (type={payload[2]}, size={width}x{height}, "
+ f"depth={payload[16]}, descriptor={payload[17]})"
+ )
+ with Image.open(texture_path) as source:
+ rgba = source.convert("RGBA")
+ colors = rgba.getcolors(maxcolors=width * height)
+ if colors != [(width * height, ATTACK_TEXTURE_COLOR)]:
+ fail(
+ f"{skin.name} AttackIndicator.tga is not the solid neutral "
+ "native tint source"
+ )
- player_window = item(player, "Screen", "PlayerWindow")
- piece_names = [(piece.text or "").strip()
- for piece in player_window.findall("Pieces")]
- attack_pieces = [
- f"A_AttackIndicatorAnim{edge}"
- for edge in ("Top", "Bottom", "Left", "Right", "Fill")
- ]
- if piece_names[-len(attack_pieces):] != attack_pieces:
- fail("native attack rails are not the topmost PlayerWindow pieces")
+ for filename in PLAYER_WINDOW_FILES:
+ path = skin / filename
+ root = root_for_path(path)
+ label = f"{skin.name}/{filename}"
+
+ for name, expected_size in ATTACK_SOURCE_SIZES.items():
+ animation = item(root, "Ui2DAnimation", name)
+ frames = animation.findall("Frames")
+ if len(frames) != 1:
+ fail(f"{label} {name} must have exactly one native frame")
+ frame = frames[0]
+ if (child_text(animation, "Cycle") != "false"
+ or child_text(frame, "Texture") != "AttackIndicator.tga"
+ or dimensions_at(frame, "Size") != expected_size
+ or (child_int(frame, "Location/X"),
+ child_int(frame, "Location/Y")) != (0, 0)
+ or frame.find("Duration") is not None):
+ fail(f"{label} {name} violates the native source contract")
+
+ edge_nodes: dict[str, ET.Element] = {}
+ for name, expected_offsets in ATTACK_GEOMETRY.items():
+ edge = item(root, "StaticAnimation", name)
+ edge_nodes[name] = edge
+ suffix = name.removeprefix("A_AttackIndicatorAnim")
+ actual_offsets = tuple(child_int(edge, tag) for tag in (
+ "TopAnchorOffset", "BottomAnchorOffset",
+ "LeftAnchorOffset", "RightAnchorOffset",
+ ))
+ actual_anchors = tuple(child_text(edge, tag) for tag in (
+ "TopAnchorToTop", "BottomAnchorToTop",
+ "LeftAnchorToLeft", "RightAnchorToLeft",
+ ))
+ if (child_text(edge, "ScreenID") != name
+ or child_text(edge, "Animation")
+ != f"A_AttackIndicator{suffix}"
+ or child_text(edge, "AutoDraw") != "false"
+ or child_text(edge, "AutoStretch") != "true"
+ or actual_offsets != expected_offsets
+ or actual_anchors != ATTACK_ANCHORS[name]):
+ fail(
+ f"{label} {name} lost its native state binding or "
+ f"8px edge geometry: {actual_offsets} {actual_anchors}"
+ )
+
+ fill = item(root, "StaticAnimation", "A_AttackIndicatorAnimFill")
+ if (child_text(fill, "ScreenID") != "A_AttackIndicatorAnimFill"
+ or fill.find("Animation") is not None
+ or fill.find("AutoDraw") is not None):
+ fail(f"{label} attack fill must remain an unbound placeholder")
+
+ window = item(root, "Screen", "PlayerWindow")
+ if dimensions(window) != (360, 193):
+ fail(f"{label} changed the canonical player-window size")
+ minimum = (
+ child_int(window, "MinHSize"), child_int(window, "MinVSize")
+ )
+ if minimum != PLAYER_MIN_SIZE:
+ fail(f"{label} changed the safe minimum size: {minimum}")
+ pieces = [(piece.text or "").strip()
+ for piece in window.findall("Pieces")]
+ expected_pieces = [
+ f"A_AttackIndicatorAnim{edge}"
+ for edge in ("Top", "Bottom", "Left", "Right", "Fill")
+ ]
+ player_subwindow_index = pieces.index("Screen:PlayerSubWindow")
+ compositor_slice = pieces[
+ player_subwindow_index + 1:player_subwindow_index + 6
+ ]
+ if (compositor_slice != expected_pieces
+ or any(pieces.count(piece) != 1 for piece in expected_pieces)):
+ fail(
+ f"{label} attack rails must be unique and immediately "
+ "follow PlayerSubWindow in the native compositor slot"
+ )
+
+ combat_state = item(root, "Button", "PW_CombatStateAnim")
+ if child_text(combat_state, "Style_Transparent") != "false":
+ fail(f"{label} changed the stock combat-state button contract")
+
+ # Resolve real rectangles at both supported extremes. This catches
+ # gaps, clipped corners, and one-sided anchor regressions that an
+ # XML symbol-table check cannot see.
+ for size in ((360, 193), PLAYER_MIN_SIZE):
+ frame_width, frame_height = size
+ expected_rects = {
+ "A_AttackIndicatorAnimTop":
+ (0, 70, frame_width, 70 + ATTACK_EDGE_WIDTH),
+ "A_AttackIndicatorAnimBottom":
+ (0, frame_height - 2 - ATTACK_EDGE_WIDTH,
+ frame_width, frame_height - 2),
+ "A_AttackIndicatorAnimLeft":
+ (0, 70, ATTACK_EDGE_WIDTH, frame_height - 2),
+ "A_AttackIndicatorAnimRight":
+ (frame_width - ATTACK_EDGE_WIDTH, 70,
+ frame_width, frame_height - 2),
+ }
+ actual_rects = {
+ name: anchored_rect(node, frame_width, frame_height)
+ for name, node in edge_nodes.items()
+ }
+ if actual_rects != expected_rects:
+ fail(
+ f"{label} attack perimeter is discontinuous at "
+ f"{frame_width}x{frame_height}: {actual_rects}"
+ )
+
+def audit_player_and_target() -> None:
+ player = root_for("EQUI_PlayerWindow.xml")
require_binding(player, "Gauge", "Player_HP", "PlayerHP", 1)
require_binding(player, "Gauge", "Player_Mana", "PlayerMana", 2)
require_binding(player, "Gauge", "Player_Fatigue", "PlayerFatigue", 3)
@@ -1101,6 +1225,7 @@ def semantic_signature(node: ET.Element):
def main() -> int:
+ audit_attack_indicator_contract()
audit_player_and_target()
audit_group_and_extended_targets()
audit_effects_casting_and_bars()
@@ -1112,7 +1237,7 @@ def main() -> int:
print("Combat Command Center audit: ALL PASS")
print(" Player/Target/ToT | Group 1..11 | XTarget 0..22 | Raid groups 1..12")
print(" buffs 30 | songs 15 | spell gems 14 | hotbars 11 x 12 | stance + invocation")
- print(" 52 compatibility aliases + native bright attack rail + spell ledger")
+ print(" 52 compatibility aliases + 14 native 8px attack perimeters + spell ledger")
print(" contrast AAA/AA | July stock parity " + ("PASS" if stock_checked else "not available"))
return 0
diff --git a/tools/audit_loremaster_desktop.py b/tools/audit_loremaster_desktop.py
new file mode 100644
index 0000000..93467ab
--- /dev/null
+++ b/tools/audit_loremaster_desktop.py
@@ -0,0 +1,223 @@
+#!/usr/bin/env python3
+"""Static contract for the Electron Loremaster themes and supported tracking.
+
+This audit intentionally checks the seams where a partially implemented theme
+can look correct in the main window while leaving the Seed, alerts, or archive
+on the old palette. It also keeps the retired Instance Information screen OCR
+from returning through a stale hotkey or protocol field.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+
+REPO = Path(__file__).resolve().parent.parent
+DESKTOP = REPO / "loremaster-desktop"
+
+
+class AuditFailure(RuntimeError):
+ pass
+
+
+def fail(message: str) -> None:
+ raise AuditFailure(message)
+
+
+def read(relative: str) -> str:
+ path = REPO / relative
+ if not path.is_file():
+ fail(f"required source is missing: {relative}")
+ return path.read_text(encoding="utf-8")
+
+
+def require(source: str, values: tuple[str, ...], owner: str) -> None:
+ missing = [value for value in values if value not in source]
+ if missing:
+ fail(f"{owner} is missing: " + ", ".join(missing))
+
+
+def audit_theme_contract() -> None:
+ protocol = read("loremaster-desktop/src/protocol.ts")
+ app = read("loremaster-desktop/src/App.tsx")
+ renderer = read("loremaster-desktop/src/main.tsx")
+ electron = read("loremaster-desktop/electron/main.ts")
+ preload = read("loremaster-desktop/electron/preload.ts")
+ base_styles = read("loremaster-desktop/src/styles.css")
+ themes = read("loremaster-desktop/src/themes.css")
+ readme = read("loremaster-desktop/README.md")
+
+ if not re.search(
+ r'type\s+LoremasterTheme\s*=\s*["\']vellum["\']\s*\|\s*["\']glass["\']',
+ protocol,
+ ):
+ fail("protocol must expose the exact vellum | glass theme union")
+ require(protocol, ("uiTheme: LoremasterTheme",), "renderer protocol")
+ require(
+ electron,
+ ("uiTheme", '"vellum"', '"glass"', "settings:changed"),
+ "Electron settings persistence",
+ )
+ if electron.count("uiTheme") < 6:
+ fail("Electron does not normalize, persist, update, and seed the theme")
+ require(
+ app,
+ (
+ "VELLUM & EMBER",
+ "MIDNIGHT FROST GLASS",
+ "SpinUI Reloaded",
+ "SpinUI Glass",
+ 'role="radiogroup"',
+ "uiTheme",
+ ),
+ "Settings theme picker",
+ )
+ require(
+ app,
+ (
+ "ALERT SOUND STUDIO",
+ "Rune Pulse",
+ "Crystal Chime",
+ "Ember Alarm",
+ "Temple Bell",
+ "sound-preset-trigger",
+ "sound-preset-menu",
+ 'aria-haspopup="listbox"',
+ "previewConfiguredSound",
+ "soundKindForAlert",
+ ),
+ "per-alert sound studio",
+ )
+ require(
+ protocol,
+ ("AlertSoundKind", "AlertSoundPreset", "soundProfiles"),
+ "sound profile protocol",
+ )
+ require(
+ electron,
+ (
+ "alerts:choose-sound",
+ "alerts:read-sound",
+ "CUSTOM_SOUND_MAX_BYTES",
+ "normalizeSoundProfiles",
+ ),
+ "custom sound boundary",
+ )
+ require(
+ preload,
+ ("chooseAlertSound", "readAlertSound"),
+ "custom sound preload API",
+ )
+ if 'role="radio"' not in app and "aria-pressed" not in app:
+ fail("theme choices need an accessible selected-state contract")
+ if app.count("applyTheme(") < 4:
+ fail("theme is not applied to all main, alert, and control surfaces")
+ require(
+ renderer,
+ ("data", "theme", "document.documentElement"),
+ "pre-render theme seed",
+ )
+ if renderer.count('import "./themes.css";') != 1:
+ fail("theme stylesheet must be imported exactly once after the base CSS")
+ if not re.search(r"\.settings-toggle\s*\{[^}]*position:\s*relative", base_styles):
+ fail("Settings toggles need a local containing block to prevent focus-scroll blanks")
+ require(
+ base_styles,
+ (".settings-toggle input", "width: 1px", "height: 1px", "clip-path: inset(50%)"),
+ "accessible Settings toggle concealment",
+ )
+
+ lowered = themes.casefold()
+ require(
+ lowered,
+ (
+ '[data-theme="glass"]',
+ "#0c0906",
+ "#130e09",
+ "#685030",
+ "#d0a254",
+ "#f1e7d4",
+ "#03080e",
+ "#060f18",
+ "#30798f",
+ "#69e1f2",
+ "#55f2be",
+ "#ab80ff",
+ "#e8f8fc",
+ "--danger",
+ "--warning",
+ "--success",
+ ),
+ "canonical theme palette",
+ )
+ for selector in (
+ ".rune-seed",
+ ".loremaster-shell",
+ ".settings-card",
+ ".alert-surface",
+ ".seed-control-surface",
+ ".seed-group-surface",
+ ".weekly-card",
+ ".gear-card",
+ ".archive-shell",
+ ".archive-fights",
+ ".archive-report",
+ ".theme-picker",
+ ".theme-option",
+ ):
+ if selector not in themes:
+ fail(f"theme stylesheet does not cover {selector}")
+ if "backdrop-filter" in lowered:
+ fail("transparent Electron windows must not depend on live blur")
+ require(
+ readme,
+ ("Vellum & Ember", "Midnight Frost Glass", "spinui_reloaded", "spinui_glass", "Alert Sound Studio"),
+ "Loremaster desktop documentation",
+ )
+
+
+def audit_retired_lockout_ocr() -> None:
+ runtime_sources = {
+ "Electron main": read("loremaster-desktop/electron/main.ts"),
+ "renderer": read("loremaster-desktop/src/App.tsx"),
+ "protocol": read("loremaster-desktop/src/protocol.ts"),
+ "desktop worker": read("loremaster/desktop_worker.py"),
+ }
+ forbidden = (
+ "scan-alt-z",
+ "scanAltZ",
+ "altZLockout",
+ "altZScan",
+ "instance_lockout_ocr",
+ "instance_lockouts",
+ "globalShortcut",
+ )
+ for owner, source in runtime_sources.items():
+ found = [value for value in forbidden if value.casefold() in source.casefold()]
+ if found:
+ fail(f"{owner} still contains retired lockout OCR: {', '.join(found)}")
+ for relative in (
+ "loremaster/instance_lockout_ocr.py",
+ "loremaster/tests/test_instance_lockout_ocr.py",
+ ):
+ if (REPO / relative).exists():
+ fail(f"retired lockout OCR file still exists: {relative}")
+
+
+def main() -> int:
+ try:
+ audit_theme_contract()
+ audit_retired_lockout_ocr()
+ except AuditFailure as exc:
+ print(f"Loremaster desktop audit: FAIL\n {exc}", file=sys.stderr)
+ return 1
+ print("Loremaster desktop audit: ALL PASS")
+ print(" Vellum & Ember + Midnight Frost Glass | persistent cross-window themes")
+ print(" no Instance Information OCR or reserved Ctrl+Shift+Z shortcut")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/audit_spinui.py b/tools/audit_spinui.py
index e4fa739..3315ed9 100644
--- a/tools/audit_spinui.py
+++ b/tools/audit_spinui.py
@@ -599,6 +599,21 @@ def audit_inventory_progression() -> None:
for path in paths:
root = ET.parse(path).getroot()
+ illusion_label = item(root, "Label", "IWM_PetIllusionLabel")
+ if (illusion_label.findtext("Text") or "").strip() != "Pet Illusion:":
+ fail(f"{path.name} pet illusion label is missing or renamed")
+ illusion_combo = item(root, "Combobox", "IWM_PetIllusionComboBox")
+ if (illusion_combo.findtext("ScreenID") or "").strip() != "IWM_PetIllusionComboBox":
+ fail(f"{path.name} lost the client-native pet illusion binding")
+ if (illusion_combo.findtext("Choices") or "").strip() != "None":
+ fail(f"{path.name} pet illusion selector lost its safe empty choice")
+ pet_page = item(root, "Page", "IW_PetInvPage")
+ pet_pieces = [(node.text or "").strip() for node in pet_page.findall("Pieces")]
+ for required_piece in ("IWM_PetIllusionComboBox", "IWM_PetIllusionLabel"):
+ if pet_pieces.count(required_piece) != 1:
+ fail(
+ f"{path.name} pet page must include {required_piece} exactly once"
+ )
gauges: list[ET.Element] = []
for gauge_name, eq_type in (
("IW_ExpGauge", 4), ("IW_AltAdvGauge", 5)):
@@ -1295,7 +1310,9 @@ def main() -> int:
f"fixed | commands 14 | effects {BUFF_CAPACITY[pet_default]} "
f"icon cells | variants {len(WINDOW_SIZES)}"
)
- print(" inventory 660x668 | equipment 23 | AA deck 6 tabs + true progress | ledger 15/15 + 6/6 | footer 6 | persona 23 | bags 12")
+ print(" inventory 660x668 | equipment 23 + pet illusion selector | "
+ "AA deck 6 tabs + true progress | ledger 15/15 + 6/6 | "
+ "footer 6 | persona 23 | bags 12")
return 0
diff --git a/tools/paint_attack_indicator.py b/tools/paint_attack_indicator.py
index 265747b..43624a2 100644
--- a/tools/paint_attack_indicator.py
+++ b/tools/paint_attack_indicator.py
@@ -17,17 +17,16 @@
from PIL import Image
-from generate_spinui_textures import save_tga
-
-
REPO = Path(__file__).resolve().parent.parent
OUTPUT = REPO / "spinui_reloaded" / "AttackIndicator.tga"
FRAME_SIZE = (128, 32)
SIZE = FRAME_SIZE
-EDGE_WIDTH = 5
-# Modern uses 189 gray. Full neutral white preserves the same native tint
-# animation while raising its red peak to 255 for dark Reloaded/Glass frames.
-COLOR = (255, 255, 255, 255)
+EDGE_WIDTH = 8
+# Use the exact neutral source proven by EQ's working Modern UI. The client
+# modulates this gray to its bright neutral/red phases only while attack is on.
+# Pre-coloring it red collapses the modulation, and an uncompressed 32-bit
+# pure-white texture is an unproven legacy-renderer combination.
+COLOR = (189, 189, 189, 255)
def render() -> Image.Image:
@@ -36,10 +35,12 @@ def render() -> Image.Image:
def main() -> int:
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
- save_tga(render(), OUTPUT)
+ # RLE 32-bit is the known-working Modern TGA envelope. For this solid
+ # source Pillow emits a deterministic 204-byte file (type 10, alpha 8).
+ render().save(OUTPUT, format="TGA", compression="tga_rle")
print(
- "AttackIndicator.tga: full-bright neutral attack rail painted | "
- "EverQuest tint-driven red flash | no fill wash"
+ "AttackIndicator.tga: native neutral RLE attack rail painted | "
+ "8px EverQuest tint-driven pulse | no fill wash"
)
return 0
diff --git a/tools/release_quality_gate.py b/tools/release_quality_gate.py
index b2f0e1e..098e3a8 100644
--- a/tools/release_quality_gate.py
+++ b/tools/release_quality_gate.py
@@ -123,13 +123,17 @@
"loremaster-desktop/package.json",
"loremaster-desktop/pnpm-lock.yaml",
"loremaster-desktop/pnpm-workspace.yaml",
+ "loremaster-desktop/index.html",
"loremaster-desktop/electron/main.ts",
"loremaster-desktop/electron/preload.ts",
"loremaster-desktop/electron/gear-plan.ts",
"loremaster-desktop/fixtures/control-replay.json",
"loremaster-desktop/src/App.tsx",
+ "loremaster-desktop/src/CombatArchive.tsx",
+ "loremaster-desktop/src/main.tsx",
"loremaster-desktop/src/protocol.ts",
"loremaster-desktop/src/styles.css",
+ "loremaster-desktop/src/themes.css",
"loremaster-desktop/README.md",
"loremaster-desktop/scripts/validate-fixture.mjs",
"loremaster-desktop/scripts/test-gear-plan.cjs",
@@ -138,6 +142,7 @@
"installer/versioninfo-installer.txt",
"loremaster/versioninfo-loremaster.txt",
"tools/audit_combat_ui.py",
+ "tools/audit_loremaster_desktop.py",
"tools/audit_spinui_glass.py",
"tools/audit_spinui.py",
"tools/build_spinui_glass.py",
diff --git a/tools/restyle_combat.py b/tools/restyle_combat.py
index 00a7c56..219ed1f 100644
--- a/tools/restyle_combat.py
+++ b/tools/restyle_combat.py
@@ -64,9 +64,24 @@
# so users may resize them without clipping the command rows.
PLAYER_MIN_SIZE = (280, 174)
TARGET_MIN_SIZE = (260, 174)
-ATTACK_EDGE_WIDTH = 5
+# EverQuest recognizes the attack indicator by exact animation and ScreenID
+# names, then owns its attack-on visibility and pulse. Keep the proven stock
+# source slices byte-for-byte compatible and make only the destination rails
+# bolder. Eight screen pixels remains clear at high resolutions without
+# washing over the command-frame contents.
+ATTACK_EDGE_WIDTH = 8
ATTACK_FRAME_SIZE = (128, 32)
ATTACK_TEXTURE_SIZE = ATTACK_FRAME_SIZE
+ATTACK_SOURCE_SIZES = {
+ "A_AttackIndicator": ATTACK_FRAME_SIZE,
+ "A_AttackIndicatorTop": (128, 2),
+ "A_AttackIndicatorBottom": (128, 2),
+ "A_AttackIndicatorLeft": (2, 32),
+ # This asymmetric crop is intentional. It is the native contract used by
+ # the working Default, Default Light, and Default Modern player windows.
+ "A_AttackIndicatorRight": (128, 2),
+ "A_AttackIndicatorFill": ATTACK_FRAME_SIZE,
+}
# Older SpinUI releases exposed a large collection of visual variants. Most of
# those files predate the July Legends schema and can lose live controls when a
@@ -113,6 +128,54 @@
"EQUI_CastSpellWnd.xml": tuple(f"EQUI_CastSpellWnd{i}.xml" for i in range(1, 3)),
}
+PET_ILLUSION_BLOCK = """
+
+
+ 3
+ true
+
+ 189
+ 112
+
+
+ 120
+ 14
+
+
+ 138
+ 163
+ 255
+
+ Pet Illusion:
+ Selects a pet illusion item from your activated items inventory.
+ false
+ false
+ true
+
+
+
+ IWM_PetIllusionComboBox
+ WDT_InnerSolid
+
+ 187
+ 130
+
+
+ 160
+ 24
+
+ 80
+ BDT_Combo
+ true
+ None
+
+
+"""
+
+PET_ILLUSION_COMPACT_BLOCK = (PET_ILLUSION_BLOCK
+ .replace("189 \n\t\t\t112 ", "175 \n\t\t\t14 ", 1)
+ .replace("187 \n\t\t\t130 ", "245 \n\t\t\t10 ", 1))
+
def fail(message: str) -> None:
raise RuntimeError(message)
@@ -442,19 +505,10 @@ def invocation_rail(block: str) -> str:
b, "Size", CX=ATTACK_TEXTURE_SIZE[0], CY=ATTACK_TEXTURE_SIZE[1]),
)
- attack_animations = {
- "A_AttackIndicator": ATTACK_FRAME_SIZE,
- "A_AttackIndicatorTop": (ATTACK_FRAME_SIZE[0], ATTACK_EDGE_WIDTH),
- "A_AttackIndicatorBottom": (ATTACK_FRAME_SIZE[0], ATTACK_EDGE_WIDTH),
- "A_AttackIndicatorLeft": (ATTACK_EDGE_WIDTH, ATTACK_FRAME_SIZE[1]),
- "A_AttackIndicatorRight": (ATTACK_EDGE_WIDTH, ATTACK_FRAME_SIZE[1]),
- "A_AttackIndicatorFill": ATTACK_FRAME_SIZE,
- }
-
def attack_animation(block: str, size: tuple[int, int]) -> str:
- # Match the working Modern UI contract exactly: EverQuest toggles and
- # flashes these stock-named widgets itself. A custom Cycle can look
- # correct in static audits while never advancing in the live client.
+ # Match the working native source contract exactly: EverQuest toggles,
+ # tints, and flashes these stock-named widgets itself. A custom Cycle
+ # can look correct in static audits while never advancing in game.
block = set_value(block, "Cycle", "false")
block = re.sub(r"\n\t\t.*? ", "", block,
flags=re.DOTALL)
@@ -478,7 +532,7 @@ def attack_animation(block: str, size: tuple[int, int]) -> str:
)
return block[:cycle_end] + frames + block[cycle_end:]
- for name, size in attack_animations.items():
+ for name, size in ATTACK_SOURCE_SIZES.items():
text = change_item(
text, "Ui2DAnimation", name,
lambda b, s=size: attack_animation(b, s),
@@ -513,6 +567,13 @@ def attack_fill(_block: str) -> str:
text = change_item(
text, "StaticAnimation", "A_AttackIndicatorAnimFill", attack_fill,
)
+ # Preserve the stock command-state button contract as well. This is a
+ # separate native widget, but matching the proven player-window semantics
+ # avoids a transparent child swallowing state-driven rendering.
+ text = change_item(
+ text, "Button", "PW_CombatStateAnim",
+ lambda b: set_value(b, "Style_Transparent", "false"),
+ )
# change_item intentionally starts at the element's opening ``<`` and
# leaves its existing line prefix in place. Older generator runs returned
# an additional leading tab here, so normalize the one stock placeholder
@@ -537,8 +598,12 @@ def root_style(block: str) -> str:
# live client; the compact PlayerSubWindow remains the visible frame.
block = set_value(block, "Style_Border", "false")
block = set_value(block, "Style_Sizable", "true")
- # Draw the native rails last so the buff canvas and drag surfaces
- # cannot cover the top edge at their shared y=70 boundary.
+ # Keep the native rails in EverQuest's proven compositor slot: directly
+ # after PlayerSubWindow. SIDL Pieces do not behave like a browser DOM;
+ # placing these state-owned children last causes the themed subwindow
+ # chrome to be composed over parts of the pulse in the live client.
+ # Default Modern uses this exact placement and renders the rails above
+ # the command frame, which is the behavior Reloaded and Glass need.
attack_pieces = tuple(
f"A_AttackIndicatorAnim{edge}"
for edge in ("Top", "Bottom", "Left", "Right", "Fill")
@@ -551,11 +616,13 @@ def root_style(block: str) -> str:
f"\n\t\t{piece} " for piece in attack_pieces
)
block, count = re.subn(
- r"\n\t$", pieces + "\n\t", block,
+ r"(\n[ \t]*Screen:PlayerSubWindow )",
+ r"\1" + pieces,
+ block,
count=1,
)
if count != 1:
- fail("PlayerWindow root has no closing Screen tag")
+ fail("PlayerWindow root has no PlayerSubWindow piece")
return block
text = change_item(text, "Screen", "PlayerWindow", root_style)
@@ -1364,6 +1431,61 @@ def style_experience_gauges() -> None:
write_ascii(path, text)
+def restore_pet_illusion_selector() -> None:
+ """Restore Legends' activated-item pet illusion binding on every layout.
+
+ The client populates this exact Combobox ScreenID from Activated Items and
+ applies the chosen clicky to newly summoned or charmed pets. It needs no
+ EQType or injected behavior; preserving the native name is the behavior.
+ """
+ inventory_names = (
+ "EQUI_InventoryWindow.xml",
+ "EQUI_InventoryWindow1.xml",
+ "EQUI_InventoryWindow2.xml",
+ "EQUI_InventoryWindow3.xml",
+ )
+ for inventory_name in inventory_names:
+ path = SKIN / inventory_name
+ text = path.read_text(encoding="utf-8")
+ if 'item="IWM_PetIllusionComboBox"' not in text:
+ anchor = ('\t'
+ if '\t' in text
+ else '\t')
+ if text.count(anchor) != 1:
+ fail(f"{inventory_name} has no unique pet page label anchor")
+ block = PET_ILLUSION_COMPACT_BLOCK if inventory_name == "EQUI_InventoryWindow2.xml" else PET_ILLUSION_BLOCK
+ text = text.replace(anchor, block + anchor, 1)
+
+ label_xy, combo_xy = ((175, 14), (245, 10)) if inventory_name == "EQUI_InventoryWindow2.xml" else ((189, 112), (187, 130))
+ text = change_item(
+ text, "Label", "IWM_PetIllusionLabel",
+ lambda b: set_container(b, "Location", X=label_xy[0], Y=label_xy[1]),
+ )
+ text = change_item(
+ text, "Combobox", "IWM_PetIllusionComboBox",
+ lambda b: set_container(b, "Location", X=combo_xy[0], Y=combo_xy[1]),
+ )
+
+ def include_native_selector(block: str) -> str:
+ pieces = (
+ "\t\tIWM_PetIllusionComboBox \n"
+ "\t\tIWM_PetIllusionLabel \n"
+ )
+ if "IWM_PetIllusionComboBox " in block:
+ return block
+ anchor = ("\t\tIWM_PetInfoLabel \n"
+ if "\t\tIWM_PetInfoLabel \n" in block
+ else "\t\tIWM_NameLabel \n")
+ if anchor not in block:
+ fail(f"{inventory_name} pet page lost its label pieces")
+ return block.replace(anchor, pieces + anchor, 1)
+
+ text = change_item(text, "Page", "IW_PetInvPage", include_native_selector)
+ # Inventory sources retain some stock whitespace deliberately. Avoid a
+ # file-wide formatting churn when adding this small client binding.
+ path.write_text(text, encoding="utf-8", newline="")
+
+
def style_raid() -> None:
path = SKIN / "EQUI_RaidWindow.xml"
text = path.read_text(encoding="ascii")
@@ -1390,6 +1512,7 @@ def main() -> int:
style_spell_gems()
style_hotbuttons()
style_stance()
+ restore_pet_illusion_selector()
style_experience_gauges()
style_raid()
sync_canonical_variants()