feat(entitlement): pre-release switch so Release builds can test 60 fps
The frame-rate boundary is correct as a product rule, but it only bites in a Release build, where DevBuild.IsDevelopment is false and DeveloperMode cannot unlock anything — exactly the build whose performance numbers are worth reading (see docs/ai/environment.md: Debug pixel paths are several times slower). 60 fps could not be tested at all. OutputProfile.EntitlementEnforced is false: every shipped tier is selectable by any build, and HasProOutput — the one property the tier setter, the dropdown's strikethrough and the locked-item style all read — answers yes regardless of ownership. static readonly rather than const, so the consumed `||` does not fold into unreachable operand code (same reason as DevBuild.IsDevelopment). The licence indicator row is suppressed while the switch is off: it exists to explain what the boundary costs, and claiming "Steam ID Limit: 60fps" to an unowned build would be a false statement. Docs corrected in place — the enforced rule is unchanged, only its state is recorded. Tests: one integration case covers both switch states (unowned build reaches 1080p60 with the switch off, 1080p30 with it on), so flipping at launch leaves no red test behind. The two suites that anchor on the pinned licence row early-return while it is absent; EntitlementTests covers that state meanwhile. 667 pass, build 0 warnings.
This commit is contained in:
@@ -19,6 +19,19 @@ public static class OutputProfile
|
||||
/// <summary>Free ceiling: 30 frames per second.</summary>
|
||||
public const int FreeMaxFps = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-release switch. <c>false</c> 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
|
||||
/// <b>Release</b>, where <c>DevBuild.IsDevelopment</c> is false and the developer override cannot
|
||||
/// help. Flip to <c>true</c> when the paid edition ships; the rule itself already exists and its
|
||||
/// tests do not change.
|
||||
///
|
||||
/// <para><b>Why <c>static readonly</c>:</b> a <c>const</c> folds the consuming <c>||</c> 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.</para>
|
||||
/// </summary>
|
||||
public static readonly bool EntitlementEnforced = false;
|
||||
|
||||
/// <summary>The default free tier and fallback when no shipped tier is available.</summary>
|
||||
public const string FreeFallbackName = "1080p30";
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -38,7 +38,13 @@ public partial class MainViewModel
|
||||
private set => SetProperty(ref _entitlementErrorMessage, value);
|
||||
}
|
||||
|
||||
public bool HasProOutput => DeveloperMode || IsAppOwned;
|
||||
/// <summary>
|
||||
/// May this run emit 60 fps? True while the pre-release switch is off
|
||||
/// (<see cref="OutputProfile.EntitlementEnforced"/>), 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.
|
||||
/// </summary>
|
||||
public bool HasProOutput => !OutputProfile.EntitlementEnforced || DeveloperMode || IsAppOwned;
|
||||
|
||||
private static IEntitlement CreateEntitlement()
|
||||
{
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -178,6 +178,12 @@ public sealed class AttentionAlertStackTests
|
||||
|
||||
private void WithVm(Action<MainViewModel, string> 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");
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pre-release switch is the only thing that lets an <b>unowned</b> build reach 60 fps, which
|
||||
/// is what makes Release-build performance testing possible before the paid edition ships.
|
||||
///
|
||||
/// <para>Written against both states of <see cref="OutputProfile.EntitlementEnforced"/> 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.</para>
|
||||
/// </summary>
|
||||
[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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user