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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ Reliability rule for runtime/mod tasks:
evidence: bundle `TestResults/runs/LIVE-TACTICAL-20260228-211256/repro-bundle.json`
evidence: bundle `TestResults/runs/LIVE-AOTR-20260228-211521/repro-bundle.json`
evidence: bundle `TestResults/runs/LIVE-ROE-20260228-211757/repro-bundle.json`
- [x] Delta closure wave: default non-forced promoted FoC routing + env override (`SWFOC_FORCE_PROMOTED_EXTENDER`), `universal_auto` backend de-trap (`auto`), and profile metadata save-root portability cleanup.
evidence: test `tests/SwfocTrainer.Tests/Runtime/BackendRouterTests.cs`
evidence: test `tests/SwfocTrainer.Tests/Profiles/ProfileInheritanceTests.cs`
evidence: test `tests/SwfocTrainer.Tests/Profiles/ProfileMetadataPortabilityTests.cs`
evidence: manual `2026-03-01` `dotnet restore SwfocTrainer.sln` + `dotnet build SwfocTrainer.sln -c Release --no-restore` + `dotnet test tests/SwfocTrainer.Tests/SwfocTrainer.Tests.csproj -c Release --no-build --filter "FullyQualifiedName!~SwfocTrainer.Tests.Profiles.Live&FullyQualifiedName!~RuntimeAttachSmokeTests"` => `Passed: 233`
evidence: bundle `TestResults/runs/20260301-004145/repro-bundle.json` (`classification=blocked_environment`, tactical default routing run; no swfoc process detected)
evidence: bundle `TestResults/runs/20260301-004232/repro-bundle.json` (`classification=blocked_environment`, tactical forced-override run; no swfoc process detected)
Comment on lines +126 to +127
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add matching repro-bundle.md evidence entries.

Line 126 and Line 127 list only repro-bundle.json. For runtime/mod closure evidence in this repo, include the paired markdown artifact too for each run ID.

📎 Suggested update
   evidence: bundle `TestResults/runs/20260301-004145/repro-bundle.json` (`classification=blocked_environment`, tactical default routing run; no swfoc process detected)
+  evidence: bundle `TestResults/runs/20260301-004145/repro-bundle.md` (`classification=blocked_environment`, tactical default routing run; no swfoc process detected)
   evidence: bundle `TestResults/runs/20260301-004232/repro-bundle.json` (`classification=blocked_environment`, tactical forced-override run; no swfoc process detected)
+  evidence: bundle `TestResults/runs/20260301-004232/repro-bundle.md` (`classification=blocked_environment`, tactical forced-override run; no swfoc process detected)

