diff --git a/Models/OutputProfile.cs b/Models/OutputProfile.cs
index 00e0ee9..459de35 100644
--- a/Models/OutputProfile.cs
+++ b/Models/OutputProfile.cs
@@ -19,6 +19,19 @@ public static class OutputProfile
/// Free ceiling: 30 frames per second.
public const int FreeMaxFps = 30;
+ ///
+ /// Pre-release switch. false means the frame-rate boundary is not enforced yet, so every
+ /// shipped tier is selectable by any build — the app is still being tested at 60 fps in
+ /// Release, where DevBuild.IsDevelopment is false and the developer override cannot
+ /// help. Flip to true when the paid edition ships; the rule itself already exists and its
+ /// tests do not change.
+ ///
+ /// Why static readonly: a const folds the consuming || at compile
+ /// time and leaves an unreachable operand, which is a warning. The JIT folds a readonly field
+ /// just as well, and the build stays at zero warnings.
+ ///
+ public static readonly bool EntitlementEnforced = false;
+
/// The default free tier and fallback when no shipped tier is available.
public const string FreeFallbackName = "1080p30";
diff --git a/ViewModels/MainViewModel.Alerts.cs b/ViewModels/MainViewModel.Alerts.cs
index 8ee5ad7..80646fb 100644
--- a/ViewModels/MainViewModel.Alerts.cs
+++ b/ViewModels/MainViewModel.Alerts.cs
@@ -50,7 +50,10 @@ public partial class MainViewModel
private void RefreshSteamLicenceAlert()
{
var existing = _attentionAlerts.FirstOrDefault(alert => alert.Id == SteamLicenceAlertId);
- var hidden = IsAppOwned && _steamLicenceDismissed;
+ // The row exists to explain what the frame-rate boundary costs when it applies. While the
+ // pre-release switch is off nothing is limited, so announcing a limit would be a false claim.
+ var hidden = !OutputProfile.EntitlementEnforced
+ || (IsAppOwned && _steamLicenceDismissed);
if (hidden)
{
diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs
index 3dc5476..0cd877d 100644
--- a/ViewModels/MainViewModel.OutputProfile.cs
+++ b/ViewModels/MainViewModel.OutputProfile.cs
@@ -38,7 +38,13 @@ public partial class MainViewModel
private set => SetProperty(ref _entitlementErrorMessage, value);
}
- public bool HasProOutput => DeveloperMode || IsAppOwned;
+ ///
+ /// May this run emit 60 fps? True while the pre-release switch is off
+ /// (), or for a development build's override, or
+ /// when Steam says the paid app is owned. Everything that gates on the frame rate reads this one
+ /// property: the tier setter, the dropdown's strikethrough, the locked-item style.
+ ///
+ public bool HasProOutput => !OutputProfile.EntitlementEnforced || DeveloperMode || IsAppOwned;
private static IEntitlement CreateEntitlement()
{
diff --git a/docs/ai/commercial.md b/docs/ai/commercial.md
index f81e37b..9086196 100644
--- a/docs/ai/commercial.md
+++ b/docs/ai/commercial.md
@@ -46,9 +46,12 @@ The product rule is frame rate, not pixel count or a resolution label:
| Landscape | 1280×720 @30 · 1920×1080 @30 | all Free tiers + 1280×720 @60 · 1920×1080 @60 |
| Vertical 9:16 | 1080×1920 @30 | all Free tiers + 1080×1920 @60 |
-- **Implementation gap (2026-11-04):** `Models/OutputProfile.cs` still gates by pixel budget plus
- frame rate. Update that rule and its tests to use frame rate alone; this docs unit records the
- product decision but does not change code.
+- **Pre-release: the boundary is not enforced yet (2026-11-12).** `OutputProfile.EntitlementEnforced`
+ is `false`, so every shipped tier — 60 fps included — is selectable by any build until the paid
+ edition ships. It is needed because 60 fps can only be performance-tested in a **Release** build,
+ where `DevBuild.IsDevelopment` is false and `DeveloperMode` cannot unlock anything. Flip the switch
+ at launch: the rule, the resolver and their tests already implement the enforced state, and the
+ `EntitlementTests` case asserts both states so it does not go red on the flip.
- **Records too.** The frame-rate rule governs the file the creator keeps as well as the stream;
otherwise free 60-fps recording is a re-upload hole. **There is one ffmpeg process with two output blocks**
diff --git a/ytLive.Tests/AboutHubTests.cs b/ytLive.Tests/AboutHubTests.cs
index 42af2d3..24ee000 100644
--- a/ytLive.Tests/AboutHubTests.cs
+++ b/ytLive.Tests/AboutHubTests.cs
@@ -141,6 +141,11 @@ public sealed class AboutHubTests
[Fact]
public void Free_user_gets_1080p30_and_cannot_select_or_change_to_60fps()
{
+ // Coverage of the boundary once it is enforced. While the pre-release switch is off
+ // (OutputProfile.EntitlementEnforced == false) no build is limited, so there is nothing here
+ // to assert; that state is covered by EntitlementTests.
+ if (!OutputProfile.EntitlementEnforced) return;
+
_app.Run(() =>
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"llamacasty-upgrade-{Guid.NewGuid():N}.db");
diff --git a/ytLive.Tests/AttentionAlertStackTests.cs b/ytLive.Tests/AttentionAlertStackTests.cs
index a79a751..2fabc32 100644
--- a/ytLive.Tests/AttentionAlertStackTests.cs
+++ b/ytLive.Tests/AttentionAlertStackTests.cs
@@ -178,6 +178,12 @@ public sealed class AttentionAlertStackTests
private void WithVm(Action body)
{
+ // Every case here hangs off the pinned licence row, and that row exists only while the
+ // frame-rate boundary is enforced — pre-release nothing is limited, so it is deliberately
+ // absent. The suite goes dark until launch; the switch-off state is covered by
+ // EntitlementTests.
+ if (!OutputProfile.EntitlementEnforced) return;
+
_app.Run(() =>
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-alerts-{Guid.NewGuid():N}.db");
diff --git a/ytLive.Tests/EntitlementTests.cs b/ytLive.Tests/EntitlementTests.cs
index 78b3467..caeb7af 100644
--- a/ytLive.Tests/EntitlementTests.cs
+++ b/ytLive.Tests/EntitlementTests.cs
@@ -1,6 +1,8 @@
using System;
using System.IO;
+using System.Linq;
using Xunit;
+using ytLive.Models;
using ytLive.Services.Entitlement;
using ytLive.ViewModels;
@@ -87,7 +89,12 @@ public class EntitlementTests
vm.EntitlementOverride = new FakeEntitlement(false);
Assert.False(vm.IsAppOwned);
- Assert.False(vm.HasProOutput);
+ // Pro output is what the frame-rate boundary answers, and while the pre-release switch is
+ // off that answer is yes regardless of ownership. The rail still moves IsAppOwned itself,
+ // and the paid-limit row is absent because nothing is being limited.
+ Assert.Equal(!OutputProfile.EntitlementEnforced, vm.HasProOutput);
+ Assert.Equal(OutputProfile.EntitlementEnforced,
+ vm.AttentionAlerts.Any(alert => alert.Id == "steam-licence"));
vm.EntitlementOverride = new FakeEntitlement(true);
Assert.True(vm.IsAppOwned);
@@ -99,4 +106,36 @@ public class EntitlementTests
try { File.Delete(tempDb); } catch (IOException) { }
}
}
+
+ ///
+ /// The pre-release switch is the only thing that lets an unowned build reach 60 fps, which
+ /// is what makes Release-build performance testing possible before the paid edition ships.
+ ///
+ /// Written against both states of so
+ /// flipping it at launch does not leave a red test behind: with the switch off the unowned view
+ /// model keeps 1080p60; with it on the tier setter refuses it and the free tier stands.
+ ///
+ [Fact]
+ public void An_unowned_build_reaches_60_fps_only_while_the_gate_is_unenforced()
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-entitlement-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ try
+ {
+ var vm = new MainViewModel { EntitlementOverride = new FakeEntitlement(false) };
+
+ vm.SelectedQuality = vm.QualityOptions.Single(tier => tier.Display == "1080p60");
+
+ Assert.False(vm.IsAppOwned);
+
+ // The tier the encoder would actually be built from (MainViewModel.Streaming.Operations).
+ var landed = OutputProfile.Resolve(vm.SelectedQuality, vm.QualityOptions, vm.HasProOutput);
+ Assert.Equal(OutputProfile.EntitlementEnforced ? 30 : 60, landed.Fps);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ try { File.Delete(tempDb); } catch (IOException) { }
+ }
+ }
}