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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
· <a href="#spins-loremaster">Explore Loremaster</a>
· <a href="#layout-profiles">Choose a layout</a>
· <a href="#4k-readability-with-spinfourkayyy">4K scaling</a>
· <a href="#sharper-world-textures-with-spintexture">Texture upgrades</a>
</p>

<p align="center">
Expand Down Expand Up @@ -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 |
Expand Down
28 changes: 28 additions & 0 deletions loremaster-desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
156 changes: 136 additions & 20 deletions loremaster-desktop/electron/main.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<AlertSoundKind, AlertSoundProfile>;

export interface AlertSettings {
alertsEnabled: boolean;
Expand All @@ -81,13 +85,15 @@ export interface AlertSettings {
lullTimersEnabled: boolean;
lullTimerSound: boolean;
lullWarningSeconds: number;
soundProfiles: AlertSoundProfiles;
}

interface DesktopSettings {
logPath: string;
raidDifficulty: number | null;
bisBuildPath: string;
inventoryPath: string;
uiTheme: "vellum" | "glass";
alwaysOnTop: boolean;
fontScale: number;
composition: string;
Expand Down Expand Up @@ -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<string, unknown> : {};
return Object.fromEntries(ALERT_SOUND_KINDS.map((kind) => {
const candidate = source[kind] && typeof source[kind] === "object"
? source[kind] as Partial<AlertSoundProfile> : {};
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,
Expand All @@ -133,13 +168,15 @@ const defaultAlertSettings: AlertSettings = {
lullTimersEnabled: true,
lullTimerSound: false,
lullWarningSeconds: 12,
soundProfiles: defaultSoundProfiles,
};

const defaultSettings: DesktopSettings = {
logPath: "",
raidDifficulty: null,
bisBuildPath: "",
inventoryPath: "",
uiTheme: "vellum",
alwaysOnTop: true,
fontScale: 1.15,
composition: "",
Expand Down Expand Up @@ -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) : "",
Expand All @@ -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) } };
}
}

Expand Down Expand Up @@ -445,17 +484,24 @@ class EngineSupervisor {
this.send({ type: "engine.set-raid-difficulty", raidDifficulty });
}

updateDesktopSettings(patch: Partial<Pick<DesktopSettings, "alwaysOnTop" | "fontScale" | "composition" | "splitCharmedPetDps" | "stanceAdvisorEnabled">> & {
updateDesktopSettings(patch: Partial<Pick<DesktopSettings, "uiTheme" | "alwaysOnTop" | "fontScale" | "composition" | "splitCharmedPetDps" | "stanceAdvisorEnabled">> & {
alerts?: Partial<AlertSettings>;
}): 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
? scaledSeedPosition(this.settings.seedPosition, previousScale, nextScale)
: 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,
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -1041,6 +1083,12 @@ function setAnalysisMode(active: boolean, preserveAnchor = false): void {
setImmediate(() => { movingWindowProgrammatically = false; });
}

function rendererUrl(base: string, query: Record<string, string>): 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);
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()")
Expand All @@ -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());
Expand Down Expand Up @@ -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<string, unknown>;
const patch: Parameters<EngineSupervisor["updateDesktopSettings"]>[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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -1517,7 +1634,6 @@ app.whenReady().then(() => {
}
});
app.on("before-quit", () => {
globalShortcut.unregisterAll();
clearTopmostReassertions();
if (topmostHeartbeatTimer) clearInterval(topmostHeartbeatTimer);
topmostHeartbeatTimer = null;
Expand Down
2 changes: 2 additions & 0 deletions loremaster-desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions loremaster-desktop/index.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="en" data-theme="vellum">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://127.0.0.1:5173 ws://127.0.0.1:5173"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' http://127.0.0.1:5173 ws://127.0.0.1:5173"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
Expand Down
Loading