Based on learnings: Mod/runtime bugfixes must include a reproducible bundle with TestResults/runs/<runId>/repro-bundle.json and TestResults/runs/<runId>/repro-bundle.md.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
evidence: bundle `TestResults/runs/20260301-004145/repro-bundle.json` (`classification=blocked_environment`, tactical default routing run; no swfoc process detected)
evidence: bundle `TestResults/runs/20260301-004232/repro-bundle.json` (`classification=blocked_environment`, tactical forced-override run; no swfoc process detected)
evidence: bundle `TestResults/runs/20260301-004145/repro-bundle.json` (`classification=blocked_environment`, tactical default routing run; no swfoc process detected)
evidence: bundle `TestResults/runs/20260301-004145/repro-bundle.md` (`classification=blocked_environment`, tactical default routing run; no swfoc process detected)
evidence: bundle `TestResults/runs/20260301-004232/repro-bundle.json` (`classification=blocked_environment`, tactical forced-override run; no swfoc process detected)
evidence: bundle `TestResults/runs/20260301-004232/repro-bundle.md` (`classification=blocked_environment`, tactical forced-override run; no swfoc process detected)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TODO.md` around lines 126 - 127, The two evidence entries that reference only
repro-bundle.json need matching markdown artifacts added: for each run ID listed
(e.g., runs/20260301-004145 and runs/20260301-004232) add a paired evidence line
pointing to the corresponding repro-bundle.md (e.g.,
TestResults/runs/<runId>/repro-bundle.md) so each JSON bundle has its paired
markdown; update the entries that currently show only `repro-bundle.json` to
include the paired `repro-bundle.md` artifact for both run IDs.


## Later (M2 + M3 + M4)

Expand Down
20 changes: 17 additions & 3 deletions docs/LIVE_VALIDATION_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ Use this runbook to gather real-machine evidence for runtime/mod issues and mile
- `native/runtime/SwfocExtender.Host.exe`
- `SWFOC_EXTENDER_HOST_PATH` for every `dotnet test` subprocess.
- If running a live test command manually (outside run-live script), set `SWFOC_EXTENDER_HOST_PATH` explicitly to avoid nondeterministic host resolution.
- Promoted actions are extender-authoritative and fail-closed:
- no managed/memory fallback is accepted for promoted matrix evidence.
- missing or unverified promoted capability must surface fail-closed diagnostics.
- Save-root defaults are runtime-resolved from current user environment (`SaveOptions.DefaultSaveRootPath`); no profile should hardcode per-user save paths.
- Promoted FoC actions default to non-forced routing:
- `SWFOC_FORCE_PROMOTED_EXTENDER` unset/false keeps normal execution-kind routing.
- set `SWFOC_FORCE_PROMOTED_EXTENDER=1` when running extender-authoritative promoted matrix evidence.
- missing or unverified promoted capability under forced mode must surface fail-closed diagnostics.
- For AOTR: ensure launch context resolves to `aotr_1397421866_swfoc`.
- For ROE: ensure launch context resolves to `roe_3447786229_swfoc`.
- From repo root, run on Windows PowerShell.
Expand Down Expand Up @@ -139,6 +141,12 @@ vNext bundle sections (required for runtime-affecting changes):

## 4. Promoted Action Matrix Evidence (Issue #7)

Set extender-forced routing before promoted matrix closure runs:

```powershell
$env:SWFOC_FORCE_PROMOTED_EXTENDER = "1"
```

Promoted matrix evidence must cover 3 profiles x 5 actions (15 total checks):

| Action ID | `base_swfoc` | `aotr_1397421866_swfoc` | `roe_3447786229_swfoc` |
Expand Down Expand Up @@ -168,6 +176,12 @@ Expected evidence behavior for promoted actions:
- `hasFallbackMarker=false`
- fail-closed outcomes use explicit route diagnostics (`SAFETY_FAIL_CLOSED`) and block issue `#7` closure.

Reset the override after matrix runs:

```powershell
Remove-Item Env:SWFOC_FORCE_PROMOTED_EXTENDER -ErrorAction SilentlyContinue
```

`set_unit_cap` promoted matrix contract:

