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) { } + } + } }