From 52b8d2150d689dd0d29f2005217bbda6c061e889 Mon Sep 17 00:00:00 2001 From: CJ Lees Date: Fri, 2 Oct 2026 04:08:48 +0000 Subject: [PATCH] feat: add native GPUI desktop alternative and evaluation workflow --- .github/workflows/gpui-build.yml | 64 + .gitignore | 5 +- README.md | 9 + docs/gpui-evaluation.md | 176 + docs/images/gpui-dashboard.png | Bin 0 -> 78252 bytes native/gpui/Cargo.lock | 8047 ++++++++++++++++++++++++++++++ native/gpui/Cargo.toml | 18 + native/gpui/README.md | 89 + native/gpui/rust-toolchain.toml | 4 + native/gpui/src/backend.rs | 243 + native/gpui/src/main.rs | 58 + native/gpui/src/ui.rs | 798 +++ scripts/gpui.py | 125 + scripts/measure_desktop.py | 71 + scripts/package_gpui.py | 29 + scripts/smoke_gpui_linux.py | 92 + scripts/test_gpui.py | 63 + 17 files changed, 9890 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/gpui-build.yml create mode 100644 docs/gpui-evaluation.md create mode 100644 docs/images/gpui-dashboard.png create mode 100644 native/gpui/Cargo.lock create mode 100644 native/gpui/Cargo.toml create mode 100644 native/gpui/README.md create mode 100644 native/gpui/rust-toolchain.toml create mode 100644 native/gpui/src/backend.rs create mode 100644 native/gpui/src/main.rs create mode 100644 native/gpui/src/ui.rs create mode 100644 scripts/gpui.py create mode 100644 scripts/measure_desktop.py create mode 100644 scripts/package_gpui.py create mode 100644 scripts/smoke_gpui_linux.py create mode 100644 scripts/test_gpui.py diff --git a/.github/workflows/gpui-build.yml b/.github/workflows/gpui-build.yml new file mode 100644 index 0000000..814dd6f --- /dev/null +++ b/.github/workflows/gpui-build.yml @@ -0,0 +1,64 @@ +name: GPUI Alternative + +on: + pull_request: + paths: + - 'native/gpui/**' + - 'scripts/*gpui*.py' + - 'scripts/measure_desktop.py' + - 'backend/**' + - '*.py' + - 'requirements.txt' + - 'presets.json' + - 'docs/gpui-evaluation.md' + - '.github/workflows/gpui-build.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + native: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2022] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.99.0' + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: native/gpui + - name: Linux native dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libfontconfig1-dev libfreetype6-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libvulkan-dev libx11-xcb-dev libxcb1-dev libasound2-dev libclang-dev mesa-vulkan-drivers xvfb xauth openbox xcompmgr xdotool + - name: Python lifecycle tests + run: python -m unittest scripts/test_gpui.py + - name: Format + run: cargo fmt --manifest-path native/gpui/Cargo.toml --check + - name: Lint + run: cargo clippy --locked --manifest-path native/gpui/Cargo.toml --all-targets -- -D warnings + - name: Rust tests + run: python scripts/gpui.py test + - name: Build release + run: python scripts/gpui.py build --release + - name: Linux native window lifecycle regression check + if: runner.os == 'Linux' + run: xvfb-run -a python scripts/smoke_gpui_linux.py --start-session --executable native/gpui/target/release/patchopsiii-gpui + - name: Archive Linux evaluation build + if: runner.os == 'Linux' + run: python scripts/package_gpui.py --executable native/gpui/target/release/patchopsiii-gpui --output dist/gpui/PatchOpsIII-gpui-linux.zip + - name: Archive Windows evaluation build + if: runner.os == 'Windows' + run: python scripts/package_gpui.py --executable native/gpui/target/release/patchopsiii-gpui.exe --output dist/gpui/PatchOpsIII-gpui-windows.zip + - uses: actions/upload-artifact@v4 + with: + name: PatchOpsIII-gpui-${{ runner.os }} + path: dist/gpui/*.zip diff --git a/.gitignore b/.gitignore index 5dc83a9..95593d2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ node_modules/ *.vdf # codex: -.codex/ \ No newline at end of file +.codex/ + +# Standalone GPUI build output (Cargo.lock is committed) +native/gpui/target/ diff --git a/README.md b/README.md index 6d08e37..dee7975 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - [Advanced Tab](#advanced-tab) - [Terminal & Logging](#terminal--logging) - [Installation](#installation) +- [Native GPUI evaluation](#native-gpui-evaluation) - [Forked Components](#forked-components) - [Quick Start](#quick-start) - [Screenshots](#screenshots) @@ -84,7 +85,15 @@ bun run dev:desktop The browser development server uses Vite on `127.0.0.1:5173` and the Python API on `127.0.0.1:8765`. The Electron desktop command uses Vite on `127.0.0.1:5174` and its Python API on `127.0.0.1:8766`, so both commands can run at the same time. The renderer communicates with Python through HTTP APIs and `/ws` WebSockets; Electron IPC is reserved for desktop-specific bridge actions such as selecting a local game directory. +## Native GPUI evaluation + +An independent Rust/GPUI desktop alternative lives in [native/gpui](native/gpui/README.md). +It uses native controls and the current Python operations alongside Electron. +See [the research and evaluation plan](docs/gpui-evaluation.md) for comparisons +with the Tauri alternatives, supported workflows, build instructions, and parity gaps. + ## Forked Components + - **BO3 Enhanced Proton fork metadata:** [bo3-enhanced-proton/README.md](bo3-enhanced-proton/README.md) - Upstream source: https://github.com/Weather-OS/GDK-Proton - Current base release: `release10-32` diff --git a/docs/gpui-evaluation.md b/docs/gpui-evaluation.md new file mode 100644 index 0000000..b34b88a --- /dev/null +++ b/docs/gpui-evaluation.md @@ -0,0 +1,176 @@ +# GPUI research and evaluation plan + +Research date: 2026-10-02. Baseline: the current `main` Electron/React/Python app. +Related alternatives: [Tauri shell + Rust core PR #36](https://github.com/boggedbrush/PatchOpsIII/pull/36) +and [in-process Rust/Tauri PR #40](https://github.com/boggedbrush/PatchOpsIII/pull/40). +This GPUI branch starts from `main`, independently of both Tauri branches. + +## Findings and architecture decision + +GPUI is Zed's GPU-accelerated Rust framework, combining retained entity state +with declarative view rendering. It is pre-1.0 and APIs can break between +versions. It replaces the renderer as well as the desktop shell: our React, +CSS, browser accessibility, DOM tests, and Electron IPC cannot simply transfer. +The upstream main README now describes a separate `gpui_platform` package; +the published GPUI 0.2.2 selected here still uses `Application::new()` and +includes the platform implementations. Mixing these APIs would break builds. + +GPUI Component supplies keyboard-aware buttons and text inputs, text editing, +focus handling, and the root/theme setup, reducing custom control work. Pinning +the compatible GPUI 0.2.2 / Component 0.5.0 pair and committing the lockfile +avoids adopting an evolving Git revision or silently upgrading the toolkit. +The optional WebView feature is not enabled. Both dependencies use Apache-2.0; +distribution still needs an audit of notices for the complete dependency tree. + +Reusing the current Python API creates a controlled first comparison of desktop +renderers. It retains the existing download, install, backup, hash, Steam, UAC, +and config semantics. It also retains Python startup, packaging cost, and HTTP +serialization; removing Chromium does not prove a smaller or faster complete +application. A later in-process Rust port should use a toolkit-independent core +so Tauri and GPUI can exercise identical business logic. This PR does not import +the unmerged Rust rewrite or duplicate delicate game-file operations in Rust. + +Sources inspected: + +- [GPUI upstream README](https://github.com/zed-industries/zed/blob/main/crates/gpui/README.md): architecture, pre-1.0 status, platform requirements. +- [Published GPUI 0.2.2 source](https://crates.io/crates/gpui/0.2.2): standalone application, async contexts, platform folder prompts. +- [GPUI Component 0.5.0 source](https://crates.io/crates/gpui-component/0.5.0): compatible GPUI dependency, inputs, buttons, Root and theme setup. +- Repository `src/main/main.ts`, `src/renderer/lib/api.ts`, `src/renderer/main.tsx`, and `backend/api.py`: actual lifecycle, payloads, config normalization, state, error responses, and logs. +- PR #36 and #40 metadata: the shell-only migration and the subsequent backend rewrite have different scopes and costs. + +## Implementation plan and resulting slice + +1. Add an isolated Cargo application and compatible native controls. Preserve the + app's dark charcoal/red control-center visual language and use native window + chrome, seven navigation sections, scrollable settings, and a log panel. +2. Launch a supervised Python service on an ephemeral loopback port. Wait for + health before opening GPUI; always reap owned processes on normal/error exits. +3. Execute API calls on one HTTP worker. Reflect backend-reported state, preserve + unsuccessful HTTP-200 responses and depot instructions, disable overlapping + actions, and require an explicit operation confirmation. +4. Implement representative real workflows throughout the app, using existing + payloads and config value mappings. Keep input drafts intact during refresh; + provide an explicit button to load current values. +5. Add meaningful protocol/lifecycle checks, Windows/Linux build CI, downloadable + evaluation archives, a common process-tree sampler, and a parity/manual review + checklist. Keep production release workflows separate from evaluation builds. + +## Stack comparison + +| Criterion | Current Electron | Tauri #36 / #40 | GPUI slice | +|---|---|---|---| +| UI technology | React/CSS + Chromium | React/CSS + OS WebView | Native Rust controls + GPU rendering | +| Backend | Python child + HTTP/WS | #36 Python + small Rust core; #40 in-process Rust | Same Python + HTTP | +| Renderer reuse | Baseline | High | Rewrite required | +| Platform work | Existing Windows/Linux packages | WebView availability and new Rust packaging | GPU/driver compatibility, native UI/input and new packaging | +| Accessibility | Browser infrastructure; app still needs testing | WebView infrastructure; app still needs testing | Must validate controls and screen readers on target OSes | +| Delivery in this branch | Unchanged | Independent open PRs | Source build and prerequisite-based evaluation ZIPs | +| Startup/RAM/package size | Measure | Measure each PR separately | Measure complete stack; no claimed improvement | +| Migration cost | Existing maintenance | Shell changes; #40 also ports operations | Entire UI rewrite; later backend port optional | + +## Workflow coverage + +| Workflow | Implemented in this slice | Remaining evaluation/parity work | +|---|---|---| +| Installation detection/directory | Actual status and native folder picker; manual path entry; existing backend validation | Folder picker on Windows, Wayland portals and Steam Deck | +| Launch/profiles | Steam launch, backend-provided profiles, Workshop open/install request | Workshop progress presentation and detailed profile states | +| T7 Patch | Install/update, uninstall, gamertag/color/password, friends-only | Real install/UAC cancellation, name preview, password editing UX | +| EXE Swapper | Compatible/current/Enhanced restore, hash and integrity state, depot error text | Guided depot dialog/polling/copy action; real restore/backup checks | +| Enhanced | Pick/enter source, validate, install, uninstall | Real Windows/Linux game/proton workflows | +| Graphics | Presets, FPS, FOV, refresh, render %, resolution, display modes, V-Sync and FPS counter | Slider UX, valid hardware-specific mode list, side-by-side visual polish | +| Advanced/QoL | Smoothing, hidden options, CPU reduction, frame latency, VRAM target, intro/compiler/read-only toggles | Real config/backup verification on disposable installs | +| DXVK | Install/uninstall, async/cache/HUD, threads, FPS, latency and tear-free settings | Real download/install/proton behavior | +| Logs/maintenance | Up to 80 recent lines, five-second refresh, clear logs/cache, reset stock, update check/channel | Streaming WS logs, copy/export UX and platform updater integration | +| Delivery | Windows/Linux CI and source-backed release archives | Bundled Python or shared Rust core; signed installers, updater, notices and release integration | + +Status refresh uses the complete existing status endpoint, including executable +hashing. Polling every five seconds can cost more idle CPU than Electron's event +driven updates; include that cost in measurements. No new background game scans +or fabricated installation states are introduced. Actions preserve backend +validation and require confirmation; all game-modifying smoke tests must use +an installation whose backups can be inspected and restored. + +## Fair measurement protocol + +Use the same Windows PC and Steam Deck/Linux PC for all three candidates. Record +OS, GPU/driver, display scale, power profile, game-directory state, and commit +SHA. Build optimized release artifacts. Exclude compilation, debugger and dev +servers. Compare Electron, #36, #40 and GPUI separately; a Python-backed GPUI +versus #40 compares both renderer and backend changes. + +For each candidate collect ten cold launches and ten warm launches. Record +process start to first painted interactive window **and** service-ready status +with screen recording or platform tracing. Include backend startup. Report +median and p95, retaining raw observations. Do not label cargo build time as app +startup time, or GPUI entity creation as first paint. + +Measure idle for 60 seconds after settling, then the same navigation/config and +log workload. Use the supervisor PID that owns both UI and backend processes: + +```sh +python -m pip install psutil +python scripts/measure_desktop.py --pid 12345 --label gpui-release \ + --seconds 60 --artifact dist/gpui/PatchOpsIII-gpui-linux.zip \ + --output measurements/gpui-idle.json +``` + +The sampler captures RSS and CPU for the process tree, including children; RSS +can double-count shared pages and excludes GPU memory. Collect GPU usage with +platform tools. GPUI archives currently omit the Python runtime; report its +installed footprint separately, and do not compare that ZIP directly with a +fully bundled Electron installer. Elevated/detached children must be measured +separately. Evaluate input latency, scrolling, text entry/IME, scaling, keyboard +focus, screen readers, offline/error behavior, and update/install/uninstall +reliability before selecting a winner. There is no winner established by this PR. + +## Validation recorded for this PR + +On the supplied Linux cloud environment: + +- Debug and optimized release builds completed with the locked dependency tree. +- Four Rust protocol tests passed, including an actual local HTTP worker request + and HTTP error response. Clippy with warnings denied and rustfmt checks passed. +- Six Python launcher tests passed, covering child reaping, startup/UI failures, + missing binaries, early service exit, and custom target-directory consistency. +- The existing Electron renderer TypeScript check (`tsc --noEmit`) passed. +- A real Python service bound an ephemeral port, returned health/status with no + game installation, and stopped accepting connections after launcher cleanup. +- The native window rendered under Xvfb/Openbox with Mesa's software Vulkan + driver and an X compositor. Dashboard/Graphics navigation and text entry were + inspected; an edited FPS draft remained unchanged across status refreshes. +- The normal X11 close path exposed a GPUI 0.2.2 callback borrow panic. Quit is + now scheduled on a later event-loop turn. A read-only native lifecycle smoke + check exercises the real platform close callback, and Linux CI runs it too. +- Archive contents and the process-tree sampler were smoke checked. The sampler + captured the supervisor, Python API and native GUI; its debug/software-rendered + samples are not comparative performance evidence. + +![GPUI release dashboard in the Linux smoke session](images/gpui-dashboard.png) + +A GUI Apply-click smoke check was rejected by automatic approval review because +it could activate a configuration/file-changing action. No game-mutating UI +clicks were executed. Confirmation behavior was reviewed in code; actual game +operations, Windows, Wayland, Steam Deck, accessibility, and comparative +performance remain manual/hardware validation gates. Native Windows/Linux CI +is configured in this PR; its hosted results are separate from these local checks. + +## Reviewer smoke checklist + +- Build with the pinned lockfile; open the native window on Windows and Linux. +- Start Electron simultaneously; confirm distinct ports/processes. Close GPUI + and confirm its service exits while Electron stays alive. +- With no game directory, verify status/errors and manual folder input. Browse + to a disposable BO3 install, confirm selection, refresh, and relaunch. +- Navigate every section with mouse and keyboard. Edit a field while refresh + runs; confirm the draft remains. Verify current-values reload is explicit. +- Cancel an operation confirmation and verify no files changed. Verify the + selected operation and target before confirming a config mutation. +- Apply a preset and individual config values; inspect config.ini and backend + logs, including readonly error handling and special CPU/hidden-option values. +- On a test install verify each mod install/uninstall and EXE restore preserves + expected backups. Exercise compatible-depot-required and UAC cancellation. +- Verify API startup failure, disconnect, download errors, and clean shutdown. + Wait for active file operations before closing; cancellation is not implemented. +- Test X11, Wayland, actual Steam Deck, high DPI, IME and screen-reader behavior. +- Measure optimized complete stacks and record results before any replacement + decision. Hardware results and full packaged parity are follow-up gates. diff --git a/docs/images/gpui-dashboard.png b/docs/images/gpui-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..4bfb3a344e5168ff10e93432c6ea5862917628a9 GIT binary patch literal 78252 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, +} + +pub struct Reply { + pub state: Option, + pub message: String, + pub failed: bool, + pub is_status: bool, +} + +pub struct Backend { + pub requests: mpsc::Sender, + pub replies: mpsc::Receiver, +} + +pub fn validate_url(url: &str) -> Result { + let parsed = reqwest::Url::parse(url)?; + if parsed.scheme() != "http" + || parsed.host_str() != Some("127.0.0.1") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + { + bail!("GPUI backend must be an HTTP origin on 127.0.0.1"); + } + Ok(url.trim_end_matches('/').to_owned()) +} + +pub fn decode_reply(path: &str, value: Value) -> Result { + if value.get("ok").and_then(Value::as_bool) == Some(false) + || value.get("valid").and_then(Value::as_bool) == Some(false) + { + let mut message = value["error"] + .as_str() + .or(value["message"].as_str()) + .unwrap_or("Operation failed") + .to_owned(); + if let Some(command) = value["depotCommand"].as_str() { + message.push_str(&format!("\nSteam console command: {command}")); + } + bail!("{message}"); + } + if path != "/api/status" && value.get("ok").and_then(Value::as_bool) != Some(true) { + bail!("Backend returned an invalid operation result"); + } + let state = if path == "/api/status" { + if !value["appVersion"].is_string() || !value["gameDetected"].is_boolean() { + bail!("Backend returned an invalid status document"); + } + Some(value.clone()) + } else { + value.get("state").cloned() + }; + let message = if path == "/api/status" { + "Connected to the local service".to_owned() + } else if let Some(update) = value.get("update") { + format!("Update check: {update}") + } else { + value["message"] + .as_str() + .unwrap_or("Operation completed") + .to_owned() + }; + Ok(Reply { + state, + message, + failed: false, + is_status: path == "/api/status", + }) +} + +fn execute(client: &Client, url: &str, request: Request) -> Result { + let path = request.path; + let builder = match request.body { + Some(body) => client.post(format!("{url}{path}")).json(&body), + None => client + .get(format!("{url}{path}")) + .timeout(Duration::from_secs(30)), + }; + let value = builder + .send() + .context("Local service request failed")? + .error_for_status() + .context("Local service returned an HTTP error")? + .json::() + .context("Local service returned invalid JSON")?; + decode_reply(&path, value) +} + +impl Backend { + pub fn start(url: String) -> Result { + let url = validate_url(&url)?; + // Loopback requests must not use a developer's system HTTP proxy. + let client = Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(3)) + .timeout(Duration::from_secs(600)) + .build()?; + let (requests, incoming) = mpsc::channel::(); + let (outgoing, replies) = mpsc::channel(); + thread::Builder::new() + .name("patchops-gpui-api".into()) + .spawn(move || { + for request in incoming { + let is_status = request.path == "/api/status"; + let reply = execute(&client, &url, request).unwrap_or_else(|error| Reply { + state: None, + message: format!("{error:#}"), + failed: true, + is_status, + }); + if outgoing.send(reply).is_err() { + break; + } + } + })?; + Ok(Self { requests, replies }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn accepts_only_loopback_origins() { + assert_eq!( + validate_url("http://127.0.0.1:8767/").unwrap(), + "http://127.0.0.1:8767" + ); + for url in [ + "https://127.0.0.1", + "http://example.com", + "http://localhost", + "http://127.0.0.1/api", + "http://user@127.0.0.1", + "http://127.0.0.1?x=1", + ] { + assert!(validate_url(url).is_err(), "{url}"); + } + } + + #[test] + fn handles_http_200_operation_failures_and_depot_instructions() { + let error = decode_reply("/api/exe-swap/compatible", json!({"ok": false, "error": "Download depot first", "depotCommand": "download_depot 311210 311211 123"})).err().unwrap(); + assert!( + error + .to_string() + .contains("download_depot 311210 311211 123") + ); + assert!(decode_reply("/api/status", json!({"ok": true})).is_err()); + assert!(decode_reply("/api/config", json!({"state": {}})).is_err()); + assert!( + decode_reply( + "/api/enhanced-validate", + json!({"ok": true, "valid": false, "message": "Missing files"}) + ) + .is_err() + ); + } + + #[test] + fn preserves_backend_state_without_fabricating_success() { + let state = json!({"appVersion": "v1.3.4", "gameDetected": false}); + assert_eq!( + decode_reply("/api/status", state.clone()).unwrap().state, + Some(state.clone()) + ); + assert_eq!( + decode_reply("/api/config", json!({"ok": true, "state": state})) + .unwrap() + .state, + Some(state) + ); + } + + #[test] + fn worker_posts_exact_payload_and_surfaces_http_errors() { + use std::{ + io::{Read, Write}, + net::TcpListener, + }; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut request = Vec::new(); + let mut byte = [0u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + let headers = String::from_utf8(request).unwrap(); + assert!(headers.starts_with("POST /api/config HTTP/1.1")); + let length: usize = headers + .lines() + .find_map(|line| { + line.to_lowercase() + .strip_prefix("content-length:") + .map(|s| s.trim().parse().unwrap()) + }) + .unwrap(); + let mut body = vec![0; length]; + stream.read_exact(&mut body).unwrap(); + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + json!({"key": "MaxFPS", "value": 144}) + ); + stream.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").unwrap(); + }); + let backend = Backend::start(url).unwrap(); + backend + .requests + .send(Request { + path: "/api/config".into(), + body: Some(json!({"key": "MaxFPS", "value": 144})), + }) + .unwrap(); + let reply = backend + .replies + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + assert!(reply.failed); + assert!(reply.message.contains("503")); + assert!(reply.state.is_none()); + server.join().unwrap(); + } +} diff --git a/native/gpui/src/main.rs b/native/gpui/src/main.rs new file mode 100644 index 0000000..3326059 --- /dev/null +++ b/native/gpui/src/main.rs @@ -0,0 +1,58 @@ +mod backend; +mod ui; + +use anyhow::{Context, Result}; +use gpui::{prelude::*, *}; +use gpui_component::{Root, Theme, ThemeMode}; + +fn main() -> Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + let url = std::env::var("PATCHOPSIII_GPUI_BACKEND_URL") + .context("Start GPUI with: python scripts/gpui.py run (see native/gpui/README.md)")?; + let backend = backend::Backend::start(url)?; + Application::new().run(move |cx: &mut App| { + gpui_component::init(cx); + Theme::change(ThemeMode::Dark, None, cx); + let theme = Theme::global_mut(cx); + theme.colors.primary = rgb(0xd92323).into(); + theme.colors.primary_hover = rgb(0xf03535).into(); + theme.colors.primary_active = rgb(0xa41616).into(); + theme.colors.primary_foreground = rgb(0xffffff).into(); + theme.colors.ring = rgb(0xf03535).into(); + cx.on_window_closed(|cx| { + // GPUI 0.2.2's X11 close callback holds platform state borrowed. + // Quit on a later event-loop turn, after that callback has returned. + cx.spawn(async move |cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(1)) + .await; + let _ = cx.update(|cx| { + if cx.windows().is_empty() { + cx.quit(); + } + }); + }) + .detach(); + }) + .detach(); + let bounds = Bounds::centered(None, size(px(1150.), px(820.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(800.), px(600.))), + titlebar: Some(TitlebarOptions { + title: Some("PatchOpsIII · GPUI".into()), + ..Default::default() + }), + ..Default::default() + }, + move |window, cx| { + let view = cx.new(|cx| ui::ControlCenter::new(backend, window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }, + ) + .expect("Unable to open the GPUI window"); + cx.activate(true); + }); + Ok(()) +} diff --git a/native/gpui/src/ui.rs b/native/gpui/src/ui.rs new file mode 100644 index 0000000..d8a18d0 --- /dev/null +++ b/native/gpui/src/ui.rs @@ -0,0 +1,798 @@ +use crate::backend::{Backend, Request}; +use gpui::{prelude::*, *}; +use gpui_component::{ + Disableable, + button::{Button, ButtonVariants}, + input::{Input, InputState}, +}; +use serde_json::{Value, json}; +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; + +const PAGES: [&str; 7] = [ + "Dashboard", + "T7 Patch", + "EXE Swapper", + "Enhanced", + "Graphics", + "DXVK", + "Tools", +]; + +pub struct ControlCenter { + backend: Backend, + state: Value, + page: usize, + busy: bool, + connected: bool, + message: String, + failed: bool, + last_refresh: Instant, + inputs: BTreeMap<&'static str, Entity>, + pending: Option<(String, Request)>, +} + +impl ControlCenter { + pub fn new(backend: Backend, window: &mut Window, cx: &mut Context) -> Self { + let inputs = [ + ("directory", "Game directory"), + ("gamertag", "Gamertag (up to 20 characters)"), + ("password", "Network password"), + ("color", "Color code, e.g. ^1"), + ("dump", "BO3 Enhanced source directory"), + ("fps", "0–1000"), + ("fov", "65–120"), + ("resolution", "1920x1080"), + ("refresh", "Refresh rate"), + ("render", "Render resolution %"), + ("latency", "0–4"), + ("vram", "75–100"), + ("dxvk-threads", "0–64"), + ("dxvk-fps", "0–360"), + ("dxvk-latency", "0–16"), + ] + .into_iter() + .map(|(key, placeholder)| { + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(placeholder) + .masked(key == "password") + }); + (key, input) + }) + .collect(); + let mut view = Self { + backend, + state: Value::Null, + page: 0, + busy: false, + connected: false, + message: "Connecting to the local service…".into(), + failed: false, + last_refresh: Instant::now(), + inputs, + pending: None, + }; + view.send("/api/status", None, cx); + // Drain replies and periodically refresh on GPUI's foreground executor. + // The only blocking work lives on the dedicated HTTP worker. + cx.spawn_in(window, async move |entity, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + if entity + .update_in(cx, |view, window, cx| { + while let Ok(reply) = view.backend.replies.try_recv() { + view.busy = false; + if reply.failed { + view.failed = true; + if reply.is_status { + view.connected = false; + } + view.message = reply.message; + } else { + let initial = view.state.is_null(); + let reconnected = !view.connected; + if let Some(state) = reply.state { + view.state = state; + view.connected = true; + } + if initial { + view.load_inputs(window, cx); + } + if !reply.is_status + || reconnected + || view.message.starts_with("Connecting") + { + view.failed = false; + view.message = reply.message; + } + } + view.last_refresh = Instant::now(); + cx.notify(); + } + if !view.busy + && view.pending.is_none() + && view.last_refresh.elapsed() >= Duration::from_secs(5) + { + view.send("/api/status", None, cx); + } + }) + .is_err() + { + break; + } + } + }) + .detach(); + view + } + + fn send(&mut self, path: &str, body: Option, cx: &mut Context) { + if self.busy { + return; + } + match self.backend.requests.send(Request { + path: path.into(), + body, + }) { + Ok(()) => { + self.busy = true; + self.last_refresh = Instant::now(); + } + Err(_) => { + self.failed = true; + self.connected = false; + self.message = "Local service worker stopped. Restart GPUI.".into(); + } + } + cx.notify(); + } + + fn stage(&mut self, label: String, path: String, body: Value, cx: &mut Context) { + self.pending = Some(( + label, + Request { + path, + body: Some(body), + }, + )); + cx.notify(); + } + + fn text(&self, key: &str, cx: &App) -> String { + self.inputs[key].read(cx).value().to_string() + } + fn load_inputs(&mut self, window: &mut Window, cx: &mut Context) { + for (key, pointer) in [ + ("directory", "/gameDir"), + ("gamertag", "/t7/plainName"), + ("color", "/t7/colorCode"), + ("dump", "/enhanced/dumpSource"), + ("fps", "/graphics/maxFps"), + ("fov", "/graphics/fov"), + ("resolution", "/graphics/resolution"), + ("refresh", "/graphics/refreshRate"), + ("render", "/graphics/renderResolution"), + ("latency", "/advanced/maxFrameLatency"), + ("vram", "/advanced/vramTarget"), + ("dxvk-threads", "/dxvk/settings/numCompilerThreads"), + ("dxvk-fps", "/dxvk/settings/maxFrameRate"), + ("dxvk-latency", "/dxvk/settings/maxFrameLatency"), + ] { + let value = self.value(pointer); + self.inputs[key].update(cx, |state, cx| { + state.set_value(if value == "—" { String::new() } else { value }, window, cx) + }); + } + } + fn flag(&self, pointer: &str) -> bool { + self.state + .pointer(pointer) + .and_then(Value::as_bool) + .unwrap_or(false) + } + fn value(&self, pointer: &str) -> String { + match self.state.pointer(pointer) { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n + .as_f64() + .map(|n| n.to_string()) + .unwrap_or_else(|| n.to_string()), + Some(Value::Null) | None => "—".into(), + Some(v) => v.to_string(), + } + } + + fn action( + &self, + id: String, + label: String, + path: &str, + body: Value, + cx: &mut Context, + ) -> Button { + let path = path.to_owned(); + Button::new(SharedString::from(id)) + .label(label.clone()) + .disabled(self.busy || !self.connected || self.pending.is_some()) + .on_click(cx.listener(move |view, _, _, cx| { + view.stage(label.clone(), path.clone(), body.clone(), cx) + })) + } + + fn toggle( + &self, + label: &str, + pointer: &str, + path: &str, + key: &str, + cx: &mut Context, + ) -> AnyElement { + let current = self.flag(pointer); + let body = json!({key: !current}); + div() + .flex() + .justify_between() + .items_center() + .gap_4() + .child(format!("{label}: {}", if current { "On" } else { "Off" })) + .child(self.action( + format!("toggle-{pointer}"), + format!("{} {label}", if current { "Disable" } else { "Enable" }), + path, + body, + cx, + )) + .into_any_element() + } + + fn config_toggle( + &self, + label: &str, + pointer: &str, + key: &str, + cx: &mut Context, + ) -> AnyElement { + let current = self.flag(pointer); + div().flex().justify_between().items_center().gap_4() + .child(format!("{label}: {}", if current { "On" } else { "Off" })) + .child(self.action(format!("config-{key}"), format!("{} {label}", if current { "Disable" } else { "Enable" }), "/api/config", + json!({"key": key, "value": if current {0} else {1}, "comment": "Managed by PatchOpsIII"}), cx)).into_any_element() + } + + fn field(&self, label: &str, key: &str) -> AnyElement { + div() + .flex() + .flex_col() + .gap_2() + .child(label.to_owned()) + .child(Input::new(&self.inputs[key]).disabled(self.busy)) + .into_any_element() + } + + fn numeric_field( + &self, + label: &str, + input: &'static str, + config: &'static str, + min: i64, + max: i64, + cx: &mut Context, + ) -> AnyElement { + div() + .flex() + .items_end() + .gap_3() + .child(div().flex_1().child(self.field(label, input))) + .child( + Button::new(SharedString::from(format!("apply-{input}"))) + .label("Apply") + .disabled(self.busy || !self.connected || self.pending.is_some()) + .on_click(cx.listener(move |view, _, _, cx| { + match view.text(input, cx).parse::() { + Ok(value) if (min..=max).contains(&value) => view.stage( + format!("Set {config} to {value}"), + "/api/config".into(), + json!({"key": config, "value": value}), + cx, + ), + _ => { + view.message = format!("Enter a whole number from {min} to {max}."); + view.failed = true; + cx.notify(); + } + } + })), + ) + .into_any_element() + } + + fn choose_directory(&mut self, input: &'static str, window: &Window, cx: &mut Context) { + let paths = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Select a folder".into()), + }); + cx.spawn_in(window, async move |entity, cx| match paths.await { + Ok(Ok(Some(paths))) => { + if let Some(path) = paths.first() { + let value = path.to_string_lossy().into_owned(); + let _ = entity.update_in(cx, |view, window, cx| { + view.inputs[input] + .update(cx, |state, cx| state.set_value(value, window, cx)); + }); + } + } + Ok(Ok(None)) => {} + _ => { + let _ = entity.update(cx, |view, cx| { + view.message = "Folder picker unavailable. Enter the path directly.".into(); + view.failed = true; + cx.notify(); + }); + } + }) + .detach(); + } + + fn folder_field(&self, label: &str, key: &'static str, cx: &mut Context) -> AnyElement { + div() + .flex() + .items_end() + .gap_3() + .child(div().flex_1().child(self.field(label, key))) + .child( + Button::new(SharedString::from(format!("browse-{key}"))) + .label("Browse…") + .disabled(self.busy) + .on_click(cx.listener(move |view, _, window, cx| { + view.choose_directory(key, window, cx) + })), + ) + .into_any_element() + } + + fn dashboard(&self, cx: &mut Context) -> AnyElement { + panel("Game installation") + .child(format!("Current directory: {}", self.value("/gameDir"))) + .child(format!( + "Platform: {} · Version: {}", + self.value("/platform"), + self.value("/appVersion") + )) + .child(format!( + "Executable: {} · {}", + self.value("/exeSwap/displayLabel"), + self.value("/exeSwap/integrityMessage") + )) + .child(self.folder_field("Black Ops III directory", "directory", cx)) + .child( + Button::new("save-directory") + .label("Use this directory") + .primary() + .disabled(self.busy || !self.connected || self.pending.is_some()) + .on_click(cx.listener(|view, _, _, cx| { + let path = view.text("directory", cx); + if path.trim().is_empty() { + view.message = "Select or enter a directory first.".into(); + view.failed = true; + cx.notify(); + return; + } + view.stage( + format!("Use directory {path}"), + "/api/game-directory".into(), + json!({"path": path}), + cx, + ); + })), + ) + .child(div().mt_4().child("Launch profiles")) + .children( + self.state["launchProfiles"] + .as_array() + .into_iter() + .flatten() + .map(|profile| { + let label = profile["label"].as_str().unwrap_or("Profile").to_owned(); + let option = profile["option"].as_str().unwrap_or("").to_owned(); + let id = profile["id"].as_str().unwrap_or("profile").to_owned(); + div() + .flex() + .gap_3() + .items_center() + .child(self.action( + format!("profile-{id}"), + format!( + "{label}{}", + if profile["active"] == true { + " · Active" + } else { + "" + } + ), + "/api/launch-options", + json!({"options": option}), + cx, + )) + .when( + profile["subscribed"].is_boolean() + && id != "default" + && id != "offline", + |row| { + row.child(self.action( + format!("workshop-{id}"), + "Open Workshop".into(), + "/api/workshop-install", + json!({"profileId": id}), + cx, + )) + }, + ) + }), + ) + .into_any_element() + } + + fn t7(&self, cx: &mut Context) -> AnyElement { + panel("T7 Patch") + .child(format!("Installed: {} · Current gamertag: {}", self.flag("/t7/installed"), self.value("/t7/plainName"))) + .child(div().flex().gap_3() + .child(self.action("t7-install".into(), "Install / update T7 Patch".into(), "/api/t7-install", json!({}), cx)) + .child(self.action("t7-uninstall".into(), "Uninstall T7 Patch".into(), "/api/t7-uninstall", json!({}), cx))) + .child(self.field("Gamertag", "gamertag")) + .child(self.field("Gamertag color", "color")) + .child(Button::new("save-gamertag").label("Save gamertag").disabled(self.busy || !self.connected || self.pending.is_some()) + .on_click(cx.listener(|view, _, _, cx| view.stage("Update T7 gamertag".into(), "/api/t7-config".into(), json!({"gamertag": view.text("gamertag", cx), "colorCode": view.text("color", cx)}), cx)))) + .child(self.field("Network password (blank clears it)", "password")) + .child(Button::new("save-password").label("Save network password").disabled(self.busy || !self.connected || self.pending.is_some()) + .on_click(cx.listener(|view, _, _, cx| view.stage("Update T7 network password".into(), "/api/t7-config".into(), json!({"networkPassword": view.text("password", cx)}), cx)))) + .child(self.toggle("Friends only", "/t7/friendsOnly", "/api/t7-config", "friendsOnly", cx)).into_any_element() + } + + fn exe(&self, cx: &mut Context) -> AnyElement { + panel("Executable profiles") + .child(self.value("/exeSwap/displayLabel")) + .child(self.value("/exeSwap/integrityMessage")) + .child(format!("SHA-256: {}", self.value("/exeSwap/executableHash"))) + .child("Compatible installs may require a Steam depot download. Instructions appear in the operation result below.") + .children([("compatible", "Install compatible build"), ("current", "Restore current Steam build"), ("enhanced", "Restore Enhanced build")].into_iter().map(|(id, label)| + self.action(format!("exe-{id}"), label.into(), &format!("/api/exe-swap/{id}"), json!({}), cx))).into_any_element() + } + + fn enhanced(&self, cx: &mut Context) -> AnyElement { + panel("BO3 Enhanced") + .child(format!( + "Installed: {} · Backup status: {}", + self.flag("/enhanced/installed"), + self.value("/enhanced/backupStatus") + )) + .child(self.folder_field("Enhanced source directory", "dump", cx)) + .children( + [ + ("validate", "Validate source"), + ("install", "Install Enhanced"), + ] + .into_iter() + .map(|(id, label)| { + Button::new(SharedString::from(format!("enhanced-{id}"))) + .label(label) + .disabled(self.busy || !self.connected || self.pending.is_some()) + .on_click(cx.listener(move |view, _, _, cx| { + view.stage( + label.into(), + format!("/api/enhanced-{id}"), + json!({"dumpSource": view.text("dump", cx)}), + cx, + ) + })) + }), + ) + .child(self.action( + "enhanced-uninstall".into(), + "Uninstall Enhanced".into(), + "/api/enhanced-uninstall", + json!({}), + cx, + )) + .into_any_element() + } + + fn graphics(&self, cx: &mut Context) -> AnyElement { + panel("Graphics & performance") + .child(format!("Current: {} · {} Hz · FOV {} · FPS cap {}", self.value("/graphics/resolution"), self.value("/graphics/refreshRate"), self.value("/graphics/fov"), self.value("/graphics/maxFps"))) + .children(self.state["presets"].as_array().into_iter().flatten().filter_map(Value::as_str).map(|name| + self.action(format!("preset-{name}"), format!("Apply {name}"), "/api/presets/apply", json!({"name": name}), cx))) + .child(self.numeric_field("FPS cap (0 disables the limiter)", "fps", "MaxFPS", 0, 1000, cx)) + .child(self.numeric_field("Field of view", "fov", "FOV", 65, 120, cx)) + .child(self.numeric_field("Refresh rate", "refresh", "RefreshRate", 1, 1000, cx)) + .child(self.numeric_field("Render resolution %", "render", "ResolutionPercent", 50, 200, cx)) + .child(self.field("Resolution (WIDTHxHEIGHT)", "resolution")) + .child(Button::new("save-resolution").label("Apply resolution").disabled(self.busy || !self.connected || self.pending.is_some()) + .on_click(cx.listener(|view, _, _, cx| { + let value = view.text("resolution", cx); + let valid = value.split_once('x').is_some_and(|(w, h)| [w, h].iter().all(|n| n.parse::().is_ok_and(|n| (320..=16384).contains(&n)))); + if valid { view.stage(format!("Set resolution to {value}"), "/api/config".into(), json!({"key": "WindowSize", "value": value}), cx); } + else { view.message = "Enter resolution as WIDTHxHEIGHT, e.g. 1920x1080.".into(); view.failed = true; cx.notify(); } + }))) + .child(div().flex().gap_2().children([(0, "Windowed"), (1, "Fullscreen"), (2, "Borderless")].into_iter().map(|(mode, label)| self.action(format!("display-{mode}"), label.into(), "/api/config", json!({"key": "FullScreenMode", "value": mode}), cx)))) + .child(self.config_toggle("V-Sync", "/graphics/vsync", "Vsync", cx)) + .child(self.config_toggle("FPS counter", "/graphics/drawFps", "DrawFPS", cx)) + .child(self.config_toggle("Frame smoothing", "/advanced/smoothFramerate", "SmoothFramerate", cx)) + .child(self.action("unlock-graphics".into(), "Toggle hidden graphics options".into(), "/api/config", json!({"key": "RestrictGraphicsOptions", "value": if self.flag("/advanced/unlockOptions") {1} else {0}}), cx)) + .child(self.action("reduce-cpu".into(), "Toggle reduced CPU pressure".into(), "/api/config", json!({"key": "SerializeRender", "value": if self.flag("/advanced/reduceCpu") {0} else {2}}), cx)) + .child(self.numeric_field("Maximum frame latency", "latency", "MaxFrameLatency", 0, 4, cx)) + .child(self.field("VRAM target %", "vram")) + .child(Button::new("save-vram").label("Apply VRAM target").disabled(self.busy || !self.connected || self.pending.is_some()) + .on_click(cx.listener(|view, _, _, cx| { + match view.text("vram", cx).parse::() { + Ok(target) if (75..=100).contains(&target) => view.stage(format!("Set VRAM target to {target}%"), "/api/vram-target".into(), json!({"limited": target < 100, "target": target}), cx), + _ => { view.message = "VRAM target must be a whole number from 75 to 100.".into(); view.failed = true; cx.notify(); } + } + }))) + .child(self.toggle("Skip intro", "/qol/intro", "/api/intro-skip", "enabled", cx)) + .child(self.toggle("Skip all intros", "/qol/allIntros", "/api/all-intros-skip", "enabled", cx)) + .child(self.toggle("Modern DirectX compiler", "/qol/d3dcompiler", "/api/d3dcompiler", "enabled", cx)) + .child(self.toggle("Read-only config", "/advanced/configReadonly", "/api/config-readonly", "enabled", cx)).into_any_element() + } + + fn dxvk(&self, cx: &mut Context) -> AnyElement { + let settings = self + .state + .pointer("/dxvk/settings") + .cloned() + .unwrap_or(json!({})); + panel("DXVK-GPLAsync") + .child(format!("Installed: {}", self.flag("/dxvk/installed"))) + .child("Install uses the settings reported by the service, or backend defaults for a new installation.") + .child(format!("Current settings: {settings}")) + .child(self.action("dxvk-install".into(), "Install DXVK".into(), "/api/dxvk-install", settings.clone(), cx)) + .child(self.action("dxvk-uninstall".into(), "Uninstall DXVK".into(), "/api/dxvk-uninstall", json!({}), cx)) + .children([("Async compilation", "enableAsync"), ("GPL async cache", "gplAsyncCache"), ("DXVK HUD", "hudEnabled")].into_iter().map(|(label, key)| { + let mut next = settings.clone(); + next[key] = json!(!settings[key].as_bool().unwrap_or(false)); + self.action(format!("dxvk-{key}"), format!("Toggle {label}"), "/api/dxvk-config", next, cx) + })) + .children([("Compiler threads", "dxvk-threads", "numCompilerThreads", 64), ("FPS cap", "dxvk-fps", "maxFrameRate", 360), ("Frame latency", "dxvk-latency", "maxFrameLatency", 16)].into_iter().map(|(label, input, setting, max)| { + div().flex().items_end().gap_3().child(div().flex_1().child(self.field(label, input))) + .child(Button::new(SharedString::from(format!("apply-{input}"))).label("Apply").disabled(self.busy || !self.connected || self.pending.is_some()) + .on_click(cx.listener(move |view, _, _, cx| { + match view.text(input, cx).parse::() { + Ok(value) if value <= max => { let mut settings = view.state["dxvk"]["settings"].clone(); settings[setting] = json!(value); view.stage(format!("Set DXVK {label} to {value}"), "/api/dxvk-config".into(), settings, cx); } + _ => { view.message = format!("Enter a whole number from 0 to {max}."); view.failed = true; cx.notify(); } + } + }))) + })) + .children(["True", "False", "Auto"].into_iter().map(|mode| { let mut next = settings.clone(); next["tearFree"] = json!(mode); self.action(format!("tearfree-{mode}"), format!("Tear-free: {mode}"), "/api/dxvk-config", next, cx) })).into_any_element() + } + + fn tools(&self, cx: &mut Context) -> AnyElement { + panel("Maintenance") + .child(format!("Log file: {}", self.value("/logPath"))) + .children( + [ + ("/api/logs/clear", "Clear logs"), + ("/api/mod-files/clear", "Clear cached mod files"), + ("/api/reset-stock", "Reset game to stock"), + ("/api/update-check", "Check for updates"), + ] + .into_iter() + .map(|(path, label)| self.action(path.into(), label.into(), path, json!({}), cx)), + ) + .child(self.action( + "channel-stable".into(), + "Use stable channel".into(), + "/api/release-channel", + json!({"channel": "stable"}), + cx, + )) + .child(self.action( + "channel-beta".into(), + "Use beta channel".into(), + "/api/release-channel", + json!({"channel": "beta"}), + cx, + )) + .into_any_element() + } +} + +fn panel(title: &str) -> Div { + div() + .flex() + .flex_col() + .gap_4() + .p_6() + .rounded_lg() + .border_1() + .border_color(rgb(0x343438)) + .bg(rgb(0x17171b)) + .child( + div() + .text_xl() + .font_weight(FontWeight::BOLD) + .child(title.to_owned()), + ) +} + +impl Render for ControlCenter { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = match self.page { + 0 => self.dashboard(cx), + 1 => self.t7(cx), + 2 => self.exe(cx), + 3 => self.enhanced(cx), + 4 => self.graphics(cx), + 5 => self.dxvk(cx), + _ => self.tools(cx), + }; + let confirmation = self.pending.as_ref().map(|(label, _)| { + panel("Confirm operation") + .child(label.clone()) + .child(format!("Target: {}", self.value("/gameDir"))) + .child( + div() + .flex() + .gap_3() + .child(Button::new("confirm").label("Confirm").primary().on_click( + cx.listener(|view, _, _, cx| { + if let Some((label, request)) = view.pending.take() { + view.message = format!("Running: {label}…"); + view.failed = false; + view.send(&request.path, request.body, cx); + } + }), + )) + .child(Button::new("cancel").label("Cancel").on_click(cx.listener( + |view, _, _, cx| { + view.pending = None; + cx.notify(); + }, + ))), + ) + .into_any_element() + }); + div() + .flex() + .size_full() + .bg(rgb(0x0d0d0f)) + .text_color(rgb(0xf6f6f6)) + .child( + div() + .flex() + .flex_col() + .w(px(200.)) + .p_4() + .gap_3() + .border_r_1() + .border_color(rgb(0x343438)) + .child( + div() + .text_xl() + .font_weight(FontWeight::BOLD) + .child("PATCHOPS III"), + ) + .child( + div() + .text_sm() + .text_color(rgb(0xff4545)) + .child("GPUI evaluation"), + ) + .children(PAGES.into_iter().enumerate().map(|(index, label)| { + Button::new(SharedString::from(format!("nav-{index}"))) + .label(label) + .when(index == self.page, |button| button.primary()) + .on_click(cx.listener(move |view, _, _, cx| { + view.page = index; + cx.notify(); + })) + })), + ) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .p_5() + .gap_4() + .child( + div() + .flex() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .text_2xl() + .font_weight(FontWeight::BOLD) + .child(PAGES[self.page]), + ) + .child( + div() + .flex() + .gap_2() + .child( + Button::new("load-settings") + .label("Load current values") + .disabled(self.busy || !self.connected) + .on_click(cx.listener(|view, _, window, cx| { + view.load_inputs(window, cx) + })), + ) + .child( + Button::new("refresh") + .label(if self.busy { "Working…" } else { "Refresh" }) + .disabled(self.busy) + .on_click(cx.listener(|view, _, _, cx| { + view.message = + "Connecting to the local service…".into(); + view.send("/api/status", None, cx); + })), + ) + .child(self.action( + "launch".into(), + "Launch game".into(), + "/api/launch", + json!({}), + cx, + )), + ), + ) + .children(confirmation) + .child( + div() + .id("content-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .flex() + .flex_col() + .gap_4() + .child(content), + ) + .child( + div() + .text_sm() + .text_color(if self.failed { + rgb(0xff9f0a) + } else { + rgb(0xb6b6bb) + }) + .child(self.message.clone()), + ) + .child( + div() + .id("log-scroll") + .h(px(130.)) + .overflow_y_scroll() + .bg(rgb(0x050505)) + .rounded_lg() + .p_3() + .text_sm() + .children( + self.state["logs"] + .as_array() + .into_iter() + .flatten() + .rev() + .take(80) + .map(|entry| { + div() + .text_color(if entry["category"] == "Error" { + rgb(0xff4545) + } else { + rgb(0xb6b6bb) + }) + .child(entry["line"].as_str().unwrap_or("").to_owned()) + }), + ), + ), + ) + } +} diff --git a/scripts/gpui.py b/scripts/gpui.py new file mode 100644 index 0000000..602e6e1 --- /dev/null +++ b/scripts/gpui.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Build/run the GPUI evaluation without changing Electron's processes or ports.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time +from urllib.request import ProxyHandler, build_opener + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = ROOT / "native" / "gpui" / "Cargo.toml" + + +def target_directory() -> Path: + target = Path(os.environ.get("CARGO_TARGET_DIR", MANIFEST.parent / "target")) + return target if target.is_absolute() else ROOT / target + + +def stop(process: subprocess.Popen) -> None: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def serve(ready: Path) -> None: + # Bind once and hand the listening socket directly to Uvicorn. No port race, + # no collision with Electron, and no need to modify the normal API launcher. + import uvicorn + from backend.api import app + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(128) + port = listener.getsockname()[1] + temporary = ready.with_suffix(".tmp") + temporary.write_text(json.dumps({"url": f"http://127.0.0.1:{port}"}), encoding="utf-8") + temporary.replace(ready) + server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")) + server.run(sockets=[listener]) + + +def wait_ready(process: subprocess.Popen, ready: Path, timeout: float = 30) -> str: + deadline = time.monotonic() + timeout + opener = build_opener(ProxyHandler({})) + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError(f"Local service exited with code {process.returncode}") + if ready.exists(): + url = json.loads(ready.read_text(encoding="utf-8"))["url"] + try: + with opener.open(f"{url}/api/health", timeout=1) as response: + if json.load(response).get("ok") is True: + return url + except OSError: + pass + time.sleep(0.1) + raise RuntimeError("Local service did not become healthy within 30 seconds") + + +def run(executable: Path) -> int: + if not executable.is_file(): + raise RuntimeError(f"GPUI executable not found: {executable}. Run the build command first.") + with tempfile.TemporaryDirectory(prefix="patchops-gpui-") as directory: + ready = Path(directory) / "ready.json" + # Source execution needs the repository root on Python's import path. + env = {**os.environ, "PYTHONPATH": str(ROOT)} + service = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), "serve", "--ready", str(ready)], cwd=ROOT, env=env) + app = None + try: + url = wait_ready(service, ready) + app = subprocess.Popen([str(executable)], cwd=ROOT, env={**env, "PATCHOPSIII_GPUI_BACKEND_URL": url}) + while app.poll() is None: + if service.poll() is not None: + raise RuntimeError("The local service stopped while GPUI was running") + time.sleep(0.2) + return app.returncode + finally: + if app is not None: + stop(app) + stop(service) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["build", "run", "serve", "test", "check"]) + parser.add_argument("--release", action="store_true") + parser.add_argument("--executable", type=Path, help="Run a prebuilt GPUI executable") + parser.add_argument("--ready", type=Path, help=argparse.SUPPRESS) + args = parser.parse_args() + if args.command == "serve": + if args.ready is None: + parser.error("serve requires --ready") + # When this file is run directly, sys.path starts at scripts/. + sys.path.insert(0, str(ROOT)) + serve(args.ready) + return 0 + if args.command in {"build", "test", "check"}: + command = ["cargo", args.command, "--locked", "--manifest-path", str(MANIFEST)] + if args.release: + command.append("--release") + env = {**os.environ, "CARGO_TARGET_DIR": str(target_directory())} + return subprocess.call(command, cwd=MANIFEST.parent, env=env) + target = target_directory() + executable = args.executable or target / ("release" if args.release else "debug") / ("patchopsiii-gpui.exe" if os.name == "nt" else "patchopsiii-gpui") + return run(executable.resolve()) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + raise SystemExit(130) + except (OSError, RuntimeError) as error: + print(f"GPUI: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/measure_desktop.py b/scripts/measure_desktop.py new file mode 100644 index 0000000..de5a0b0 --- /dev/null +++ b/scripts/measure_desktop.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Sample an already running app and descendants consistently across desktop stacks. + +Install the optional measurement dependency with: python -m pip install psutil +""" +import argparse +import json +import platform +from pathlib import Path +import statistics +import time + + +def main(): + import psutil + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pid", type=int, required=True, help="Supervisor PID including backend and GUI descendants") + parser.add_argument("--label", required=True) + parser.add_argument("--seconds", type=int, default=60) + parser.add_argument("--artifact", type=Path, action="append", default=[], help="Complete deliverable(s) to measure, not just the UI binary") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.seconds < 1: + parser.error("--seconds must be positive") + root = psutil.Process(args.pid) + samples = [] + previous_cpu = None + previous_time = None + for _ in range(args.seconds): + now = time.monotonic() + if not root.is_running(): + raise RuntimeError("App supervisor exited before measurement finished") + processes = [root, *root.children(recursive=True)] + rss = 0 + cpu = 0 + names = [] + skipped = [] + for process in processes: + try: + rss += process.memory_info().rss + times = process.cpu_times() + cpu += times.user + times.system + names.append({"pid": process.pid, "name": process.name()}) + except (psutil.NoSuchProcess, psutil.AccessDenied): + skipped.append(process.pid) + # Aggregate core-percent: 100 is one CPU core. Process churn can make + # cumulative counters fall; omit that interval rather than invent data. + percent = None + if previous_cpu is not None and cpu >= previous_cpu: + percent = 100 * (cpu - previous_cpu) / (now - previous_time) + samples.append({"rssBytes": rss, "cpuCorePercent": percent, "processes": names, "skippedPids": skipped}) + previous_cpu, previous_time = cpu, now + time.sleep(1) + cpu_samples = [s["cpuCorePercent"] for s in samples if s["cpuCorePercent"] is not None] + result = { + "label": args.label, "platform": platform.platform(), "sampleSeconds": args.seconds, + "medianTreeRssBytes": statistics.median(s["rssBytes"] for s in samples), + "peakTreeRssBytes": max(s["rssBytes"] for s in samples), + "medianCpuCorePercent": statistics.median(cpu_samples) if cpu_samples else None, + "artifacts": [{"path": str(p), "bytes": p.stat().st_size} for p in args.artifact], + "notes": ["RSS sums can double-count shared pages; use the same method for every candidate.", "GPU memory and detached/elevated processes are excluded.", "Measure startup separately; this sampler measures an already running app."], + "samples": samples, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(args.output) + + +if __name__ == "__main__": + main() diff --git a/scripts/package_gpui.py b/scripts/package_gpui.py new file mode 100644 index 0000000..db911d8 --- /dev/null +++ b/scripts/package_gpui.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Create an evaluation archive; Python and API dependencies remain prerequisites.""" +import argparse +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--executable", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if not args.executable.is_file(): + parser.error("Build the executable first") + files = ["LICENSE", "package.json", "requirements.txt", "presets.json", "utils.py", "t7_patch.py", "dxvk_manager.py", "bo3_enhanced.py", "scripts/gpui.py", "native/gpui/README.md", "docs/gpui-evaluation.md"] + files += [str(path.relative_to(ROOT)) for path in (ROOT / "backend").glob("*.py")] + files += [str(path.relative_to(ROOT)) for path in (ROOT / "docs" / "images").glob("gpui-*.png")] + args.output.parent.mkdir(parents=True, exist_ok=True) + with ZipFile(args.output, "w", ZIP_DEFLATED) as archive: + archive.write(args.executable, f"bin/{args.executable.name}") + for name in files: + archive.write(ROOT / name, name) + print(args.output) + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_gpui_linux.py b/scripts/smoke_gpui_linux.py new file mode 100644 index 0000000..1bd8002 --- /dev/null +++ b/scripts/smoke_gpui_linux.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Render a native window and close it through X11 against a read-only fake API. + +Requires an X11 DISPLAY, xdotool, and a compositor/window manager. CI uses +xvfb-run with --start-session. No real game service or game mutations are used. +""" +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import subprocess +import threading +import time + + +class ReadOnlyStatus(BaseHTTPRequestHandler): + def do_GET(self): + if self.path != "/api/status": + self.send_error(404) + return + payload = json.dumps({"appVersion": "GPUI smoke", "platform": "Linux", "gameDetected": False, "gameDir": None, "launchProfiles": [], "logs": []}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_POST(self): + self.send_error(405, "Smoke service is read-only") + + def log_message(self, *_): + pass + + +def stop(process): + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--executable", type=Path, required=True) + parser.add_argument("--start-session", action="store_true") + args = parser.parse_args() + owned = [] + with ThreadingHTTPServer(("127.0.0.1", 0), ReadOnlyStatus) as server: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + if args.start_session: + owned.append(subprocess.Popen(["openbox", "--sm-disable"])) + owned.append(subprocess.Popen(["xcompmgr"])) + time.sleep(1) + env = {**os.environ, "PATCHOPSIII_GPUI_BACKEND_URL": f"http://127.0.0.1:{server.server_port}"} + app = subprocess.Popen([str(args.executable.resolve())], env=env) + owned.append(app) + deadline = time.monotonic() + 30 + window = None + while time.monotonic() < deadline: + if app.poll() is not None: + raise RuntimeError(f"GPUI exited during startup: {app.returncode}") + result = subprocess.run(["xdotool", "search", "--onlyvisible", "--pid", str(app.pid), "--name", "PatchOpsIII"], capture_output=True, text=True) + if result.returncode == 0 and result.stdout.strip(): + window = result.stdout.splitlines()[0] + break + time.sleep(0.1) + if window is None: + raise RuntimeError("GPUI did not map its native window") + time.sleep(1) + # Close this exact owned window through its WM keyboard shortcut. + # This reproduces the platform callback path that TestAppContext + # cannot exercise; direct XDestroyWindow is not normal app closure. + subprocess.run(["xdotool", "windowactivate", "--sync", window, "key", "alt+F4"], check=True, timeout=5) + result = app.wait(timeout=10) + if result != 0: + raise RuntimeError(f"Native window close failed with exit code {result}") + print("GPUI native window startup and normal X11 close passed") + finally: + for process in reversed(owned): + stop(process) + server.shutdown() + thread.join(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_gpui.py b/scripts/test_gpui.py new file mode 100644 index 0000000..ae4a975 --- /dev/null +++ b/scripts/test_gpui.py @@ -0,0 +1,63 @@ +"""Lifecycle checks use harmless stand-ins; no game installation is modified.""" +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("gpui_launcher", Path(__file__).with_name("gpui.py")) +launcher = importlib.util.module_from_spec(spec) +spec.loader.exec_module(launcher) + + +class LifecycleTests(unittest.TestCase): + def test_relative_target_directory_is_consistent_for_build_and_run(self): + with patch.dict(os.environ, {"CARGO_TARGET_DIR": "build/custom-gpui"}): + self.assertEqual(launcher.target_directory(), launcher.ROOT / "build/custom-gpui") + + def test_stop_reaps_child(self): + process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + launcher.stop(process) + self.assertIsNotNone(process.poll()) + launcher.stop(process) # idempotent + + def test_startup_failure_cleans_service(self): + process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + try: + with patch.object(launcher.subprocess, "Popen", return_value=process), patch.object(launcher, "wait_ready", side_effect=RuntimeError("unhealthy")): + with self.assertRaisesRegex(RuntimeError, "unhealthy"): + launcher.run(Path(sys.executable)) + self.assertIsNotNone(process.poll()) + finally: + launcher.stop(process) + + def test_early_service_exit_is_reported(self): + process = subprocess.Popen([sys.executable, "-c", "raise SystemExit(7)"]) + process.wait() + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(RuntimeError, "code 7"): + launcher.wait_ready(process, Path(directory) / "ready.json") + + def test_missing_executable_does_not_start_service(self): + with patch.object(launcher.subprocess, "Popen") as popen: + with self.assertRaisesRegex(RuntimeError, "not found"): + launcher.run(Path("/missing/patchopsiii-gpui")) + popen.assert_not_called() + + def test_ui_failure_stops_owned_service(self): + service = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"]) + app = subprocess.Popen([sys.executable, "-c", "raise SystemExit(9)"]) + app.wait() + try: + with patch.object(launcher.subprocess, "Popen", side_effect=[service, app]), patch.object(launcher, "wait_ready", return_value="http://127.0.0.1:12345"): + self.assertEqual(launcher.run(Path(sys.executable)), 9) + self.assertIsNotNone(service.poll()) + finally: + launcher.stop(service) + + +if __name__ == "__main__": + unittest.main()