- run as a deterministic two-step sequence per profile:
Expand Down
17 changes: 15 additions & 2 deletions docs/TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
- verifies feature-flag gate, missing runtime context gate, and mode mismatch blocking
- `BackendRouterTests`
- verifies fail-closed mutating behavior for hard-extender profiles
- verifies extender route promotion only when capability proof is present
- verifies promoted extender routing is opt-in via `SWFOC_FORCE_PROMOTED_EXTENDER`
- verifies extender route promotion only when capability proof is present under override mode
- verifies capability contract blocking and legacy memory fallback behavior
- verifies fallback patch actions stay off promoted extender matrix and preserve managed-memory routing
- `MainViewModelSessionGatingTests`
Expand Down Expand Up @@ -193,7 +194,13 @@ For each profile (`base_sweaw`, `base_swfoc`, `aotr_1397421866_swfoc`, `roe_3447

## Phase 2 promoted action and hotkey checks

Promoted actions are extender-authoritative and fail-closed for FoC profiles.
Promoted FoC actions are not forced to extender by default.
For extender-authoritative promoted matrix evidence, explicitly enable:

```powershell
$env:SWFOC_FORCE_PROMOTED_EXTENDER = "1"
```
Comment on lines +197 to +202
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align matrix pass-count criteria with the current promoted contract.

Line 223 still expects summary.total=15, while the companion runbook gate tracks two-step set_unit_cap entries ([1/2] + [2/2]) for an 18-row closure matrix. Please sync this section to avoid accepting incomplete promoted evidence.

Also applies to: 223-225, 236-240

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/TEST_PLAN.md` around lines 197 - 202, The TEST_PLAN expects
summary.total=15 but the promoted contract now records two-step set_unit_cap
entries (e.g., "[1/2]" + "[2/2]") producing an 18-row closure matrix; update the
assertions/expected values that reference summary.total=15 to summary.total=18
and adjust any companion checks that validate matrix row counts or pass-count
criteria (including the sections that reference set_unit_cap multi-step entries)
so the runbook gate counts both steps as separate rows and will not accept
incomplete promoted evidence.


Evidence must come from `actionStatusDiagnostics` in `repro-bundle.json` (source `live-promoted-action-matrix.json`).

Required profile/action matrix:
Expand Down Expand Up @@ -226,6 +233,12 @@ Verification checklist:
- when `top-mods.json` is present, `actionStatusDiagnostics.entries` must include explicit rows for discovered workshop ids (up to 10 mods x 5 promoted actions)
- top-mod rows may be `Skipped` during deterministic/live runs that only execute shipped profile matrix, but each skipped row must include `skipReasonCode`

After matrix runs:

```powershell
Remove-Item Env:SWFOC_FORCE_PROMOTED_EXTENDER -ErrorAction SilentlyContinue
```

## Live Ops checklist (M1)

For tactical sessions:
Expand Down
1 change: 0 additions & 1 deletion profiles/default/profiles/base_sweaw.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,6 @@
"metadata": {
"profileLineage": "base",
"targetExecutable": "sweaw.exe",
"saveRootDefault": "C:\\Users\\Prekzursil\\Saved Games\\Petroglyph",
"profileAliases": "base_sweaw,sweaw,empire at war,eaw",
"localPathHints": "sweaw,empire at war",
"criticalSymbols": "credits,planet_owner,hero_respawn_timer,game_speed,unit_cap",
Expand Down
1 change: 0 additions & 1 deletion profiles/default/profiles/base_swfoc.json
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,6 @@
"metadata": {
"profileLineage": "base",
"targetExecutable": "swfoc.exe",
"saveRootDefault": "C:\\Users\\Prekzursil\\Saved Games\\Petroglyph",
"profileAliases": "base_swfoc,swfoc,forces of corruption,foc",
"localPathHints": "swfoc,corruption,forces of corruption",
"criticalSymbols": "credits,planet_owner,hero_respawn_timer,game_speed,unit_cap",
Expand Down
10 changes: 2 additions & 8 deletions profiles/default/profiles/universal_auto.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,8 @@
"inherits": "base_swfoc",
"exeTarget": "Swfoc",
"steamWorkshopId": null,
"backendPreference": "extender",
"requiredCapabilities": [
"set_credits",
"freeze_timer",
"toggle_fog_reveal",
"set_unit_cap",
"toggle_instant_build_patch"
],
"backendPreference": "auto",
"requiredCapabilities": [],
"hostPreference": "starwarsg_preferred",
"experimentalFeatures": [
"overlay_ui"
Expand Down
47 changes: 43 additions & 4 deletions src/SwfocTrainer.Runtime/Services/BackendRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ namespace SwfocTrainer.Runtime.Services;

public sealed class BackendRouter : IBackendRouter
{
private const string PromotedExtenderOverrideEnvironmentVariable = "SWFOC_FORCE_PROMOTED_EXTENDER";

private static readonly HashSet<string> PromotedExtenderActionIds = new(StringComparer.OrdinalIgnoreCase)
{
"freeze_timer",
Expand Down Expand Up @@ -59,7 +61,9 @@ private static RouteResolutionState CreateRouteResolutionState(
ProcessMetadata process,
CapabilityReport capabilityReport)
{
var isPromotedExtenderAction = IsPromotedExtenderAction(request.Action.Id, profile, process);
var promotedExtenderOverride = ResolvePromotedExtenderOverrideState();
var isPromotedActionCandidate = IsPromotedExtenderActionCandidate(request.Action.Id, profile, process);
var isPromotedExtenderAction = promotedExtenderOverride.Enabled && isPromotedActionCandidate;
var defaultBackend = MapDefaultBackend(request.Action.ExecutionKind, isPromotedExtenderAction);
var preferredBackend = ResolvePreferredBackend(profile.BackendPreference, defaultBackend, isPromotedExtenderAction);
var isMutating = IsMutating(request.Action.Id);
Expand All @@ -81,7 +85,9 @@ private static RouteResolutionState CreateRouteResolutionState(
profileRequiredCapabilities,
requiredCapabilities,
missingRequired,
isPromotedExtenderAction));
isPromotedExtenderAction,
promotedExtenderOverride.Enabled,
promotedExtenderOverride.Source));
return new RouteResolutionState(
PreferredBackend: preferredBackend,
MutatingAction: isMutating,
Expand All @@ -108,6 +114,9 @@ private static RouteResolutionState CreateRouteResolutionState(
["missingRequiredCapabilities"] = context.MissingRequired,
["hybridExecution"] = false,
["promotedExtenderAction"] = context.PromotedExtenderAction,
["promotedExtenderOverrideEnabled"] = context.PromotedExtenderOverrideEnabled,
["promotedExtenderOverrideSource"] = context.PromotedExtenderOverrideSource,
["promotedExtenderApplied"] = context.PromotedExtenderAction,
["capabilityMapReasonCode"] = capabilityMapReasonCode ?? string.Empty,
["capabilityMapState"] = capabilityMapState ?? string.Empty,
["capabilityDeclaredAvailable"] = capabilityDeclaredAvailable
Expand Down Expand Up @@ -393,7 +402,7 @@ private static string[] ResolveRequiredCapabilitiesForAction(
return required.ToArray();
}

private static bool IsPromotedExtenderAction(
private static bool IsPromotedExtenderActionCandidate(
string actionId,
TrainerProfile profile,
ProcessMetadata process)
Expand All @@ -403,6 +412,30 @@ private static bool IsPromotedExtenderAction(
PromotedExtenderActionIds.Contains(actionId);
}

private static PromotedExtenderOverrideState ResolvePromotedExtenderOverrideState()
{
var raw = Environment.GetEnvironmentVariable(PromotedExtenderOverrideEnvironmentVariable);
if (string.IsNullOrWhiteSpace(raw))
{
return new PromotedExtenderOverrideState(Enabled: false, Source: "default");
}

if (bool.TryParse(raw, out var parsedBool))
{
return new PromotedExtenderOverrideState(Enabled: parsedBool, Source: "env");
}

if (int.TryParse(raw, out var parsedInt))
{
return new PromotedExtenderOverrideState(Enabled: parsedInt != 0, Source: "env");
}

var normalized = raw.Trim();
var enabled = normalized.Equals("on", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("yes", StringComparison.OrdinalIgnoreCase);
return new PromotedExtenderOverrideState(Enabled: enabled, Source: "env");
}

private static bool IsFoCContext(TrainerProfile profile, ProcessMetadata process)
{
return profile.ExeTarget == ExeTarget.Swfoc ||
Expand All @@ -419,12 +452,18 @@ private readonly record struct BackendDiagnosticsContext(
IReadOnlyList<string> ProfileRequiredCapabilities,
IReadOnlyList<string> RequiredCapabilities,
IReadOnlyList<string> MissingRequired,
bool PromotedExtenderAction);
bool PromotedExtenderAction,
bool PromotedExtenderOverrideEnabled,
string PromotedExtenderOverrideSource);

private readonly record struct RouteResolutionState(
ExecutionBackendKind PreferredBackend,
bool MutatingAction,
IReadOnlyList<string> MissingRequired,
Dictionary<string, object?> Diagnostics,
bool PromotedExtenderAction);

private readonly record struct PromotedExtenderOverrideState(
bool Enabled,
string Source);
}
18 changes: 18 additions & 0 deletions tests/SwfocTrainer.Tests/Profiles/ProfileInheritanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,22 @@ public async Task Manifest_Should_List_All_Target_Profiles()
"universal_auto"
]);
}

[Fact]
public async Task UniversalAutoProfile_Should_Default_To_AutoBackendPreference()
{
var root = TestPaths.FindRepoRoot();
var options = new ProfileRepositoryOptions
{
ProfilesRootPath = Path.Combine(root, "profiles", "default")
};

var repository = new FileSystemProfileRepository(options);
var profile = await repository.ResolveInheritedProfileAsync("universal_auto");

profile.BackendPreference.Should().Be("auto");
profile.Actions.Should().ContainKey("set_credits");
profile.Actions.Should().ContainKey("freeze_timer");
profile.Actions.Should().ContainKey("set_unit_cap");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Text.RegularExpressions;
using FluentAssertions;
using SwfocTrainer.Tests.Common;
using Xunit;

namespace SwfocTrainer.Tests.Profiles;

public sealed class ProfileMetadataPortabilityTests
{
private static readonly Regex UserScopedWindowsSaveRootPattern = new(
"\"saveRootDefault\"\\s*:\\s*\"[A-Za-z]:\\\\\\\\Users\\\\\\\\",
RegexOptions.IgnoreCase | RegexOptions.Compiled);

[Fact]
public void DefaultProfiles_ShouldNotContain_UserSpecific_SaveRootDefaults()
{
var root = TestPaths.FindRepoRoot();
var profileDirectory = Path.Combine(root, "profiles", "default", "profiles");
var profileFiles = Directory.GetFiles(profileDirectory, "*.json", SearchOption.TopDirectoryOnly);

profileFiles.Should().NotBeEmpty();

foreach (var profileFile in profileFiles)
{
var content = File.ReadAllText(profileFile);
UserScopedWindowsSaveRootPattern.IsMatch(content).Should().BeFalse(
because: $"profile metadata in '{Path.GetFileName(profileFile)}' should stay portable across user accounts");
}
}
}
Loading
Loading