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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/architecture/phase5-onlinecontainerized-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Phase 5 — OnlineContainerized Runner Validation Guard + Launch-Path Logging Facts

> Status: **IMPLEMENTED (2026-08-15)**, branch `phase5/container-mode-validation`.
> Phase 5 part 1: the `OnlineContainerized` config-save guard; part 2 documents the
> launch-path logging facts discovered during the Portal 2 (620) pure-PE rendering
> diagnostic.

## Part 1 — Validation guard

### Problem

`OnlineContainerized` runs the game through `<proton>/proton run` inside the Steam
Linux Runtime container (`src/container/launch.rs`). A plain Wine runner has no
`proton` entry script, so a misconfigured game fails **at launch** with
`OnlineContainerized requires a Proton compatibility tool` — a confusing error that
only appears when the user presses Play.

Worse, the configuration can be internally inconsistent without any warning: the
per-game `forced_proton_version` pin and the `steam_mode` are stored separately
(`config.json` vs `user_apps.json`), so a containerized test can silently run a
different runner than the file states (observed 2026-08-15: the running app held an
older in-memory config, launching purepe while the file pinned wine11-wow64).

### Rule

> If `steam_mode` is `OnlineContainerized`, the effective runner (per-game
> `forced_proton_version` → global `proton_version`, mirroring
> `resolve_effective_proton_name`) must classify as a Proton compatibility tool
> (`classify_runner` → `RunnerKind::Proton`, i.e. a `proton` entry script exists).

Rejected updates are refused at config-save time with:

> `OnlineContainerized mode requires a Proton compatibility tool runner (e.g., steamflow-proton-11.0-purepe). Bare Wine runners are not supported in container mode.`

### Implementation

- `src/config.rs` — `validate_online_containerized_runner(steam_mode, forced_proton_version, global_proton_version, library_root) -> Result<(), String>`.
Pure function: non-`OnlineContainerized` modes always pass; container mode resolves
the effective runner (`resolve_runner`) and requires `RunnerKind::Proton`.
- `src/ui.rs` — per-game settings save block: the guard runs before the
`user_configs.insert` + `save_user_configs`; on rejection the update is NOT
persisted and the status bar shows `Configuration rejected: <msg>`.
- Unit tests in `src/config.rs` (`online_containerized_rejects_bare_wine_and_accepts_proton`):
Proton forced ✓, Proton global ✓, bare-Wine forced ✗ (exact message), bare-Wine
global ✗, `OfflineEmulated`/`Auto` never blocked, empty forced → global fallback ✓.

## Part 2 — Launch-path logging facts (from the Phase 5 diagnostic)

### PROTON_LOG only works on the proton-script path

| Launch path | Proton script? | `PROTON_LOG=1` → `~/steam-<appid>.log` | Wine debug destination |
|---|---|---|---|
| `OnlineContainerized` (SLR → `<proton>/proton run`) | yes | **yes** (19.6 KB capture, proton-11.0-1b) | container stderr → `WINE_LOG_OUTPUT` |
| DirectWine, PlainWine runner (`steamflow-runner-wine11-wow64`) | no | **silent no-op** | `~/.config/SteamFlow/logs/wine_<appid>.log` (truncated per launch) |

The proton script also **rewrites `WINEDEBUG`** (its own
`+d3d,+winevulkan,+win32u,+mfplat,+wg_transform,+gstreamer,err+all`), so CLI
`WINEDEBUG="+loaddll,+vulkan,+d3d"` does not survive the container path.

### debug.json precedence

`~/.config/SteamFlow/debug.json` → `DebugConfig { env }` is applied **last** in
`build_env` (after per-game env vars and built-in debug toggles), so its keys win
over everything — including the CLI and the proton default. Caveat: setting
`WINEDLLOVERRIDES` there **replaces** the whole computed override string; include the
full base set when overriding it. Verify via `effective_env.json` in the session dir.

### OnlineContainerized × runner matrix

| steam_mode | effective runner kind | result |
|---|---|---|
| `OnlineContainerized` | Proton (e.g. `steamflow-proton-11.0-purepe`) | OK |
| `OnlineContainerized` | PlainWine / Unknown (e.g. `steamflow-runner-wine11-wow64`) | **rejected at save** (guard) / launch error (without guard) |
| `OfflineEmulated` / `Auto` | anything | never blocked |

## White-screen context (Portal 2 / RTX Remix, 2026-08-15)

Runner-independent remix render-path stall (0 GBuffer passes, all draws skipped,
dxvk-cache frozen, GPU 100%) + a Steam-session gate (game stuck on the loading
screen without a reachable Steam client). Stock D3D9 (mod off + `d3d9=n,b`) renders
under purepe — the runner/DXVK/Vulkan-ICD stack is healthy. Evidence:
`/home/wer/devis/tmp/p2-phase5-20260815/`; full detail in the `rtx-remix-modding`
skill.
125 changes: 125 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,128 @@ pub async fn save_user_configs(configs: &UserConfigStore) -> Result<()> {
.with_context(|| format!("failed writing {}", path.display()))?;
Ok(())
}

