From 5f88d37fb35dbb3dcb08cdaac367662374e4ecfa Mon Sep 17 00:00:00 2001 From: Random <65575762+rndaom@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:45:48 -0400 Subject: [PATCH] feat: add an Edit page and group the sidebar by Look, Feel, Sound, Mods, Performance and Advanced Edit on the Setups page now opens one page listing every setting of the setup in the game with its current value; each row opens the pane that changes it. The page only reads the snapshot the panes already load. The sidebar follows the same groups, with an Edit entry under Setups. One-pane groups (Sounds, Mods, Performance) share a group without a heading so the sidebar still fits at 1200x800. Comfig is shown as Performance; internal ids and file names are unchanged. --- CHANGELOG.md | 10 +- apps/desktop/src/App.boot.test.tsx | 2 +- apps/desktop/src/App.setups.test.tsx | 25 ++- apps/desktop/src/App.tsx | 50 +++-- apps/desktop/src/ComfigPane.tsx | 6 +- apps/desktop/src/FilesPane.tsx | 2 +- apps/desktop/src/SettingsHost.tsx | 49 ++++- apps/desktop/src/SettingsLayout.tsx | 31 ++- apps/desktop/src/SetupSheet.tsx | 160 ++++++++++++++ apps/desktop/src/SetupsHome.tsx | 2 +- apps/desktop/src/ViewmodelSettings.test.tsx | 2 +- apps/desktop/src/ViewmodelSettings.tsx | 2 +- apps/desktop/src/components/WelcomeTour.tsx | 6 +- apps/desktop/src/components/ui/tabIcons.ts | 4 +- apps/desktop/src/index.css | 145 +++++++++++- apps/desktop/src/lib/preview-bridge.ts | 13 +- apps/desktop/src/lib/preview.ts | 3 + apps/desktop/src/lib/settings-ui.test.ts | 28 +++ apps/desktop/src/lib/settings-ui.ts | 56 +++-- apps/desktop/src/lib/setup-sheet.test.ts | 127 +++++++++++ apps/desktop/src/lib/setup-sheet.ts | 232 ++++++++++++++++++++ apps/desktop/src/lib/setups-ui.ts | 4 +- docs/ARCHITECTURE.md | 16 +- docs/guide/README.md | 8 +- docs/guide/comfig.md | 4 +- docs/guide/profiles.md | 17 +- 26 files changed, 919 insertions(+), 85 deletions(-) create mode 100644 apps/desktop/src/SetupSheet.tsx create mode 100644 apps/desktop/src/lib/setup-sheet.test.ts create mode 100644 apps/desktop/src/lib/setup-sheet.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 223bc692..a37e3db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ User-facing changes only. The release workflow publishes the matching - A Setups page that opens first: every setup with its HUD's picture and key details, and one click to switch, or to switch and play. +- An Edit page for the setup in the game: every setting on one page, grouped as Look, Feel, + Sound, Mods, Performance and Advanced, each showing its current value. Click a row to open the + page that changes it. ### Fixed @@ -20,7 +23,10 @@ User-facing changes only. The release workflow publishes the matching ### Changed -- No more fold-out sections: every setting is on the page. Comfig's modules have their own tab +- Comfig is now called Performance, and the sidebar uses the Edit page's groups: Look (HUD, + Crosshair, Viewmodels), Feel (Gameplay, Binds), then Sounds, Mods and Performance, Advanced + (Files, Launch) and Inventory. +- No more fold-out sections: every setting is on the page. Performance's modules have their own tab with search and category filters; HUD options, Mods' file check, Casual addons and particles, and App settings' credits are always shown; Sounds puts pitch and repeat delay with each sound. - Viewmodels shows each class as one table, with every weapon under its slot and its own Shown, @@ -30,7 +36,7 @@ User-facing changes only. The release workflow publishes the matching - Crosshair, Viewmodels and Mods' Casual setup show the same bar while changes aren't in TF2 yet, with Discard changes and Apply changes. Viewmodels applies straight from that bar, without a separate review window. -- Comfig shows an update button only when a newer mastercomfig is out, and keeps the comfig.app +- Performance shows an update button only when a newer mastercomfig is out, and keeps the comfig.app customizer next to the comfig-custom folder it makes. Removing the crosshair pack is a button at the bottom of the Crosshair page instead of a hidden menu, and buttons that repeated a switch or the sidebar (Open Comfig addons, Open Viewmodels) are gone. diff --git a/apps/desktop/src/App.boot.test.tsx b/apps/desktop/src/App.boot.test.tsx index a96cef8f..d388587e 100644 --- a/apps/desktop/src/App.boot.test.tsx +++ b/apps/desktop/src/App.boot.test.tsx @@ -48,7 +48,7 @@ it("holds the startup screen over an inert app until the first pane has loaded", await act(async () => root.render()); const splash = box.querySelector('[data-testid="boot-splash"]'); - expect(splash?.textContent).toContain("Loading Comfig…"); + expect(splash?.textContent).toContain("Loading Performance…"); expect(splash?.nextElementSibling?.hasAttribute("inert")).toBe(true); await act(async () => finishRead()); diff --git a/apps/desktop/src/App.setups.test.tsx b/apps/desktop/src/App.setups.test.tsx index 919dfc60..94618db8 100644 --- a/apps/desktop/src/App.setups.test.tsx +++ b/apps/desktop/src/App.setups.test.tsx @@ -76,11 +76,26 @@ it("opens on Setups with the panes kept, hidden, behind it", async () => { expect(text(".setups-name")).toBe("Main"); }); -it("goes to the last pane from Edit and back with the Setups entry", async () => { +it("opens the Edit page from Edit, a pane from its rows, and back with the Setups entry", async () => { await click('[data-testid="setups-edit"]'); expect(q('[data-testid="setups-home"]')).toBeNull(); - expect(current("settings-tab-comfig")).toBe("page"); - expect(q('[data-testid="settings-pane-comfig"] > [hidden]')).toBeNull(); + expect(q('[data-testid="setup-sheet"]')).not.toBeNull(); + expect(current("settings-tab-edit")).toBe("page"); + expect(text('[data-testid="settings-tab-edit"]')).toBe("Edit Main"); + expect(text(".setup-sheet-name")).toBe("Main"); + expect( + Array.from(box.querySelectorAll(".setup-group-title")).map((title) => title.textContent), + ).toEqual(["Look", "Feel", "Sound", "Mods", "Performance", "Advanced"]); + + await click('[data-testid="setup-row-fov"]'); + expect(q('[data-testid="setup-sheet"]')).toBeNull(); + expect(current("settings-tab-gameplay")).toBe("page"); + expect(q('[data-testid="settings-pane-gameplay"] > [hidden]')).toBeNull(); + + await click('[data-testid="settings-tab-edit"]'); + expect(q('[data-testid="setup-sheet"]')).not.toBeNull(); + await click('[data-testid="setup-row-launch"]'); + expect(current("settings-tab-launch")).toBe("page"); await click('[data-testid="settings-tab-binds"]'); expect(current("settings-tab-binds")).toBe("page"); @@ -100,6 +115,10 @@ it("opens the guide's profiles page from Setups and the pane's page elsewhere", await click('[data-testid="settings-tab-binds"]'); await click('[data-testid="guide-open"]'); expect(external).toHaveBeenLastCalledWith(expect.stringMatching(/\/docs\/guide\/binds\.md$/)); + // The Edit page is described with the setups. + await click('[data-testid="settings-tab-edit"]'); + await click('[data-testid="guide-open"]'); + expect(external).toHaveBeenLastCalledWith(expect.stringMatching(/\/docs\/guide\/profiles\.md$/)); }); it("returns to Setups from App settings", async () => { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 209a49c6..33ec4b66 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -66,6 +66,7 @@ import { import { createSettingsDraftStore } from "./lib/settings-drafts"; import { browserStorage, + editLabel, readLastPane, SETTINGS_TAB_LABELS, type SettingsTab, @@ -117,6 +118,8 @@ export function App({ const [appSettingsOpen, setAppSettingsOpen] = useState(false); // The workspace opens on Setups; a fixture that picks a pane opens on that. const [setupsOpen, setSetupsOpen] = useState(() => previewSettingsTab(preview) === null); + // The Edit page: every setting of the setup in the game, one row per setting. + const [sheetOpen, setSheetOpen] = useState(() => preview === "settings-edit"); const [launchSync, setLaunchSync] = useState(null); const [launchSyncPrompt, setLaunchSyncPrompt] = useState(null); const appSettingsButton = useRef(null); @@ -136,12 +139,19 @@ export function App({ const navigateSettings = useCallback((tab: SettingsTab) => { setAppSettingsOpen(false); setSetupsOpen(false); + setSheetOpen(false); setSettingsTab(tab); }, []); const openSetups = useCallback(() => { setAppSettingsOpen(false); + setSheetOpen(false); setSetupsOpen(true); }, []); + const openSheet = useCallback(() => { + setAppSettingsOpen(false); + setSetupsOpen(false); + setSheetOpen(true); + }, []); const reviewSettings = useCallback( (tab: SettingsTab) => { navigateSettings(tab); @@ -289,6 +299,15 @@ export function App({ if (filesVersion >= 0 && filesDraftStore.hasDirty(changedProfileId)) tabs.add("files"); return tabs; }, [settingsDrafts, filesVersion, filesDraftStore, changedProfileId]); + const activeProfileName = + profiles.library?.profiles.find((profile) => profile.id === profiles.library?.activeProfileId) + ?.name ?? null; + // What the workspace shows when App settings is closed. + const workspaceName = setupsOpen + ? "Setups" + : sheetOpen + ? editLabel(activeProfileName) + : SETTINGS_TAB_LABELS[settingsTab]; const pendingPanes = [ ...new Set(settingsDrafts.map((entry) => SETTINGS_TAB_LABELS[entry.tab])), ].join(", "); @@ -452,7 +471,7 @@ export function App({ surface, settingsOpen, settingsSettled, - paneLabel: setupsOpen ? "Setups" : SETTINGS_TAB_LABELS[settingsTab], + paneLabel: sheetOpen ? (activeProfileName ?? "Edit") : workspaceName, failed: error !== null, }; const boot = bootStage(stageInput); @@ -585,9 +604,7 @@ export function App({ backAction={ } onChangeInstall={() => @@ -766,7 +783,9 @@ export function App({ void openExternal( guideUrl( appSettingsOpen ? "app-settings" - : setupsOpen + : setupsOpen || sheetOpen ? "profiles" : settingsTab, update.version, @@ -813,17 +832,16 @@ export function App({ profile.id === profiles.library?.activeProfileId, - )?.name ?? null - } + activeProfileName={activeProfileName} onNavigate={navigateSettings} onTryComfig={tryComfig} running={lock.running} @@ -859,7 +877,7 @@ export function App({ onSwitch={requestSwitch} onSwitchAndPlay={switchAndPlay} onPlay={launchNow} - onEdit={() => navigateSettings(settingsTab)} + onEdit={openSheet} onCreateNew={() => filesExit.request(firstRun.openCreate)} onCompare={openCompare} /> @@ -889,11 +907,7 @@ export function App({ profile.id === profiles.library?.activeProfileId, - )?.name ?? null - } + profileName={activeProfileName} origin={welcome ?? "saved"} offerComfig={welcomeOffersComfig} onClose={() => setWelcome(null)} diff --git a/apps/desktop/src/ComfigPane.tsx b/apps/desktop/src/ComfigPane.tsx index 935c2721..31890586 100644 --- a/apps/desktop/src/ComfigPane.tsx +++ b/apps/desktop/src/ComfigPane.tsx @@ -223,7 +223,7 @@ export function ComfigPane({ if (detail && detail.layer !== "comfig") { return (
- +

This profile doesn't use mastercomfig

{onTryComfig ? ( @@ -249,7 +249,7 @@ export function ComfigPane({
@@ -277,7 +277,7 @@ export function ComfigPane({ }, ]} selected={view} - label="Comfig" + label="Performance" idPrefix="comfig-view" panelId="comfig-view-panel" onSelect={setView} diff --git a/apps/desktop/src/FilesPane.tsx b/apps/desktop/src/FilesPane.tsx index 61460c92..6fe50e0d 100644 --- a/apps/desktop/src/FilesPane.tsx +++ b/apps/desktop/src/FilesPane.tsx @@ -1351,7 +1351,7 @@ function owningPane(path: string): { tab: SettingsTab; label: string } { if (path.includes("execs_binds")) return { tab: "binds", label: "Open Binds" }; if (path.includes("execs_gameplay")) return { tab: "gameplay", label: "Open Gameplay" }; if (path.includes("execs_preload")) return { tab: "mods", label: "Open Mods" }; - return { tab: "comfig", label: "Open Comfig" }; + return { tab: "comfig", label: "Open Performance" }; } function FindingRow({ diff --git a/apps/desktop/src/SettingsHost.tsx b/apps/desktop/src/SettingsHost.tsx index 55e9e043..901a02f0 100644 --- a/apps/desktop/src/SettingsHost.tsx +++ b/apps/desktop/src/SettingsHost.tsx @@ -55,7 +55,7 @@ import { } from "./lib/files-drafts"; import { cfgHudFolder } from "./lib/files-reference"; import { blockingFindingsForFile, cfgFileMeta, hitAnalysisLimit } from "./lib/files-ui"; -import { gameplayPath } from "./lib/gameplay-ui"; +import { gameplayPath, seedGameplay } from "./lib/gameplay-ui"; import { hudOverlayCrosshairState } from "./lib/hud-ui"; import { recommendedLaunchOptions } from "./lib/launch-ui"; import { type ModInstallResult, type ModSelection, PRELOADER_REPO_URL } from "./lib/mods-ui"; @@ -66,6 +66,7 @@ import { SETTINGS_TAB_LABELS, type SettingsTab } from "./lib/settings-ui"; import type { IncomingSounds } from "./lib/sound-library"; import { prefetchViewmodelCatalog } from "./lib/viewmodel-catalog-cache"; import { ModsPane } from "./ModsPane"; +import { SetupSheet } from "./SetupSheet"; import { SoundsPane } from "./SoundsPane"; import { ViewmodelPane } from "./ViewmodelPane"; @@ -82,7 +83,10 @@ export function SettingsHost({ tab, activeProfileId, activeProfileName, - visible = true, + visible: hostVisible = true, + sheet = false, + changed, + onBack, running, externalBusy, refreshKey, @@ -112,6 +116,12 @@ export function SettingsHost({ activeProfileName?: string | null; /** Global pages retain drafts but release auditions, key capture and reads. */ visible?: boolean; + /** Show the Edit page instead of the pane; the panes keep their drafts. */ + sheet?: boolean; + /** Panes with changes that have not reached the profile yet. */ + changed?: ReadonlySet; + /** Leaves the Edit page for the Setups page. */ + onBack?: () => void; running: boolean; externalBusy: boolean; refreshKey: string | number; @@ -136,6 +146,9 @@ export function SettingsHost({ onLaunchOptionsSaved?: () => void; }) { const { error, dismissError } = useAppStatus(); + // The Edit page shows no pane: panes release their reads as on a global page. + const visible = hostVisible && !sheet; + const sheetShown = hostVisible && sheet; const toast = useToast(); const [queueBusy, setQueueBusy] = useState(false); const [loadedDetail, setDetail] = useState(null); @@ -288,6 +301,8 @@ export function SettingsHost({ () => mapsFromFiles(files, layer, detail?.files, detail ?? undefined), [files, layer, detail], ); + // Values on the Edit page come only from a complete read of this profile. + const cfgReadable = maps.complete && !filesLimited && cfgSnapshotProfileId === profileId; // Only the visible pane pays for the extra override probe. const provenanceTab = visible && !filesLimited ? tab : null; const provenance = useMemo( @@ -1672,10 +1687,10 @@ export function SettingsHost({
) : null} - {filesLimited && (usesCfgState(tab) || tab === "files") ? ( + {filesLimited && (sheetShown || usesCfgState(tab) || tab === "files") ? (

{cfgReadProblem}

- {usesCfgState(tab) && cfgSnapshotProfileId === profileId ? ( + {(sheetShown || usesCfgState(tab)) && cfgSnapshotProfileId === profileId ? (

Values below are from the last complete read and may be stale.

) : null}
@@ -1687,7 +1702,7 @@ export function SettingsHost({ > Retry loading settings - {tab !== "files" && onNavigate ? ( + {(sheetShown || tab !== "files") && onNavigate ? ( @@ -1716,7 +1731,7 @@ export function SettingsHost({ {!profileId && loading && !identityPending ? ( Loading settings… ) : null} - {!identityPending && !filesLimited && !maps.complete && usesCfgState(tab) ? ( + {!identityPending && !filesLimited && !maps.complete && (sheetShown || usesCfgState(tab)) ? (

{maps.reason ?? CFG_INCOMPLETE_MESSAGE}

{onNavigate && ( @@ -1735,6 +1750,28 @@ export function SettingsHost({ )}
) : null} + {sheetShown && detail && !identityPending ? ( + file.path === gameplayPath(layer))?.text ?? "", + maps.effective, + ) + : null, + boundKeys: cfgReadable ? Object.keys(maps.binds).length : null, + comfig, + launchOptions: launchSeed, + cfgFiles: files.length, + }} + refreshKey={`${refreshKey}:${profileId}`} + changed={changed} + onOpen={(next) => onNavigate?.(next)} + onBack={() => onBack?.()} + /> + ) : null} ) : null} {SETTINGS_TAB_GROUPS.map((group) => ( diff --git a/apps/desktop/src/SetupSheet.tsx b/apps/desktop/src/SetupSheet.tsx new file mode 100644 index 00000000..e027b91b --- /dev/null +++ b/apps/desktop/src/SetupSheet.tsx @@ -0,0 +1,160 @@ +import { + ArrowLeft, + CaretRight, + GameController, + Gauge, + Monitor, + Package, + SpeakerHigh, + Wrench, +} from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import type { TabIcon } from "./components/ui/tabIcons"; +import type { Api } from "./lib/api"; +import type { HudCatalogEntry } from "./lib/bridge"; +import type { SettingsTab, SetupGroupId } from "./lib/settings-ui"; +import { type SheetInput, sheetGroups } from "./lib/setup-sheet"; +import { hudLabel, hudPicture } from "./lib/setups-ui"; +import { Picture } from "./SetupsHome"; + +const GROUP_ICONS: Record = { + look: Monitor, + feel: GameController, + sound: SpeakerHigh, + mods: Package, + performance: Gauge, + advanced: Wrench, +}; + +/** + * The Edit page: every setting of the setup in the game on one page, grouped + * like the sidebar. It only reads; each row opens the pane that changes it. + */ +export function SetupSheet({ + api, + input, + refreshKey, + changed, + onOpen, + onBack, +}: { + api: Pick; + input: Omit; + /** Changes whenever the profile is read again. */ + refreshKey: string; + /** Panes with changes that have not reached the profile yet. */ + changed?: ReadonlySet; + onOpen: (tab: SettingsTab) => void; + onBack: () => void; +}) { + const { detail } = input; + const [catalog, setCatalog] = useState([]); + const [casual, setCasual] = useState<{ id: string; count: number } | null>(null); + + useEffect(() => { + let cancelled = false; + // Names and pictures are a nicety: without the saved catalog the HUD id shows. + api + .getCachedHudCatalog() + .then((result) => { + if (!cancelled) setCatalog(result?.entries ?? []); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [api]); + + const profileId = detail.id; + const mods = detail.mods?.length ?? 0; + // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey re-reads the counts after a save or switch. + useEffect(() => { + let cancelled = false; + // The overview counts Casual setup choices along with the profile's mods. + api + .getSetupOverviews() + .then((overviews) => { + const overview = overviews.find((item) => item.id === profileId); + if (!cancelled && overview) { + setCasual({ id: profileId, count: Math.max(0, overview.mods - mods) }); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [api, profileId, mods, refreshKey]); + + const groups = sheetGroups({ + ...input, + catalog, + casualChoices: casual?.id === profileId ? casual.count : 0, + }); + const picture = hudPicture(detail.hud?.id ?? null, catalog); + + return ( +
+ +
+ + + {detail.hud ? hudLabel(detail.hud.id, catalog) : "TF2's own HUD"} + + +
+

+ {detail.name} +

+

+

+
+
+ {groups.map((group) => { + const Icon = GROUP_ICONS[group.id]; + return ( +
+

+

+
+ {group.rows.map((row) => ( + + ))} +
+
+ ); + })} +
+ ); +} diff --git a/apps/desktop/src/SetupsHome.tsx b/apps/desktop/src/SetupsHome.tsx index fd67dc81..ef4c3a9a 100644 --- a/apps/desktop/src/SetupsHome.tsx +++ b/apps/desktop/src/SetupsHome.tsx @@ -26,7 +26,7 @@ import { } from "./lib/setups-ui"; /** A HUD picture that falls back to a flat block when it is missing or will not load. */ -function Picture({ +export function Picture({ src, alt, className, diff --git a/apps/desktop/src/ViewmodelSettings.test.tsx b/apps/desktop/src/ViewmodelSettings.test.tsx index 2338221b..a191e38c 100644 --- a/apps/desktop/src/ViewmodelSettings.test.tsx +++ b/apps/desktop/src/ViewmodelSettings.test.tsx @@ -112,7 +112,7 @@ describe("Viewmodels in-game settings", () => { const save = vi.fn(async (_text: string) => undefined); await render({ canUseComfigAddons: false, cfgReady: false, onSave: save }); expect(control("viewmodel-transparent").disabled).toBe(true); - expect(box.textContent).toContain("Available with a Comfig profile."); + expect(box.textContent).toContain("Available with mastercomfig."); expect(control("viewmodel-min").disabled).toBe(true); expect((control("viewmodel-fov") as unknown as HTMLInputElement).disabled).toBe(true); await act(async () => vi.advanceTimersByTimeAsync(2000)); diff --git a/apps/desktop/src/ViewmodelSettings.tsx b/apps/desktop/src/ViewmodelSettings.tsx index 6e1546bd..224e5661 100644 --- a/apps/desktop/src/ViewmodelSettings.tsx +++ b/apps/desktop/src/ViewmodelSettings.tsx @@ -125,7 +125,7 @@ export function ViewmodelSettings({ description={OFFICIAL_ADDON_DETAILS["transparent-viewmodels"]} checked={transparentViewmodels} disabled={addonLocked || !canUseComfigAddons} - note={canUseComfigAddons ? undefined : "Available with a Comfig profile."} + note={canUseComfigAddons ? undefined : "Available with mastercomfig."} onChange={() => onToggleTransparentViewmodels()} />
diff --git a/apps/desktop/src/components/WelcomeTour.tsx b/apps/desktop/src/components/WelcomeTour.tsx index 48d77d6f..39ba7afa 100644 --- a/apps/desktop/src/components/WelcomeTour.tsx +++ b/apps/desktop/src/components/WelcomeTour.tsx @@ -151,8 +151,8 @@ export function WelcomeTour({ } const SIDEBAR: { group: string; tabs: SettingsTab[] }[] = [ - { group: "Setup", tabs: ["comfig", "binds"] }, - { group: "Look", tabs: ["hud", "crosshair", "sounds"] }, + { group: "Look", tabs: ["hud", "crosshair", "viewmodels"] }, + { group: "Feel", tabs: ["gameplay", "binds"] }, ]; function WelcomePicture({ id, profileName }: { id: WelcomeStepId; profileName: string | null }) { @@ -188,7 +188,7 @@ function WelcomePicture({ id, profileName }: { id: WelcomeStepId; profileName: s {SETTINGS_TAB_LABELS[tab]} - {tab === "sounds" ? : null} + {tab === "binds" ? : null} ); })} diff --git a/apps/desktop/src/components/ui/tabIcons.ts b/apps/desktop/src/components/ui/tabIcons.ts index c2ec80da..cc6e0f2e 100644 --- a/apps/desktop/src/components/ui/tabIcons.ts +++ b/apps/desktop/src/components/ui/tabIcons.ts @@ -3,11 +3,11 @@ import { Crosshair, FolderOpen, GameController, + Gauge, Keyboard, Monitor, Package, Play, - SlidersHorizontal, SpeakerHigh, UserFocus, } from "@phosphor-icons/react"; @@ -18,7 +18,7 @@ export type TabIcon = ComponentType<{ size?: number; weight?: "regular" | "bold" /** One icon per pane, shared by the sidebar and pane headings. */ export const SETTINGS_TAB_ICONS: Record = { - comfig: SlidersHorizontal, + comfig: Gauge, binds: Keyboard, gameplay: GameController, hud: Monitor, diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 6d92622f..a460b7e0 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -362,12 +362,12 @@ a:focus-visible, flex: 1; min-height: 0; overflow-y: auto; - padding: 16px 8px; + padding: 12px 8px; scrollbar-gutter: stable; } .settings-nav-group + .settings-nav-group { - margin-top: 20px; + margin-top: 12px; } .settings-nav-heading { @@ -382,8 +382,8 @@ a:focus-visible, align-items: center; gap: 10px; width: 100%; - min-height: 36px; - padding: 7px 12px; + min-height: 34px; + padding: 6px 12px; border-radius: var(--radius-sm); color: var(--color-ink-muted); font-size: 13px; @@ -3609,7 +3609,8 @@ a:focus-visible, /* Rows: label 18, two tabs of 24, label 18, then HUD, Crosshair, Sounds. */ .wp-highlight { position: absolute; - top: calc(6px + 18px + 24px * 2 + 18px + 24px * 2); + /* The last row: Binds, after Look's three panes and Feel's first. */ + top: calc(6px + 18px + 24px * 3 + 18px + 24px); right: 6px; left: 6px; height: 24px; @@ -4267,3 +4268,137 @@ a:focus-visible, } } } + +/* ---- Edit page ------------------------------------------------------------ */ + +@layer components { + /* A long setup name in the sidebar ends in an ellipsis. */ + .settings-nav-label-clip { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .setup-sheet { + max-width: 880px; + } + + .setup-sheet-back { + margin-bottom: 16px; + } + + .setup-sheet-head { + display: flex; + align-items: center; + gap: 22px; + margin-bottom: 30px; + } + + .setup-sheet-shot { + flex: none; + width: 230px; + } + + .setup-sheet-shot img { + animation: none; + } + + .setup-sheet-name { + font-size: 40px; + } + + .setup-group + .setup-group { + margin-top: 26px; + } + + .setup-group-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 8px; + font-size: 15px; + font-weight: 600; + } + + .setup-group-title svg { + color: var(--color-ink-faint); + } + + .setup-rows { + overflow: hidden; + border: 1px solid var(--color-edge); + border-radius: var(--radius-lg); + background: var(--color-panel); + } + + .setup-row { + display: grid; + grid-template-columns: 180px minmax(0, 1fr) 16px; + align-items: center; + gap: 12px; + width: 100%; + min-height: 48px; + padding: 0 16px; + text-align: left; + transition: background-color var(--motion) ease; + } + + .setup-row + .setup-row { + border-top: 1px solid var(--color-edge); + } + + .setup-row:hover { + background: var(--color-panel-raised); + } + + .setup-row-label { + color: var(--color-ink-faint); + } + + .setup-row-value { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + + .setup-row-text { + overflow: hidden; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + } + + .setup-row-more { + color: var(--color-ink-faint); + font-weight: 400; + } + + .setup-row-change { + margin-left: 0; + } + + .setup-row-caret { + color: var(--color-ink-faint); + transition: color var(--motion) ease; + } + + .setup-row:hover .setup-row-caret { + color: var(--color-ink); + } + + @media (max-width: 900px) { + .setup-sheet-shot { + width: 160px; + } + + .setup-sheet-name { + font-size: 32px; + } + + .setup-row { + grid-template-columns: 130px minmax(0, 1fr) 16px; + } + } +} diff --git a/apps/desktop/src/lib/preview-bridge.ts b/apps/desktop/src/lib/preview-bridge.ts index 79b12203..b4af4a67 100644 --- a/apps/desktop/src/lib/preview-bridge.ts +++ b/apps/desktop/src/lib/preview-bridge.ts @@ -278,12 +278,14 @@ export function createPreviewApi(state: PreviewState): Api { let launchOptions = recommendedLaunchOptions(); let steamLaunchOptions = "-novid"; let hudState: HudUiState = - state === "settings-hud-installed" || state === "hud-ownership" + state === "settings-hud-installed" || state === "hud-ownership" || state === "settings-edit" ? previewInstalledState() : emptyHudState(); let hudOwnershipPending = state === "hud-ownership"; let mods: ModRecord[] = - state === "settings-mods" ? PREVIEW_PROFILE_MODS.map((m) => ({ ...m })) : []; + state === "settings-mods" || state === "settings-edit" + ? PREVIEW_PROFILE_MODS.map((m) => ({ ...m })) + : []; const savedMods = new Map(); let modsPayload: PreloaderStatusPayload = PREVIEW_MODS_STATUS; let pendingModImport: { @@ -293,10 +295,13 @@ export function createPreviewApi(state: PreviewState): Api { section: GameBananaSection; } | null = null; const crosshairPixels: Record = {}; - let crosshair = state === "settings-crosshair" ? previewCrosshairRecord() : null; + let crosshair = + state === "settings-crosshair" || state === "settings-edit" ? previewCrosshairRecord() : null; let viewmodel = state === "settings-viewmodels" ? previewViewmodelRecord() : null; let hitsound: HitsoundRecord | null = - state === "settings-sounds" ? { hit: { name: "quack", source: "community" } } : null; + state === "settings-sounds" || state === "settings-edit" + ? { hit: { name: "quack", source: "community" } } + : null; let importReadingHandler: (() => void) | null = null; let progressHandler: ((progress: SwitchProgress) => void) | null = null; let hudProgressHandler: ((progress: HudInstallProgress) => void) | null = null; diff --git a/apps/desktop/src/lib/preview.ts b/apps/desktop/src/lib/preview.ts index b28675bf..125dbb11 100644 --- a/apps/desktop/src/lib/preview.ts +++ b/apps/desktop/src/lib/preview.ts @@ -44,6 +44,7 @@ export const PREVIEW_STATES = [ "settings-mods", "settings-files", "settings-launch", + "settings-edit", "settings-locked", "settings-app-failure", "update-available", @@ -97,6 +98,7 @@ const READY: PreviewState[] = [ "settings-mods", "settings-files", "settings-launch", + "settings-edit", "settings-locked", "settings-app-failure", "update-available", @@ -234,6 +236,7 @@ export function previewSettingsTab(state: PreviewState): SettingsTab | null { return "inventory"; case "settings-comfig": case "settings-comfig-vanilla": + case "settings-edit": case "welcome": case "folder-repair": case "profile-import-huds": diff --git a/apps/desktop/src/lib/settings-ui.test.ts b/apps/desktop/src/lib/settings-ui.test.ts index d28d92cb..83115edf 100644 --- a/apps/desktop/src/lib/settings-ui.test.ts +++ b/apps/desktop/src/lib/settings-ui.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import { emptyLibrary, previewSavedLibrary } from "./library-ui"; import { canWriteSettings, + editLabel, readLastPane, + SETTINGS_TAB_GROUPS, SETTINGS_TAB_LABELS, SETTINGS_TABS, + SETUP_GROUPS, showSettingsChrome, writeLastPane, } from "./settings-ui"; @@ -36,6 +39,31 @@ describe("settings chrome", () => { } }); + it("groups the sidebar like the Edit page, without headings that repeat a pane's name", () => { + expect(SETUP_GROUPS.map((group) => group.label)).toEqual([ + "Look", + "Feel", + "Sound", + "Mods", + "Performance", + "Advanced", + ]); + expect(SETTINGS_TAB_GROUPS).toEqual([ + { label: "Look", tabs: ["hud", "crosshair", "viewmodels"] }, + { label: "Feel", tabs: ["gameplay", "binds"] }, + { label: "", tabs: ["sounds", "mods", "comfig"] }, + { label: "Advanced", tabs: ["files", "launch"] }, + { label: "Steam", tabs: ["inventory"] }, + ]); + expect(SETTINGS_TAB_LABELS.comfig).toBe("Performance"); + }); + + it("names the Edit page after the setup in the game", () => { + expect(editLabel("FPS")).toBe("Edit FPS"); + expect(editLabel(" ")).toBe("Edit"); + expect(editLabel(null)).toBe("Edit"); + }); + it("shows only when a usable library has an active profile", () => { expect(showSettingsChrome(null)).toBe(false); expect(showSettingsChrome(emptyLibrary("/tf2", true))).toBe(false); diff --git a/apps/desktop/src/lib/settings-ui.ts b/apps/desktop/src/lib/settings-ui.ts index a314be1a..de874957 100644 --- a/apps/desktop/src/lib/settings-ui.ts +++ b/apps/desktop/src/lib/settings-ui.ts @@ -2,14 +2,14 @@ import type { ProfileLibrary } from "./bridge"; import { canWrite } from "./write-gate"; export const SETTINGS_TABS = [ - "comfig", - "binds", - "gameplay", "hud", "crosshair", "viewmodels", + "gameplay", + "binds", "sounds", "mods", + "comfig", "files", "launch", "inventory", @@ -18,7 +18,7 @@ export const SETTINGS_TABS = [ export type SettingsTab = (typeof SETTINGS_TABS)[number]; export const SETTINGS_TAB_LABELS: Record = { - comfig: "Comfig", + comfig: "Performance", binds: "Binds", gameplay: "Gameplay", hud: "HUD", @@ -32,16 +32,44 @@ export const SETTINGS_TAB_LABELS: Record = { }; /** - * The sidebar reads as short groups instead of one long list: what you set up, - * how it looks, everything else, and the Steam account's own Inventory. A group - * with no label shows no heading. + * What a setup is made of, in the order the Edit page and the sidebar list it. + * The ids name the Edit page's sections. */ -export const SETTINGS_TAB_GROUPS: { label: string; tabs: readonly SettingsTab[] }[] = [ - { label: "Setup", tabs: ["comfig", "binds", "gameplay"] }, - { label: "Look", tabs: ["hud", "crosshair", "viewmodels", "sounds"] }, - { label: "More", tabs: ["mods", "files", "launch"] }, - { label: "Steam", tabs: ["inventory"] }, -]; +export const SETUP_GROUPS = [ + { id: "look", label: "Look", tabs: ["hud", "crosshair", "viewmodels"] }, + { id: "feel", label: "Feel", tabs: ["gameplay", "binds"] }, + { id: "sound", label: "Sound", tabs: ["sounds"] }, + { id: "mods", label: "Mods", tabs: ["mods"] }, + { id: "performance", label: "Performance", tabs: ["comfig"] }, + { id: "advanced", label: "Advanced", tabs: ["files", "launch"] }, +] as const satisfies readonly { id: string; label: string; tabs: readonly SettingsTab[] }[]; + +export type SetupGroupId = (typeof SETUP_GROUPS)[number]["id"]; + +function sidebarGroups(): { label: string; tabs: readonly SettingsTab[] }[] { + const groups: { label: string; tabs: SettingsTab[] }[] = []; + for (const group of SETUP_GROUPS) { + // A heading over a single pane only repeats its name, so neighbouring + // one-pane groups share one group without a heading. + const last = groups.at(-1); + if (group.tabs.length === 1 && last?.label === "") last.tabs.push(...group.tabs); + else if (group.tabs.length === 1) groups.push({ label: "", tabs: [...group.tabs] }); + else groups.push({ label: group.label, tabs: [...group.tabs] }); + } + return [...groups, { label: "Steam", tabs: ["inventory"] }]; +} + +/** + * The sidebar: the setup's groups, then the Steam account's own Inventory, + * which belongs to no setup. A group with no label shows no heading. + */ +export const SETTINGS_TAB_GROUPS = sidebarGroups(); + +/** The sidebar item and title for the Edit page of the setup in the game. */ +export function editLabel(name: string | null): string { + const trimmed = name?.trim(); + return trimmed ? `Edit ${trimmed}` : "Edit"; +} export function showSettingsChrome(library: ProfileLibrary | null): boolean { return ( @@ -84,7 +112,7 @@ export function writeLastPane(storage: Pick | null, tab: Set try { storage?.setItem(LAST_PANE_KEY, tab); } catch { - // Remembering the pane is a convenience; opening on Comfig is fine. + // Remembering the pane is a convenience; opening on Performance is fine. } } diff --git a/apps/desktop/src/lib/setup-sheet.test.ts b/apps/desktop/src/lib/setup-sheet.test.ts new file mode 100644 index 00000000..032f38bc --- /dev/null +++ b/apps/desktop/src/lib/setup-sheet.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import type { HudCatalogEntry, ProfileDetail } from "./bridge"; +import { defaultGameplay } from "./gameplay-ui"; +import { type SheetInput, sheetGroups, UNREADABLE } from "./setup-sheet"; + +const DETAIL: ProfileDetail = { + id: "fps", + name: "FPS", + launchOptions: "", + layer: "comfig", + files: [], +}; + +function input(overrides: Partial = {}): SheetInput { + return { + detail: DETAIL, + gameplay: defaultGameplay(), + boundKeys: 40, + comfig: { preset: "high", modules: {}, addons: [] }, + launchOptions: "-novid", + cfgFiles: 6, + casualChoices: 0, + catalog: [], + ...overrides, + }; +} + +function rows(value: SheetInput) { + return Object.fromEntries( + sheetGroups(value).flatMap((group) => + group.rows.map((row) => [row.key, { value: row.value, more: row.more, tab: row.tab }]), + ), + ); +} + +describe("Edit page rows", () => { + it("lists every group in the sidebar's order, each row opening its pane", () => { + const groups = sheetGroups(input()); + expect(groups.map((group) => group.label)).toEqual([ + "Look", + "Feel", + "Sound", + "Mods", + "Performance", + "Advanced", + ]); + expect(groups.map((group) => group.rows.map((row) => row.tab))).toEqual([ + ["hud", "crosshair", "viewmodels"], + ["gameplay", "gameplay", "gameplay", "binds"], + ["sounds", "sounds", "sounds"], + ["mods"], + ["comfig"], + ["launch", "files"], + ]); + }); + + it("reads a setup left as TF2 has it", () => { + const all = rows(input()); + expect(all.hud.value).toBe("TF2's own"); + expect(all.crosshair.value).toBe("TF2's own"); + expect(all.fov.value).toBe("90°"); + expect(all["hit-sound"].value).toBe("Off"); + expect(all.mods.value).toBe("None"); + expect(all.performance).toMatchObject({ value: "mastercomfig High", more: undefined }); + expect(all.launch.value).toBe("-novid"); + expect(all.files.value).toBe("6 files"); + expect(all.binds.value).toBe("40 keys bound"); + }); + + it("names the HUD, crosshair, sounds and mods the setup holds", () => { + const catalog = [{ id: "rayshud", name: "rayshud" }] as unknown as HudCatalogEntry[]; + const all = rows( + input({ + catalog, + detail: { + ...DETAIL, + hud: { id: "rayshud", source: "catalog", options: { a: "1", b: "2" } } as never, + crosshair: { + id: "execs-crosshairs", + shape: "dot", + assignments: { tf_weapon_sniperrifle: "circle" }, + }, + hitsound: { hit: { name: "quake_hit.wav", source: "community" } }, + mods: [ + { id: "1", name: "Better particles" }, + { id: "2", name: "No hats" }, + ] as never, + }, + gameplay: { ...defaultGameplay(), tf_dingalingaling: 1, tf_dingalingaling_lasthit: 1 }, + casualChoices: 2, + }), + ); + expect(all.hud).toMatchObject({ value: "rayshud", more: "2 options set" }); + // The pack's id is not a crosshair; its shape is. + expect(all.crosshair.value).not.toMatch(/execs/i); + expect(all.crosshair.more).toBe("different per weapon"); + expect(all["hit-sound"].value).toBe("Quake hit"); + expect(all["kill-sound"].value).toBe("TF2's own"); + expect(all.mods).toMatchObject({ + value: "Better particles, No hats", + more: "2 Casual choices", + }); + }); + + it("says when cfg values cannot be read instead of guessing", () => { + const all = rows(input({ gameplay: null, boundKeys: null })); + for (const key of ["fov", "sensitivity", "weapons", "binds", "volume", "viewmodels"]) { + expect(all[key].value).toBe(UNREADABLE); + } + // What the profile itself records still shows. + expect(all.launch.value).toBe("-novid"); + }); + + it("describes setups without mastercomfig, and helpers that are off", () => { + const all = rows( + input({ + detail: { ...DETAIL, layer: "vanilla" }, + gameplay: { ...defaultGameplay(), cl_autoreload: 0, hud_fastswitch: 0, volume: 0.5 }, + launchOptions: " ", + }), + ); + expect(all.performance.value).toBe("No mastercomfig"); + expect(all.weapons.value).toBe("No helpers"); + expect(all.volume.value).toBe("50%"); + expect(all.launch.value).toBe("None"); + }); +}); diff --git a/apps/desktop/src/lib/setup-sheet.ts b/apps/desktop/src/lib/setup-sheet.ts new file mode 100644 index 00000000..b28fd925 --- /dev/null +++ b/apps/desktop/src/lib/setup-sheet.ts @@ -0,0 +1,232 @@ +import type { HudCatalogEntry, ProfileDetail } from "./bridge"; +import { comfigPresetLabel } from "./comfig-catalog"; +import type { ComfigUiState } from "./comfig-ui"; +import { crosshairLabel } from "./crosshair-labels"; +import { tf2ChoiceForFile } from "./crosshair-ui"; +import { formatCvarNumber, type GameplaySettings } from "./gameplay-ui"; +import { SETUP_GROUPS, type SettingsTab, type SetupGroupId } from "./settings-ui"; +import { hudLabel, soundLabel, TF2_OWN } from "./setups-ui"; + +/** One line on the Edit page: a setting, its current value, and the pane that changes it. */ +export type SheetRow = { + key: string; + label: string; + value: string; + /** Quieter words after the value. */ + more?: string; + tab: SettingsTab; +}; + +export type SheetGroup = { id: SetupGroupId; label: string; rows: SheetRow[] }; + +export type SheetInput = { + detail: ProfileDetail; + /** What the panes would show, or null while the startup cfgs cannot be read. */ + gameplay: GameplaySettings | null; + /** Keys bound by the startup cfgs, or null while they cannot be read. */ + boundKeys: number | null; + comfig: ComfigUiState; + launchOptions: string; + /** Config files the Files pane lists. */ + cfgFiles: number; + /** Casual setup choices beyond the mods in `detail`. */ + casualChoices: number; + catalog: readonly HudCatalogEntry[]; +}; + +/** Shown where a value comes from cfgs that could not be read. */ +export const UNREADABLE = "Can't read"; + +function plural(count: number, one: string, many = `${one}s`): string { + return `${count} ${count === 1 ? one : many}`; +} + +function percent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function hudRow({ detail, catalog }: SheetInput): SheetRow { + const options = Object.keys(detail.hud?.options ?? {}).length; + return { + key: "hud", + label: "HUD", + value: hudLabel(detail.hud?.id ?? null, catalog), + more: detail.hud && options > 0 ? `${plural(options, "option")} set` : undefined, + tab: "hud", + }; +} + +function crosshairRow({ detail, gameplay }: SheetInput): SheetRow { + const row = { key: "crosshair", label: "Crosshair", tab: "crosshair" as const }; + const pack = detail.crosshair && !detail.crosshair.inactive ? detail.crosshair : null; + if (pack) { + const designs: Record = pack.design ? { designed: pack.design } : {}; + // `id` names the pack; `shape` is the crosshair every weapon gets unless assigned another. + const picks = new Set([pack.shape, ...Object.values(pack.assignments)]); + return { + ...row, + value: crosshairLabel(pack.shape, designs), + more: picks.size > 1 ? "different per weapon" : undefined, + }; + } + if (!gameplay) return { ...row, value: UNREADABLE }; + const file = gameplay.cl_crosshair_file.trim(); + return { + ...row, + value: file === "" ? TF2_OWN : crosshairLabel(tf2ChoiceForFile(file)), + more: `size ${gameplay.cl_crosshair_scale}`, + }; +} + +function viewmodelRow({ detail, gameplay }: SheetInput): SheetRow { + const row = { key: "viewmodels", label: "Viewmodels", tab: "viewmodels" as const }; + if (!gameplay) return { ...row, value: UNREADABLE }; + const shown = + gameplay.r_drawviewmodel === 0 + ? "Hidden" + : gameplay.tf_use_min_viewmodels + ? "Minimal" + : "Shown"; + const more = [ + gameplay.r_drawviewmodel === 0 + ? null + : `field of view ${formatCvarNumber(gameplay.viewmodel_fov)}`, + gameplay.cl_flipviewmodels ? "left-handed" : null, + detail.viewmodel ? "own pack" : null, + ].filter((part) => part !== null); + return { ...row, value: shown, more: more.length ? more.join(" · ") : undefined }; +} + +function weaponsValue(gameplay: GameplaySettings): string | null { + const on = [ + gameplay.cl_autoreload ? "Auto reload" : null, + gameplay.hud_fastswitch === 1 ? "fast switch" : null, + gameplay.hud_fastswitch === 2 ? "controller switch" : null, + gameplay.tf_medigun_autoheal ? "auto heal" : null, + ].filter((part) => part !== null); + if (on.length === 0) return null; + const text = on.join(", "); + return text[0].toUpperCase() + text.slice(1); +} + +function feelRows({ gameplay, boundKeys }: SheetInput): SheetRow[] { + const keys: SheetRow = { + key: "binds", + label: "Keys", + value: boundKeys === null ? UNREADABLE : `${plural(boundKeys, "key")} bound`, + tab: "binds", + }; + if (!gameplay) { + return [ + { key: "fov", label: "Field of view", value: UNREADABLE, tab: "gameplay" }, + { key: "sensitivity", label: "Sensitivity", value: UNREADABLE, tab: "gameplay" }, + { key: "weapons", label: "Weapons", value: UNREADABLE, tab: "gameplay" }, + keys, + ]; + } + return [ + { + key: "fov", + label: "Field of view", + value: `${formatCvarNumber(gameplay.fov_desired)}°`, + tab: "gameplay", + }, + { + key: "sensitivity", + label: "Sensitivity", + value: formatCvarNumber(gameplay.sensitivity), + more: `zoomed ${formatCvarNumber(gameplay.zoom_sensitivity_ratio)}`, + tab: "gameplay", + }, + { + key: "weapons", + label: "Weapons", + value: weaponsValue(gameplay) ?? "No helpers", + tab: "gameplay", + }, + keys, + ]; +} + +function soundRows({ detail, gameplay }: SheetInput): SheetRow[] { + const sound = (on: boolean | null, name: string | undefined) => { + if (on === false) return "Off"; + return soundLabel(name ?? null); + }; + return [ + { + key: "volume", + label: "Volume", + value: gameplay ? percent(gameplay.volume) : UNREADABLE, + more: gameplay + ? gameplay.snd_musicvolume === 0 + ? "music off" + : `music ${percent(gameplay.snd_musicvolume)}` + : undefined, + tab: "sounds", + }, + { + key: "hit-sound", + label: "Hit sound", + value: sound(gameplay ? gameplay.tf_dingalingaling === 1 : null, detail.hitsound?.hit?.name), + tab: "sounds", + }, + { + key: "kill-sound", + label: "Kill sound", + value: sound( + gameplay ? gameplay.tf_dingalingaling_lasthit === 1 : null, + detail.hitsound?.kill?.name, + ), + tab: "sounds", + }, + ]; +} + +function modsRow({ detail, casualChoices }: SheetInput): SheetRow { + const names = (detail.mods ?? []).map((mod) => mod.name); + const casual = casualChoices > 0 ? plural(casualChoices, "Casual choice") : null; + const row = { key: "mods", label: "In this setup", tab: "mods" as const }; + if (names.length === 0) return { ...row, value: casual ?? "None" }; + return { ...row, value: names.join(", "), more: casual ?? undefined }; +} + +function performanceRow({ detail, comfig }: SheetInput): SheetRow { + const row = { key: "performance", label: "Config", tab: "comfig" as const }; + if (detail.layer !== "comfig") return { ...row, value: "No mastercomfig" }; + const addons = comfig.addons.length; + return { + ...row, + value: + comfig.preset === "none" + ? "mastercomfig, no preset" + : `mastercomfig ${comfigPresetLabel(comfig.preset)}`, + more: addons > 0 ? plural(addons, "addon") : undefined, + }; +} + +function advancedRows({ launchOptions, cfgFiles }: SheetInput): SheetRow[] { + const options = launchOptions.trim(); + return [ + { + key: "launch", + label: "Launch options", + value: options === "" ? "None" : options, + tab: "launch", + }, + { key: "files", label: "Config files", value: plural(cfgFiles, "file"), tab: "files" }, + ]; +} + +/** Every setting of the setup in the game, grouped the way the sidebar groups the panes. */ +export function sheetGroups(input: SheetInput): SheetGroup[] { + const rows: Record = { + look: [hudRow(input), crosshairRow(input), viewmodelRow(input)], + feel: feelRows(input), + sound: soundRows(input), + mods: [modsRow(input)], + performance: [performanceRow(input)], + advanced: advancedRows(input), + }; + return SETUP_GROUPS.map(({ id, label }) => ({ id, label, rows: rows[id] })); +} diff --git a/apps/desktop/src/lib/setups-ui.ts b/apps/desktop/src/lib/setups-ui.ts index 6ecf18ca..90236ee2 100644 --- a/apps/desktop/src/lib/setups-ui.ts +++ b/apps/desktop/src/lib/setups-ui.ts @@ -64,7 +64,7 @@ export function hudPicture(id: string | null, catalog: readonly HudCatalogEntry[ ); } -/** mastercomfig's preset aliases, said the way the Comfig pane says them. */ +/** mastercomfig's preset aliases, said the way the Performance pane says them. */ export function performanceLabel(alias: string | null): string { if (alias === null) return "Off"; const key = alias.trim().toLowerCase().replaceAll("-", "_"); @@ -76,7 +76,7 @@ export function modsLabel(count: number): string { return count === 1 ? "1 mod" : `${count} mods`; } -function soundLabel(id: string | null): string { +export function soundLabel(id: string | null): string { if (id === null) return TF2_OWN; // Names are kept as saved, so "Half-Life" stays hyphenated. const text = id diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b1ed30d8..98cb1bc6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -86,10 +86,12 @@ A remembered folder that no longer holds TF2 (a disconnected drive, a changed dr ## Panes -- **Setups** — the first entry in the sidebar and the page the workspace opens on (`SetupsHome.tsx`; the entry and its highlight are in `SettingsLayout.tsx`, the page state in `App.tsx`). A fixture that picks a pane (`?preview=settings-*`) opens on that pane instead. The panes stay mounted but hidden behind it, so drafts survive; Edit and the sidebar's pane entries leave it, App settings opens over it and returns to it, and its Guide button opens `profiles.md`. A list of the profiles sits beside the chosen one (the profile in the game at first; up and down move through the list). The chosen setup shows its HUD's catalog picture, drifting slowly (an in-game screenshot ahead of banners and menu shots; only the saved catalog is read, through `get_cached_hud_catalog`, so opening execs never fetches it or writes its cache) (a flat block with the HUD's name where there is none, never invented imagery), its name, and eight details: HUD, crosshair, field of view, sensitivity, hit sound, kill sound, performance (the mastercomfig preset) and mods. They come from `get_setup_overviews` (`setup_overview.rs`), a read-only command that reads each profile's library manifest and stored cfgs and writes nothing; it is read again on mount and whenever the library's ids, active profile or `updatedAt` change. Another setup's details that equal the profile in the game are dimmed, and the line "N changes from " (N is the count of details that differ) opens the header menu's Compare with current… dialog. The profile in the game offers Edit (its panes, on the remembered one) and Play; any other offers Switch and Switch and play. Switch is the header menu's switch, unsaved-edit guard included, and is unavailable, with one line saying why, while TF2 runs or a write is in flight. Switch and play runs that switch, waits for its progress to finish and, only if that profile is then the one in the game, runs the header's launch path (launch-options check, then `launch_tf2`); a switch that does not happen never launches. The page adds no writes of its own. -- **Backdrop** — every page (the ready shell's workspace, and first-run and library screens without the sidebar, where it stays anchored while the page scrolls) sits over a quiet dot field (`components/DotBackdrop.tsx`, `lib/dot-field.ts`, `lib/backdrop-knockout.ts`): the TF2 emblem as a halftone of execs' orange dots, anchored in the bottom-right corner and partly cropped by it, strongest at the corner and dissolving toward the page's text, over a faint neutral grid. Every dot that would touch a visible line of text or a control is switched off, so nothing is read over dots; a page-coloured layer with one pixel per dot, scaled up without smoothing so it never shades the page, follows the text through scrolling, content changes and transitions. The pointer stirs the dots: those within reach slide aside and a little along its motion, then spring back once it rests or leaves. They brighten only slightly and keep their colour; there is no trail, glow or lens. The field is painted once per size and only the stirred patch is redrawn; frames run only while dots or text move (capped near 120 per second), and reduced motion keeps the still image. The installed app reopens the pane it last showed (Comfig the first time; browser previews use their fixture pane) and restores the main window's size, position and maximized state from `settings.json` (`window`, additive), unless the saved position is no longer on a connected monitor. -- Panes never fold content away. A pane with more than one job splits into named views under its title (HUD: Browse and Installed; Mods: Browse, Custom packs and Casual setup; Comfig: Preset and addons, and Modules, whose tab counts the modules that differ from the preset), and each view shows everything it holds. Long lists are grouped under headings and narrowed with search or filters, never collapsed. -- **Comfig** — offers mastercomfig's current Ultra, High, Medium, Low and Custom presets, modules and official addons through our UI. Removed Medium High and Medium Low and delisted Destitute remain readable on existing profiles but are not offered for new selection; no legacy choice changes automatically. VPKs come from mastercomfig's GitHub releases; writes `tf/cfg/overrides/{setup_hook,modules}.cfg`. The comfig.app customizer and the mastercomfig guide open in an in-app `WebviewWindow` with no IPC; the customizer's `comfig-custom` folder is added from a folder picker. The packages row offers one update button only when a newer release exists (or packages are missing). Credit comfig.app; never brand as official. +- **Setups** — the first entry in the sidebar and the page the workspace opens on (`SetupsHome.tsx`; the entry and its highlight are in `SettingsLayout.tsx`, the page state in `App.tsx`). A fixture that picks a pane (`?preview=settings-*`) opens on that pane instead. The panes stay mounted but hidden behind it, so drafts survive; Edit and the sidebar's pane entries leave it, App settings opens over it and returns to it, and its Guide button opens `profiles.md`. A list of the profiles sits beside the chosen one (the profile in the game at first; up and down move through the list). The chosen setup shows its HUD's catalog picture, drifting slowly (an in-game screenshot ahead of banners and menu shots; only the saved catalog is read, through `get_cached_hud_catalog`, so opening execs never fetches it or writes its cache) (a flat block with the HUD's name where there is none, never invented imagery), its name, and eight details: HUD, crosshair, field of view, sensitivity, hit sound, kill sound, performance (the mastercomfig preset) and mods. They come from `get_setup_overviews` (`setup_overview.rs`), a read-only command that reads each profile's library manifest and stored cfgs and writes nothing; it is read again on mount and whenever the library's ids, active profile or `updatedAt` change. Another setup's details that equal the profile in the game are dimmed, and the line "N changes from " (N is the count of details that differ) opens the header menu's Compare with current… dialog. The profile in the game offers Edit (the Edit page) and Play; any other offers Switch and Switch and play. Switch is the header menu's switch, unsaved-edit guard included, and is unavailable, with one line saying why, while TF2 runs or a write is in flight. Switch and play runs that switch, waits for its progress to finish and, only if that profile is then the one in the game, runs the header's launch path (launch-options check, then `launch_tf2`); a switch that does not happen never launches. The page adds no writes of its own. +- **Edit page** — every setting of the profile in the game on one page (`SetupSheet.tsx`, rows built by `lib/setup-sheet.ts`), opened by the Setups page's Edit and by the sidebar entry under Setups, labelled Edit . `SettingsHost` renders it in place of the pane (its `sheet` prop; the panes stay mounted and hidden, keeping drafts) from the snapshot the panes already read: the profile detail, the effective startup cfg values seeded the way the panes seed them, mastercomfig state and the saved launch options. Mods' Casual setup choices are counted from `get_setup_overviews`, and the HUD's name and picture come from the saved catalog only. It writes nothing. Groups and their order come from `SETUP_GROUPS` (`lib/settings-ui.ts`): Look (HUD, crosshair, viewmodels), Feel (field of view, sensitivity, weapon helpers, keys bound), Sound (volume, hit sound, kill sound), Mods, Performance (preset and addons, or No mastercomfig) and Advanced (launch options, config files). Each row opens its pane; a pane with a waiting draft shows the change dot on its rows. When the startup cfgs are incomplete or unreadable, cfg-derived rows say Can't read and the cfg alert with Review in Files shows above the page. Its Guide button opens `profiles.md`; `?preview=settings-edit` opens it with a HUD, crosshair pack, hit sound and mods. +- **Sidebar** — Setups and the Edit entry, then the setup's groups from `SETUP_GROUPS`: Look (HUD, Crosshair, Viewmodels), Feel (Gameplay, Binds), one unheaded group of the one-pane groups (Sounds, Mods, Performance; a heading there would only repeat the pane's name), Advanced (Files, Launch), then Steam (Inventory, which belongs to no profile). Guide and App settings sit below. Everything fits at 1200×800 without scrolling, with the TF2-running bar shown too. +- **Backdrop** — every page (the ready shell's workspace, and first-run and library screens without the sidebar, where it stays anchored while the page scrolls) sits over a quiet dot field (`components/DotBackdrop.tsx`, `lib/dot-field.ts`, `lib/backdrop-knockout.ts`): the TF2 emblem as a halftone of execs' orange dots, anchored in the bottom-right corner and partly cropped by it, strongest at the corner and dissolving toward the page's text, over a faint neutral grid. Every dot that would touch a visible line of text or a control is switched off, so nothing is read over dots; a page-coloured layer with one pixel per dot, scaled up without smoothing so it never shades the page, follows the text through scrolling, content changes and transitions. The pointer stirs the dots: those within reach slide aside and a little along its motion, then spring back once it rests or leaves. They brighten only slightly and keep their colour; there is no trail, glow or lens. The field is painted once per size and only the stirred patch is redrawn; frames run only while dots or text move (capped near 120 per second), and reduced motion keeps the still image. The installed app reopens the pane it last showed (Performance the first time; browser previews use their fixture pane) and restores the main window's size, position and maximized state from `settings.json` (`window`, additive), unless the saved position is no longer on a connected monitor. +- Panes never fold content away. A pane with more than one job splits into named views under its title (HUD: Browse and Installed; Mods: Browse, Custom packs and Casual setup; Performance: Preset and addons, and Modules, whose tab counts the modules that differ from the preset), and each view shows everything it holds. Long lists are grouped under headings and narrowed with search or filters, never collapsed. +- **Performance** (internally `comfig`: tab id, `ComfigPane.tsx`, `docs/guide/comfig.md`) — offers mastercomfig's current Ultra, High, Medium, Low and Custom presets, modules and official addons through our UI. Removed Medium High and Medium Low and delisted Destitute remain readable on existing profiles but are not offered for new selection; no legacy choice changes automatically. VPKs come from mastercomfig's GitHub releases; writes `tf/cfg/overrides/{setup_hook,modules}.cfg`. The comfig.app customizer and the mastercomfig guide open in an in-app `WebviewWindow` with no IPC; the customizer's `comfig-custom` folder is added from a folder picker. The packages row offers one update button only when a newer release exists (or packages are missing). Credit comfig.app; never brand as official. - A profile whose layer is vanilla shows one plain explanation and a Try mastercomfig in a new profile button (New profile with Start from: Current setup and the name Trying mastercomfig) instead of disabled controls; the player's profile is never converted in place. A mastercomfig profile with an unrecognised loader keeps the short leave-it-alone note. - **Binds** — click-to-record for the usual actions; writes `execs_binds.cfg`. Standalone, uncommented binds for known actions in that file belong to the pane; sync and recording retain every other line byte-for-byte. Actions are grouped by player task under an All view with category tabs and a search that matches action names, player words and bound keys. Each action shows its inspected startup keys as readable key caps; the CFG line that last assigned a key is in its tooltip and accessible name, and every key offers remove. Voice-command actions use `voicemenu ` from the pinned dump. Recording adds a key without silently removing other keys; a key already running a different command requires review before its new assignment. Remove leaves the key running nothing: it deletes the pane's own line when nothing earlier binds the key, otherwise it writes one standalone `unbind ` (also pane-owned) so an inherited `config.cfg` binding never comes back; a key a later startup cfg binds again is explained and left unchanged. Recording the key again replaces that unbind, and the after-game sync drops it only when TF2 bound the key again. Class, launch and in-game commands may change the runtime result. Autoexec exec lines are addressed from `tf/cfg` (`exec overrides/execs_binds`): the engine resolves `exec` per search path, so a bare stem inside `overrides/` silently fails (field bug). No alias studio, no generated `unbindall`. Binds are written with TF2's own key names (`semicolon`, `'`, `,`, `rshift`, `pause`, …). - **Gameplay** — world FOV, first-person tracers (never the cheat-only `r_drawtracers`), mouse sensitivity, weapon and combat-feedback comfort toggles; writes `execs_gameplay.cfg`, and links to Viewmodels for viewmodel settings. Transparent viewmodels is an addon VPK toggle, never a cvar. @@ -109,7 +111,7 @@ A remembered folder that no longer holds TF2 (a disconnected drive, a changed dr - **Crosshair** — one gallery of TF2's own crosshairs (`tf-default`, `tf-crosshair1`–`7`, previews decoded from the user's own `tf2_textures_dir.vpk`), execs shapes and the player's library (designs, named PNG and VTF imports). execs shapes are designer presets (`shape-*`, `lib/crosshair-presets.ts`) rendered by the app and sent as 64×64 RGBA library entries by every build that uses them; earlier built-in shapes (`dot`, `cross`, `plus-gap`, `circle`, `t`, `execs-*`) stay buildable and appear only while a profile uses one. Per-weapon choices sit on the page: class tabs, the class's weapons, and the selected weapon's choices as pictures beside them (no drop-down menus). A TF2 choice with no per-weapon exception is just `cl_crosshair_file` and autosaves with size and colour; anything else needs the custom pack, written only when the player applies the draft from the pane's apply bar, and applying a changed TF2-only draft on a live pack switches the pack off (`deactivate_crosshairs` with the chosen stock file and size in the same transaction). The pack's state and Remove pack sit in a Crosshair pack section at the end of the pane. Inside a pack, `tf-crosshairN` patches weapon scripts to Valve's own `vgui/crosshairs/crosshairN` material at its real VTF dimensions (no Valve art copied) and `tf-default` omits that weapon's script. The custom builder writes `tf/custom/execs-crosshairs/` (VTFs + patched weapon scripts read from the user's `tf2_misc_dir.vpk`, ICE-decrypted in process). New choices come from first-party shapes, the parametric designer (rotation and smooth edges render at 4× and keep older designs pixel-identical) and imported PNG/VTF. The Venom remote picker and native fetch commands are retired while previously saved VTFs remain selectable and rebuild from profile-owned bytes; missing bytes fail the build without replacing the pack. Tint is cvar-based (`cl_crosshair_red/green/blue`), textures stay white; apply forces `cl_crosshair_file ""`. VTF header: bumpScale@48, format@52, mips@56. - The crosshair preview is true to size. TF2 draws crosshairs in screen pixels with no resolution scaling (Source SDK 2013 `CHudCrosshair::Paint` / `CHudTFCrosshair::Paint`): a weapon-script sprite covers width × height × `cl_crosshair_scale` / 32, a `cl_crosshair_file` sprite 2 × round(scale). The preview resolves the game resolution from the player's choice (app-wide `localStorage`), then `-w`/`-h` launch options, then TF2's saved video setting (`get_game_resolution`: Windows registry `HKCU\Software\Valve\Source\tf\Settings`, Steam's `registry.vdf` elsewhere; read-only), then the screen. Actual size divides by `devicePixelRatio` (Windows scaling and app zoom) and stretches only fullscreen resolutions that fit inside the screen. The game-pixel bitmap is resampled the way TF2 does: point sampling for execs' VTFs, filtering for Valve's. - Crosshair settings preserve unknown external `cl_crosshair_file` values through unrelated edits. Build refuses unreadable or unpatched requested weapon scripts; Build, Mod import, and switch preflight refuse exact script-path collisions found by bounded custom-content inspection. A saved SHA-256 of installed Valve weapon scripts warns when the source later changes or cannot be verified. Stock-art and HUD overlay notices describe installed candidates, never a confirmed in-game winner. -- **Viewmodels** — opens with In game controls: viewmodel FOV, Draw viewmodel, Min viewmodels, left-handed viewmodels and the Comfig transparent-viewmodels addon toggle (the same addon state as Comfig, disabled while TF2 runs or busy and outside Comfig profiles). The four cvars stay in `execs_gameplay.cfg` under a separate `viewmodels` managed-cfg scope that native `managed_cfg` and the frontend agree on, so Gameplay, Crosshair, Sounds and Viewmodels saves never rewrite each other's lines. They autosave like Gameplay and wait for a complete startup cfg read. Per weapon follows below. Every class… previews a whole-profile Show all, Hide all or Keep melee visible choice (Hidden or Hands only) as the exact list of rows that change, applies it only to the draft, and offers Undo until the next edit; melee inspect rows count as melee and shared-animation conflicts are disclosed before applying. It builds `tf/custom/execs-viewmodels.vpk` locally from the player's installed class animation MDLs: per class, one Shown / Hidden / Hands only choice per loadout slot (Primary, Secondary, Melee, PDA) and one Shown / Hidden choice covering every inspect animation; a slot reads as what most of its weapons use (a tie follows the stock weapon), and changing it moves only the weapons that follow it. Each class is one table under shared Shown / Hidden / Hands only columns: a slot with several weapon groups lists them beneath it, each with its own choice, and spellbooks and the grappling hook (no slot) sit under Other. A group without a choice stays shown: an animation it shares with a hidden group is left untouched (Soldier's Concheror reuses the Shovel's swings), and each class names hidden rows that stay partly visible this way; a selection whose every animation is shared with a shown group is refused. Shared animations set to Hidden and Hands only at once still refuse the build. The catalog identity includes this rule's version, so a pack built under an earlier rule reads as outdated and its choices return as an unbuilt draft. Groups are derived from installed sources without CompVMInstaller data, and the build recipe still records exact per-group choices. Applying from the pane's apply bar builds the pack (release builds included, without rendered previews); applying an all-shown draft over a built pack removes it. Profile export carries the build recipe and output digest, never the generated model bytes; import rebuilds from the recipient's own install and refuses source differences. It also imports, replaces and removes a user-provided compatible VPK, and earlier Yttrium-style packs retain their saved bytes and options through profile switch and export/import; legacy build and preview commands fail before I/O. Do not copy CompVMInstaller's source archive or group table. Custom viewmodels on Valve Casual need the profile's `+exec` preload launch option (see Launch). +- **Viewmodels** — opens with In game controls: viewmodel FOV, Draw viewmodel, Min viewmodels, left-handed viewmodels and the mastercomfig transparent-viewmodels addon toggle (the same addon state as Performance, disabled while TF2 runs or busy and outside mastercomfig profiles). The four cvars stay in `execs_gameplay.cfg` under a separate `viewmodels` managed-cfg scope that native `managed_cfg` and the frontend agree on, so Gameplay, Crosshair, Sounds and Viewmodels saves never rewrite each other's lines. They autosave like Gameplay and wait for a complete startup cfg read. Per weapon follows below. Every class… previews a whole-profile Show all, Hide all or Keep melee visible choice (Hidden or Hands only) as the exact list of rows that change, applies it only to the draft, and offers Undo until the next edit; melee inspect rows count as melee and shared-animation conflicts are disclosed before applying. It builds `tf/custom/execs-viewmodels.vpk` locally from the player's installed class animation MDLs: per class, one Shown / Hidden / Hands only choice per loadout slot (Primary, Secondary, Melee, PDA) and one Shown / Hidden choice covering every inspect animation; a slot reads as what most of its weapons use (a tie follows the stock weapon), and changing it moves only the weapons that follow it. Each class is one table under shared Shown / Hidden / Hands only columns: a slot with several weapon groups lists them beneath it, each with its own choice, and spellbooks and the grappling hook (no slot) sit under Other. A group without a choice stays shown: an animation it shares with a hidden group is left untouched (Soldier's Concheror reuses the Shovel's swings), and each class names hidden rows that stay partly visible this way; a selection whose every animation is shared with a shown group is refused. Shared animations set to Hidden and Hands only at once still refuse the build. The catalog identity includes this rule's version, so a pack built under an earlier rule reads as outdated and its choices return as an unbuilt draft. Groups are derived from installed sources without CompVMInstaller data, and the build recipe still records exact per-group choices. Applying from the pane's apply bar builds the pack (release builds included, without rendered previews); applying an all-shown draft over a built pack removes it. Profile export carries the build recipe and output digest, never the generated model bytes; import rebuilds from the recipient's own install and refuses source differences. It also imports, replaces and removes a user-provided compatible VPK, and earlier Yttrium-style packs retain their saved bytes and options through profile switch and export/import; legacy build and preview commands fail before I/O. Do not copy CompVMInstaller's source archive or group table. Custom viewmodels on Valve Casual need the profile's `+exec` preload launch option (see Launch). - **Sounds** — hit and kill sounds are exactly `tf/custom/execs-hitsounds/sound/ui/{hitsound,killsound}.wav` (the two names sv_pure exempts). Engine formats: 8/16-bit PCM or 4-bit MS-ADPCM at 11025/22050/44100 Hz; other WAVs are re-encoded, MP3-as-WAV refused. New choices are stock effects from the user's `tf2_sound_misc_dir.vpk`, the user's own WAV, MP3 or Ogg Vorbis clip (decoded by Symphonia in `audio_decode.rs`, at most 30 seconds, then prepared like a WAV; clips over a second carry a note), GameBanana hit and kill sound uploads handed over from Mods (listed as From GameBanana and saved like the player's own file), and comfig.app's hits library, the one retired catalog that came back. Its index is pinned to comfig-app commit `0dd0b076`, fetched from GitHub raw and cached by commit; sounds download on preview or use from `hits.comfig.app/<128-hex id>.wav` (an opaque id, not a byte digest), are validated as MS-ADPCM and cached as rebuildable downloads, and a boosted comfig sound re-encodes from its upload. The TF2Hitsounds catalog stays retired; its installed WAVs stay playable, switchable and exportable, but their baked boost cannot change. The searchable library always chooses for one slot: a Hit sound / Kill sound switch, set by each slot's Browse, drives one Preview, one favorite star and one Use action per row ("Use [sound] for hits"). Show filters All, Favorites, Built into TF2 and comfig.app (your own file stays listed); Sort offers Suggested (yours, TF2's effects, comfig uploads made for the slot being chosen, then the rest, each in its source's own order), A to Z and Z to A. comfig publishes only names and ids, so there are no date or popularity sorts. Favorites are app-wide `localStorage`, never profile data. A compact preview dock floats at the bottom of Sounds with a preview volume (default 50%, app-wide `localStorage`) applied on top of each slot's Volume, a mute toggle and Stop; it never changes cvars or files. A selected Use stays focusable, and a row names the other slot when it holds that sound. **Boost** (0/6/12 dB, tanh soft clip) re-encodes a user-provided or comfig.app file louder because the engine caps `tf_dingaling_volume` at 1. A saved custom WAV remains dormant when a nonzero built-in effect plays, and Sounds offers Use saved sound; selecting the default file effect or explicitly removing or replacing the file clears it. `sound.cache` is deleted on apply. Sound CVars, autoexec wiring, WAVs and the hitsound record commit in one recoverable profile transaction. Accepted external managed WAV drift marks sound provenance changed until every old installed slot is replaced or removed; Sounds shows a repair warning. Sounds scans other mounted `tf/custom` packs for the two canonical virtual paths and reports candidate competitors and incomplete scans; it does not claim which file TF2 plays in-game. - Sounds also owns TF2's Game volume (`volume`) and Music volume (`snd_musicvolume`): two 0–100% sliders at the top of the pane write them into the profile's `execs_gameplay.cfg` under the `sounds` managed-cfg scope (native `managed_cfg` and the frontend agree), so they run after `config.cfg` and a replaced or default `config.cfg` cannot reset them. Values keep TF2's exact decimals until a slider moves. The after-game sync updates these lines when TF2's options changed them in `config.cfg` (0–1 only) and never adds them; Copy sound settings carries them. - **Mods** opens on three task surfaces: **Browse**, **Custom packs**, and **Casual setup**. Browse uses GameBanana game id **297** through `Mod/Index`, or `Sound/Index` when its Mods / Sounds switch is on Sounds, for listings and name search; each section has its own categories. GameBanana numbers mods and sounds separately, so every id travels with its section, and a saved record names it in its page URL (`/mods/` or `/sounds/`), which update checks follow; `Generic_Name=contains,`, a selected `Generic_Category`, and mature-off `Generic_ContentRatings=-` compose server-side. New is the default; Popular, Likes, Views, Updated and New preserve upstream order and map to `Generic_MostDownloaded`, `Generic_MostLiked`, `Generic_MostViewed`, `Generic_LatestUpdated` and `Generic_Newest`. Added and updated dates stay distinct, unavailable metrics remain unknown, and All-category totals are estimates because non-GUI excluded roots are filtered locally. GUI results in the HUDs subcategory route to HUD; every other GUI mod (menus, icons, fonts, scoreboards, loading screens) installs like any other mod, while the installer still refuses a payload that is a HUD. Direct installs read the mod's immediate category to tell the two apart. Maps install too (Decal Tool, Prefabs and Serverside Weapons stay hidden): a `.bsp` outside any content folder goes into the pack's `maps` folder with the files beside it named after it (`.nav`, `_*.txt`), and `_*.pop` MvM missions go into `scripts/population`. Several maps are separate choices, a map beside content folders joins that pack, and map cards say how to play one (console `map `). A GameBanana download with one installable choice installs under the mod's title; greyed-out choices beside it do not count. Completion, not the visible row count, controls Next. Native and renderer caches are bounded with a ten-minute fresh lifetime; Refresh and Retry bypass both while preserving the request. Cards identify GameBanana explicitly, paging appears above and below results, and one Import mod flow accepts a VPK/archive or extracted folder. **Custom packs** holds `ModRecord`s over ordinary `tf/custom` packs, including accepted external packs; Single-volume RAR4/RAR5 archives are supported; multi-part VPKs and unsupported archive features are refused with a message. In the chooser, a split, leftover or unreadable VPK greys out only that choice with its reason; it never fails the rest of the archive. Content folders are `materials`, `models`, `sound`, `particles`, `scripts`, `resource`, `cfg`, `maps` and `media` (startup videos). **Casual setup** owns the itemtest preload cfg, gameinfo bypass, four direct-author addon choices, saved legacy cueki choices from an exact verified local `mods.zip` cache, and installed profile mods that carry `particles/*.pcf` as particle sources. Duplicate-carrier PCFs are rebuilt from the user's own vanilla files; `blood_trail` maps to `npc_fx`. A selected particle source cannot be removed directly or accepted as an external removal until its selection changes. A resized `tf2_misc` (TF2 update) judges every entry by its stock CRC; untracked modified entries are repaired through `steam://validate/440`. @@ -139,7 +141,7 @@ A remembered folder that no longer holds TF2 (a disconnected drive, a changed dr - Completed after-game config drift reconciles supported TF2 menu values for Gameplay, Viewmodels, Sounds and Crosshair through separate scope validators. Only existing managed scalar assignments change, with value-token edits preserving comments, quotes and semicolon neighbors. Missing or invalid values are not inserted or adopted. - Binds includes custom command rows checked by cfglint and the existing conflict review. Only explicitly marked custom lines become pane-owned; unrelated user-authored commands remain untouched. Class actions cover Engineer build/destroy and explicit-team Spy disguises; voice choices follow Valve's installed menu definitions. -- Comfig writes require a supported profile loader and matching active live layer. Vanilla profiles show no selected preset and refuse package/preset/module/addon/import writes until cfg migration is supported. Optional release metadata records exact package hashes; addons download from that release, while unknown legacy identities require a full explicit package update. Update checks are profile-bound and read-only. +- Performance writes require a supported profile loader and matching active live layer. Vanilla profiles show no selected preset and refuse package/preset/module/addon/import writes until cfg migration is supported. Optional release metadata records exact package hashes; addons download from that release, while unknown legacy identities require a full explicit package update. Update checks are profile-bound and read-only. - Custom packs can be disabled into tracked `execs-inactive-/content/` containers, outside mounted content roots, through the profile transaction. `inactivePack` is additive metadata retaining the original name. Enabling rechecks collisions and live drift; selected particle sources must first be unselected. Copy uses verified saved bytes and an inactive destination. GameBanana update checks use its published update timestamp, with unknown results kept explicit. - Material bypass intent is install-bound in journaled preloader state. A reset by TF2 produces a Mods notice and explicit recovery action; normal status reads never rewrite game files. Pre-upgrade intent that was already reset cannot be inferred. - HUD replacement skips a live backup only when the complete tree exactly matches verified profile-owned bytes, rechecked before commit. Untracked or changed trees remain recoverable. Storage lists historical live and library copies with fingerprints and available dates, restores exact bytes to a new user-chosen external folder, and deletes only after reviewed fingerprint and containment checks. Recovery never automatically activates another HUD. @@ -268,7 +270,7 @@ These requirements originated in earlier releases and continue to constrain comp - Installed sound auditions read current bytes on each play, invalidate on profile or installed-record changes, and revoke their transient URLs. Draft ownership remains the stable profile/slot identity. Catalog assignment matches source hashes/tokens; legacy unknown identities do not match by display name. - Crosshair builds release draft asset bytes only after a confirmed successful host result. Sounds drafts belong to the profile and sound slots; a changed installed record acknowledges content without resetting newer edits. -- Comfig controls and previews show the complete persisted snapshot. Failed preset, module and addon selections stay available to retry and never enter later write payloads as if saved. +- Performance controls and previews show the complete persisted snapshot. Failed preset, module and addon selections stay available to retry and never enter later write payloads as if saved. - Import picker cancellation remains `null` through the bridge and write runner. Cancelled imports skip reload and completion feedback; picker commands reserve no Saving indicator while the user is choosing. ### 0.1.5 cfg integrity diff --git a/docs/guide/README.md b/docs/guide/README.md index 52318a26..aeb4ed40 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -3,15 +3,15 @@ How execs works, one page per part of the app. The app itself keeps to short labels; the details are here. The **Guide** button in the app's sidebar opens the page for the pane you are on. - [Installing and removing](install.md): downloads, the Windows warning, Linux, where files live -- [Profiles](profiles.md): saving, switching, comparing and restoring your setups -- [Comfig](comfig.md): mastercomfig presets, addons and modules -- [Binds](binds.md) -- [Gameplay](gameplay.md): FOV, mouse, weapons and combat feedback +- [Profiles](profiles.md): the Setups and Edit pages, saving, switching, comparing and restoring your setups - [HUD](hud.md) - [Crosshair](crosshair.md) - [Viewmodels](viewmodels.md) +- [Gameplay](gameplay.md): FOV, mouse, weapons and combat feedback +- [Binds](binds.md) - [Sounds](sounds.md): game and music volume, hit and kill sounds - [Mods](mods.md): GameBanana, custom packs and Casual setup +- [Performance](comfig.md): mastercomfig presets, addons and modules - [Files](files.md): editing cfgs directly - [Launch](launch.md): launch options - [Inventory](inventory.md) diff --git a/docs/guide/comfig.md b/docs/guide/comfig.md index c3c4e5c3..889c22b5 100644 --- a/docs/guide/comfig.md +++ b/docs/guide/comfig.md @@ -1,6 +1,6 @@ -# Comfig +# Performance -Comfig manages [mastercomfig](https://comfig.app) for the active profile: the preset, official addons and individual modules. execs downloads mastercomfig's own release packages; it is not affiliated with mastercomfig. +Performance manages [mastercomfig](https://comfig.app) for the active profile: the preset, official addons and individual modules. execs downloads mastercomfig's own release packages; it is not affiliated with mastercomfig. ## Presets diff --git a/docs/guide/profiles.md b/docs/guide/profiles.md index d9ded15f..88b6d0cb 100644 --- a/docs/guide/profiles.md +++ b/docs/guide/profiles.md @@ -15,13 +15,28 @@ If TF2 has not been played on this PC for a while, its `config.cfg` may exist on Setups is the first entry in the sidebar, and where execs opens. The list shows every profile as a setup with its HUD. Pick one to see it large, with its HUD's picture and its main details: HUD, crosshair, field of view, sensitivity, hit and kill sounds, performance and mods. -- The setup in the game is marked **In the game now**. **Edit** opens its panes, on the one you used last, and **Play** starts TF2. +- The setup in the game is marked **In the game now**. **Edit** opens its Edit page, and **Play** starts TF2. - For any other setup, **Switch** puts it in the game, and **Switch and play** switches, then starts TF2. - A line like "3 changes from Main" counts how many of those details differ from the setup in the game. Click it to open **Compare with current…**. Details shown in grey are the same as now. - **New setup**, at the end of the list, makes a new profile. Switching works only while TF2 is closed. With TF2 running, the buttons stay off and say so. +## Edit page + +The Edit page lists every setting of the setup in the game on one page, each with its current value. **Edit** on the Setups page opens it, and so does the **Edit** entry under Setups in the sidebar, which names the setup. Settings are grouped the way the sidebar groups the pages that change them: + +| Group | Settings | Opens | +|---|---|---| +| Look | HUD, crosshair, viewmodels | HUD, Crosshair, Viewmodels | +| Feel | Field of view, sensitivity, weapon helpers, keys | Gameplay, Binds | +| Sound | Volume, hit sound, kill sound | Sounds | +| Mods | Mods in the setup and Casual setup choices | Mods | +| Performance | The mastercomfig preset and addons | Performance | +| Advanced | Launch options, config files | Launch, Files | + +Click a row to open the page that changes it. The Edit page only shows values; it changes nothing itself. A row whose page has changes that are not saved yet shows the orange dot. If execs can't read the setup's cfg files, rows that come from them say **Can't read**, and **Review in Files** shows the file at fault. + ## Switching Switching replaces the whole live setup with the chosen profile, exactly. Files from the old profile are removed only if they are unchanged, then the new profile's files are written. If a switch is ever cut off, execs says so and lets you finish it.