/// Validate a per-game Steam-mode configuration against the effective runner
/// (Phase 5 — `OnlineContainerized` guard).
///
/// The containerized launch path (`OnlineContainerized`) runs the game through
/// `<proton>/proton run` inside the Steam Linux Runtime (see
/// [`crate::container::launch`]); a plain Wine runner has no `proton` entry
/// script and would fail at launch time with "OnlineContainerized requires a
/// Proton compatibility tool". Validating at config-save time surfaces the
/// misconfiguration in the UI/CLI before the user hits that launch error.
///
/// The effective runner mirrors [`crate::utils::resolve_effective_proton_name`]
/// precedence: per-game `forced_proton_version` → global `proton_version`.
pub fn validate_online_containerized_runner(
steam_mode: crate::models::SteamMode,
forced_proton_version: Option<&str>,
global_proton_version: &str,
library_root: &std::path::Path,
) -> Result<(), String> {
if steam_mode != crate::models::SteamMode::OnlineContainerized {
return Ok(());
}
let runner_name = forced_proton_version
.filter(|s| !s.trim().is_empty())
.unwrap_or(global_proton_version);
let runner_path = crate::utils::resolve_runner(runner_name, library_root);
if matches!(
crate::utils::classify_runner(&runner_path),
crate::utils::RunnerKind::Proton { .. }
) {
Ok(())
} else {
Err("OnlineContainerized mode requires a Proton compatibility tool runner (e.g., steamflow-proton-11.0-purepe). Bare Wine runners are not supported in container mode.".to_string())
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::models::SteamMode;
use std::path::Path;

/// Create a fake runner root (with or without a `proton` entry script).
fn write_runner(dir: &Path, name: &str, with_proton_script: bool) -> std::path::PathBuf {
let root = dir.join(name);
std::fs::create_dir_all(&root).expect("create runner dir");
if with_proton_script {
std::fs::write(root.join("proton"), "#!/bin/sh\n").expect("write proton script");
}
root
}

#[test]
fn online_containerized_rejects_bare_wine_and_accepts_proton() {
let tmp = tempfile::tempdir().expect("tempdir");
let purepe = write_runner(tmp.path(), "steamflow-proton-11.0-purepe", true);
let wine11 = write_runner(tmp.path(), "steamflow-runner-wine11-wow64", false);

// Proton runner as the per-game forced override → accepted.
assert!(validate_online_containerized_runner(
SteamMode::OnlineContainerized,
Some(purepe.to_str().unwrap()),
"global-default",
tmp.path(),
)
.is_ok());

// Proton runner via the global default (no forced override) → accepted.
assert!(validate_online_containerized_runner(
SteamMode::OnlineContainerized,
None,
purepe.to_str().unwrap(),
tmp.path(),
)
.is_ok());

// Bare Wine runner as the per-game forced override → rejected with the
// exact user-facing message.
let err = validate_online_containerized_runner(
SteamMode::OnlineContainerized,
Some(wine11.to_str().unwrap()),
purepe.to_str().unwrap(),
tmp.path(),
)
.unwrap_err();
assert!(
err.contains("OnlineContainerized mode requires a Proton compatibility tool runner")
);
assert!(err.contains("Bare Wine runners are not supported in container mode"));

// Bare Wine runner via the global default → rejected too.
assert!(validate_online_containerized_runner(
SteamMode::OnlineContainerized,
None,
wine11.to_str().unwrap(),
tmp.path(),
)
.is_err());

// Non-containerized modes are never blocked, even with a bare runner.
assert!(validate_online_containerized_runner(
SteamMode::OfflineEmulated,
Some(wine11.to_str().unwrap()),
wine11.to_str().unwrap(),
tmp.path(),
)
.is_ok());
assert!(validate_online_containerized_runner(
SteamMode::Auto,
Some(wine11.to_str().unwrap()),
wine11.to_str().unwrap(),
tmp.path(),
)
.is_ok());

// An empty forced override falls back to the global runner.
assert!(validate_online_containerized_runner(
SteamMode::OnlineContainerized,
Some(""),
purepe.to_str().unwrap(),
tmp.path(),
)
.is_ok());
}
}
4 changes: 2 additions & 2 deletions src/steam_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3414,7 +3414,7 @@ impl SteamClient {
if actual.starts_with(expected) {
if let Ok(metadata) = std::fs::metadata(&actual) {
let mut permissions = metadata.permissions();
permissions.set_mode(0);
permissions.set_mode(0o0);
let _ = std::fs::set_permissions(&actual, permissions);
}
}
Expand Down Expand Up @@ -5036,7 +5036,7 @@ mod steamwebhelper_management_tests {
std::fs::write(&helper, b"MZ fake webhelper").unwrap();
// Simulate the per-game "Disable CEF" enforcement lock (chmod 000).
let mut perms = std::fs::metadata(&helper).unwrap().permissions();
perms.set_mode(0);
perms.set_mode(0o0);
std::fs::set_permissions(&helper, perms).unwrap();
assert_eq!(std::fs::metadata(&helper).unwrap().permissions().mode() & 0o111, 0);

Expand Down
26 changes: 21 additions & 5 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2166,11 +2166,27 @@ impl SteamLauncher {
// Proton/Wine version".

if changed {
self.user_configs.insert(game.app_id, config);
let store = self.user_configs.clone();
self.runtime.spawn(async move {
let _ = crate::config::save_user_configs(&store).await;
});
// Phase 5: `OnlineContainerized` runs the game through
// `<proton>/proton run` inside the Steam Linux Runtime — a bare
// Wine runner cannot satisfy that. Reject the update at save time
// with a clear message instead of failing at launch.
if let Err(msg) = crate::config::validate_online_containerized_runner(
config.steam_mode,
self.launcher_config
.game_configs
.get(&game.app_id)
.and_then(|c| c.forced_proton_version.as_deref()),
&self.launcher_config.proton_version,
std::path::Path::new(&self.launcher_config.steam_library_path),
) {
self.status = format!("Configuration rejected: {msg}");
} else {
self.user_configs.insert(game.app_id, config);
let store = self.user_configs.clone();
self.runtime.spawn(async move {
let _ = crate::config::save_user_configs(&store).await;
});
}
}
}

Expand Down