diff --git a/Controls/DeveloperPanel.xaml b/Controls/DeveloperPanel.xaml
new file mode 100644
index 0000000..64774bf
--- /dev/null
+++ b/Controls/DeveloperPanel.xaml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Controls/DeveloperPanel.xaml.cs b/Controls/DeveloperPanel.xaml.cs
new file mode 100644
index 0000000..4f98c64
--- /dev/null
+++ b/Controls/DeveloperPanel.xaml.cs
@@ -0,0 +1,21 @@
+using System.Windows.Controls;
+
+namespace ytLive.Controls;
+
+///
+/// The developer affordances that must never exist in a shipped build: today, the
+/// all-resolutions override for the output profile.
+///
+/// This file is excluded from the Release compile (ytLive.csproj removes
+/// the Page and this Compile item when Configuration != Debug), so a Release binary
+/// contains no BAML for it and cannot reveal it. That is deliberate over the cheaper
+/// "collapse the panel" approach: a collapsed panel still exists in the visual tree of every
+/// shipped copy, and "not visible" is a much weaker claim than "not compiled".
+///
+public partial class DeveloperPanel : UserControl
+{
+ public DeveloperPanel()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/Controls/OverlayHost.xaml b/Controls/OverlayHost.xaml
index 62c4e73..012e1cf 100644
--- a/Controls/OverlayHost.xaml
+++ b/Controls/OverlayHost.xaml
@@ -111,6 +111,11 @@
+
+
+
diff --git a/Controls/OverlayHost.xaml.cs b/Controls/OverlayHost.xaml.cs
index 6752088..2be1a65 100644
--- a/Controls/OverlayHost.xaml.cs
+++ b/Controls/OverlayHost.xaml.cs
@@ -9,6 +9,12 @@ public partial class OverlayHost : UserControl
public OverlayHost()
{
InitializeComponent();
+#if DEBUG
+ // Development affordances are added at runtime, never declared in XAML: the panel is
+ // excluded from the Release compile (ytLive.csproj), so this line is the only
+ // reference to it and a Release build has none.
+ DeveloperPanelHost.Children.Add(new DeveloperPanel());
+#endif
}
/// Is the enlarged brand-logo preview showing? Test seam — a Popup is a separate
diff --git a/HANDOFF.md b/HANDOFF.md
index 19ed74b..f89d10d 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,40 +1,40 @@
-# HANDOFF — 2026-11-01 (the exe is LlamaCasty.exe; the depot folder builds and verifies)
+# HANDOFF — 2026-11-01 (exe renamed, depot builds, output rule enforced)
## State
-- Branch `main`, level with `origin` through `8f79b20`; this unit (the artifact rename) is local.
-- **The shipped exe is `LlamaCasty.exe`.** `AssemblyName` renamed; four pack URIs fixed (the docs
- said three — `KeepBroadcastDialog.xaml:5` was the fourth); manifest, MSIX script and
- `StorePackagingTests` follow. Guarded by `ytLive.Tests/AssemblyIdentityTests.cs`.
+- Branch `main`, level with `origin` through `8f79b20`; this unit is local.
+- **The shipped exe is `LlamaCasty.exe`** (`AssemblyName` renamed; four pack URIs fixed — the docs
+ said three, `KeepBroadcastDialog.xaml:5` was the fourth). Guarded by `AssemblyIdentityTests`.
- **`scripts/publish.sh`** stages `artifacts/steam-stage` and refuses to finish if `ffmpeg\`, the
licence, privacy or notices file is missing, or test assemblies leaked in. Output 257 MB.
-- **Distribution is Steam-exclusive** (creator ruling 2026-10-31): free app + paid edition as a
- Steam **DLC**; the **$10/month** plan sold off-Steam by Polar and *stated, never sold*, in-app.
- `commercial.md` → Channels is policy; `Distribution.md` is depot mechanics.
+- **The output-profile rule is built and enforced**: `Models/OutputProfile.cs` (921,600 px / 30 fps,
+ fail closed) resolved once in `BuildEncoderOptions`, which feeds the one ffmpeg process writing
+ both outputs. `MainViewModel.DeveloperMode` bypasses it in Debug only.
+- **Distribution is Steam-exclusive** (2026-10-31): free app + paid edition as a Steam **DLC**; the
+ **$10/month** plan sold off-Steam by Polar, *stated, never sold*, in-app.
- **Entitlement is `steamDlcOwned || polarKeyValid`**; `steam_api64.dll` ships beside the exe and
the app must start without Steam, falling back to free.
-- **Site is live with Steam** (`a9d07ce`). `www.llamacasty.com` 301s to the apex.
+- **Site live with Steam** (`a9d07ce`); `www.llamacasty.com` 301s to the apex.
## Next action
-1. **OutputProfile gate** — ≤921,600 px / ≤30 fps for the stream **and** the recorder, greyed tier
- entries, plus the TASK 53 geometry tests that pin every tier.
-2. Then the **entitlement seam** (`IEntitlement`, Steam DLC path behind a fake, Polar path) and the
- licence UI.
-3. Steamworks: App ID waiting on identity verification; then depot, store page (public 2 weeks
- before release) and the DLC app.
+1. **Greyed tier entries + entitlement wiring** — the rule is in place and tested
+ (`Models/OutputProfile.cs`); the dropdown still shows every tier as equally available, and
+ `HasProOutput` is `IsPremium || DeveloperMode` with no Steam DLC path yet.
+2. Then the **entitlement seam** (`IEntitlement`: Steam DLC via `steam_api64.dll`, Polar lease).
+3. Steamworks: App ID waits on identity verification; then depot, store page and the DLC app.
## Landmines
- **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content** — without it the publisher bundles
libav\*.dll into the exe and the app ships unable to find ffmpeg. Recipe in `MyMistakes.md`.
+- **Dev affordances are compiled out, not hidden** — `DeveloperPanel` is removed from the Release
+ build; `Helpers/DevBuild.cs` is the single source for the flag (never `const`).
- **Nothing may gate a feature.** The only paid delta is the output profile; `ShowBrandFlash` stays
`false` and entitlement must never enable it.
- **No checkout inside a Steam build** — the Wallet rule. The monthly is named, never sold, in-app.
- **The org access token never ships.** `organization_id` is public; the token is not.
- **The site publishes the model, never the numbers**, and never takes payment or links a checkout.
- **No self-updater and no version banner**: Steam updates the app.
-- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs only; and
- `ytLive.Tests` carries 3 xUnit analyzer warnings the app-only gate does not see.
-- Not every hex is a theme colour — `BorderColorPalette`/chat defaults are USER DATA; on-stream
- graphics come from `OutputPalette`. A red settings control is still a bug.
+- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs; `ytLive.Tests`
+ carries 3 xUnit warnings the app-only gate does not see; a red settings control is still a bug.
diff --git a/Helpers/DevBuild.cs b/Helpers/DevBuild.cs
new file mode 100644
index 0000000..617ba17
--- /dev/null
+++ b/Helpers/DevBuild.cs
@@ -0,0 +1,29 @@
+namespace ytLive.Helpers;
+
+///
+/// Whether this binary is a **development build**.
+///
+/// Why static readonly and not const: a const makes
+/// if (!DevBuild.IsDevelopment) return; a compile-time branch, so whichever arm is dead
+/// raises CS0162 — in Debug the guard is dead, in Release the body is. A readonly field is set
+/// once at type init, so both arms compile clean and the JIT folds the branch away.
+///
+/// This is not the only defence. The developer panel's XAML and code-behind are
+/// removed from the Release compile entirely (ytLive.csproj, Page/Compile Remove), so a
+/// shipped binary contains no BAML to reveal. This flag is the second lock: it refuses the
+/// value even if something else calls the setter.
+///
+/// Single source for the question. It gates the multi-instance profile
+/// (), the scoreboard's debug seeding, and the all-resolutions
+/// override (MainViewModel.DeveloperMode).
+///
+public static class DevBuild
+{
+ /// True for a Debug compile; false for Release and anything else.
+ public static readonly bool IsDevelopment =
+#if DEBUG
+ true;
+#else
+ false;
+#endif
+}
diff --git a/Models/OutputProfile.cs b/Models/OutputProfile.cs
new file mode 100644
index 0000000..31763a9
--- /dev/null
+++ b/Models/OutputProfile.cs
@@ -0,0 +1,62 @@
+namespace ytLive.Models;
+
+///
+/// The one entitlement rule, as pure policy: what a build is allowed to emit.
+///
+/// Why pixels and a frame rate rather than "720p". The paid delta is output
+/// quality, and a resolution name is a marketing word, not a measurable bound — 720p60 is
+/// 921,600 pixels at twice the frame rate, and vertical 720×1280 is the same pixel count
+/// with a different shape. A pixel budget plus a frame-rate ceiling expresses the same
+/// promise without a lookup table that a new tier can quietly escape.
+///
+/// Fail closed. Callers pass pro: false when entitlement is unknown,
+/// unreadable or unresolved; the free profile is the default state, never the fallback
+/// after an error.
+///
+/// This is not a feature gate. Every feature, scene, overlay and the recording
+/// itself stay available; only the emitted frame gets smaller. Never call it for anything
+/// else — a cracked build buys almost nothing, which is the entire anti-piracy posture.
+///
+public static class OutputProfile
+{
+ /// Free ceiling: 921,600 px = 1280×720, i.e. one 720p frame.
+ public const int FreeMaxPixels = 921_600;
+
+ /// Free ceiling: 30 frames per second.
+ public const int FreeMaxFps = 30;
+
+ /// The named tier a capped build falls back to when nothing else qualifies.
+ public const string FreeFallbackName = "720p30";
+
+ /// Is this tier within the free budget? Pro may use anything shipped.
+ public static bool IsAllowed(QualityOption tier, bool pro)
+ => pro
+ || (tier.Width * tier.Height <= FreeMaxPixels && tier.Fps <= FreeMaxFps);
+
+ ///
+ /// The tier a session will actually encode. A permitted selection passes through
+ /// untouched (so Pro and the free tier's own 720p30 keep their exact bitrate); a
+ /// disallowed one resolves to the best tier the budget allows — most pixels first, then
+ /// most frames, then the cheapest bitrate — because silently encoding something the user
+ /// did not pick is only acceptable if it is the closest legal thing to it.
+ ///
+ /// What the dropdown says.
+ /// Every tier the build offers.
+ /// Entitlement, already resolved. Unknown must be passed as false.
+ public static QualityOption Resolve(
+ QualityOption requested, IEnumerable shipped, bool pro)
+ {
+ if (IsAllowed(requested, pro)) return requested;
+
+ var candidate = shipped
+ .Where(tier => IsAllowed(tier, pro))
+ .OrderByDescending(tier => tier.Width * tier.Height)
+ .ThenByDescending(tier => tier.Fps)
+ .ThenBy(tier => tier.Bitrate)
+ .FirstOrDefault();
+
+ // No shipped tier fits the budget: build the floor explicitly rather than trusting
+ // the table. A build whose tiers were all redefined must still emit a legal frame.
+ return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 6.0, 1280, 720);
+ }
+}
diff --git a/MyMistakes.md b/MyMistakes.md
index 51f6cf1..6aff0fe 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -166,6 +166,20 @@ in Studio → Settings → Channel → **Feature eligibility**:
*"Advanced YouTube features aren't available for this account"*. If ID verification is ever
wanted on a personal channel, do it **before** converting to a brand account.
+### THE `const` THAT MAKES A GUARD UNREACHABLE (2026-11-01)
+
+A dev-build flag declared `const bool` turns `if (!DevBuild.IsDevelopment) return;` into a
+compile-time branch: Debug reports **CS0162 unreachable** on the guard, Release reports it on the
+body — and the repo builds at 0 warnings, so either config fails the gate.
+
+**Fix:** `public static readonly bool IsDevelopment =` … set once from `#if DEBUG`. Both arms
+compile clean in both configurations, and the JIT still folds the branch. The *real* guarantee for
+"this must not ship" is not the flag anyway — it is removing the UI's `Page`/`Compile` items from
+the Release build (``).
+
+⚠️ A Condition on the **ItemGroup** is invisible to a test that reads `item.Attribute("Condition")`.
+Read the parent too — that mistake cost one failing test here.
+
### THE SINGLE-FILE PUBLISHER EATS ffmpeg (2026-11-01)
**Problem:** `dotnet publish` with `PublishSingleFile=true` +
diff --git a/TASKS.md b/TASKS.md
index e201ccf..1afaa52 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -49,7 +49,7 @@ Dog test.
| 54 | 🔶 | Freemium: EULA + privacy shipped; **no licence path built** — needs the entitlement seam, the output-profile cap and the licence UI below; site copy still says free forever | [`task-54`](TASKS/task-54-marcom-free-merch.md) |
| — | ☐ | **Steamworks entitlement** — `steam_api64.dll` + `SteamAPI.Init`, `BIsDlcInstalled(dlcAppId)` behind `IEntitlement`, free profile when Steam is absent | [`commercial`](docs/ai/commercial.md) |
| — | ☐ | **Entitlement seam** — Polar customer-portal path for the monthly (org ID public, org token never in the app), `conditions: {channel_id}`, 7–14 day lease, `--activate` CLI. **No claim page, no server** | [`commercial`](docs/ai/commercial.md) |
-| — | ☐ | **OutputProfile gate** — ≤921,600 px / ≤30 fps for stream **and** recording, greyed tier entries, live re-resolve, never mid-stream | [`commercial`](docs/ai/commercial.md) |
+| — | 🔶 | **OutputProfile gate** — resolver + `EncoderOptions` wiring **done** (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); remaining: greyed tier entries in the dropdown, entitlement wiring | [`commercial`](docs/ai/commercial.md) |
| — | ☐ | **Licence UI** — four states, 7/1/0-day notices, "monthly is on the website, not Steam" line, portal link, licence ID in About | [`commercial`](docs/ai/commercial.md) |
| — | ☐ | **System messages** — `SystemMessage` + `MessageCenter`, strip above the dock, dedupe by key, short history, never composited | [`ui-surface`](docs/ai/ui-surface.md) |
| — | ☐ | **Steam packaging** — rename, `publish` output + `steam_api64.dll`, depot, `steamcmd` upload, store page + assets, DLC app | [`Distribution.md`](Distribution.md) |
diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs
new file mode 100644
index 0000000..b86e031
--- /dev/null
+++ b/ViewModels/MainViewModel.OutputProfile.cs
@@ -0,0 +1,48 @@
+using ytLive.Helpers;
+
+namespace ytLive.ViewModels;
+
+///
+/// The entitlement surface: whether this session may emit output above the free budget.
+///
+/// Deliberately a separate partial from License.cs: that file owns *how* a
+/// licence is validated (Polar today), this one owns *what the answer means* for output.
+/// Steam's DLC check and Polar's lease both collapse into ; nothing
+/// downstream of cares which one proved it.
+///
+public partial class MainViewModel
+{
+ ///
+ /// The single input to .
+ ///
+ /// Fail closed: an unresolved or unreadable licence is simply false, so a
+ /// build with no entitlement emits the free profile. There is no "unknown" branch to get
+ /// wrong.
+ ///
+ /// Developer mode never ships: is only ever
+ /// settable inside a development build (see ), so this expression
+ /// reduces to IsPremium in a Release binary.
+ ///
+ public bool HasProOutput => IsPremium || DeveloperMode;
+
+ private bool _developerMode;
+
+ ///
+ /// Development-only override: ignore the output budget and allow every shipped tier.
+ ///
+ /// There is no UI to reach this in a Release build — the panel that hosts the
+ /// checkbox is excluded from the Release compile — and the setter refuses the value
+ /// unless is true, so even a hand-edited settings
+ /// file cannot unlock it.
+ ///
+ public bool DeveloperMode
+ {
+ get => _developerMode;
+ set
+ {
+ if (!DevBuild.IsDevelopment) return;
+ if (!SetProperty(ref _developerMode, value)) return;
+ OnPropertyChanged(nameof(HasProOutput));
+ }
+ }
+}
diff --git a/ViewModels/MainViewModel.Scoreboard.cs b/ViewModels/MainViewModel.Scoreboard.cs
index 8c8cedf..4238b34 100644
--- a/ViewModels/MainViewModel.Scoreboard.cs
+++ b/ViewModels/MainViewModel.Scoreboard.cs
@@ -78,20 +78,15 @@ public partial class MainViewModel
}
}
-#if DEBUG
- private const bool DevelopmentBuild = true;
-#else
- private const bool DevelopmentBuild = false;
-#endif
-
/// Can the scoreboard be configured at all? Signed-in creators yes; in a DEBUG build
/// always, so the strip can be previewed and judged before there is an account (creator,
/// 2026-10-30).
public bool CanUseScoreboard => IsConnected || IsDevelopmentBuild;
/// True in a DEBUG build. Used only to expose development-only controls, never to
- /// change behaviour — the shipped binary differs by their absence, not by a code path.
- public bool IsDevelopmentBuild => DevelopmentBuild;
+ /// change behaviour — the shipped binary differs by their absence, not by a code path.
+ /// Single source: .
+ public bool IsDevelopmentBuild => DevBuild.IsDevelopment;
private bool _scoreboardSimulate;
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index 70b3d96..ef7807c 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -405,10 +405,14 @@ public partial class MainViewModel : ViewModelBase
// Full encoder options for the current tier, or null when no RTMP URL is
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
+ //
+ // BOTH outputs (RTMP + local MP4) are encoded from this one option set, so the
+ // output-profile rule is applied here, once: a capped build cannot stream 1080p and
+ // record it too. See Models/OutputProfile.cs — pixels, not "720p".
private EncoderOptions? BuildEncoderOptions()
{
var url = _rtmpUrlProvider();
- var quality = SelectedQuality;
+ var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
return new EncoderOptions
{
RtmpUrl = url ?? string.Empty,
diff --git a/docs/ai/commercial.md b/docs/ai/commercial.md
index 3e6834e..9da098c 100644
--- a/docs/ai/commercial.md
+++ b/docs/ai/commercial.md
@@ -31,9 +31,15 @@ The cap is a pixel budget plus a frame rate, not the word "720p":
| Vertical 9:16 | 720×1280 @30 | 1080×1920 @60 |
- **Records too.** The cap governs the file the creator keeps as well as the stream; otherwise
- free 1080p recording is a re-upload hole. One `OutputProfile`, resolved once per session and
- consumed by **both** the stream encoder and the recorder — never two checks.
+ free 1080p recording is a re-upload hole. **There is one ffmpeg process with two output blocks**
+ (FLV to RTMP, MP4 to disk) fed by one `EncoderOptions`, and the profile is resolved once when
+ those options are built (`MainViewModel.Streaming.Operations.cs` → `Models/OutputProfile.cs`).
+ There is no second encoder to keep in step and therefore no second check to forget.
- **Fail closed.** Unresolved, unreadable or unvalidated entitlement = free tier. Never Pro.
+- **The development override exists and never ships.** `MainViewModel.DeveloperMode` bypasses the
+ budget in a Debug build only; the Settings row that toggles it is **excluded from the Release
+ compile** (`ytLive.csproj` removes its Page and code-behind), and the setter refuses the value
+ when `Helpers/DevBuild.IsDevelopment` is false. Not a hidden row — an absent one.
- **Never downgrade a running stream.** The gate applies to the next start; pulling quality out
of a live broadcast is a worse bug than one free 1080p session.
- **The greyed menu entry is the sales surface.** It stays clickable, says what it costs, opens
diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md
index 6eb3e26..8c3273c 100644
--- a/docs/ai/ui-surface.md
+++ b/docs/ai/ui-surface.md
@@ -104,6 +104,7 @@ The app is a **director's control surface**, not an editor. This replaces the OB
- **Left panel:** `EditingActive` is an explicit `EDIT` toggle, not a mode inferred from `StreamStatus`. On = scene layers + properties; off = YouTube chat. Go Live turns editing off and stopping restores it; recording does not automatically switch it off. The staged scene is the preview/edit target; `LiveScene` is the program output.
- **Top bar:** the `EDIT` toggle sits beside the REC/ON-AIR session controls; the brand/gear and account surfaces remain at the sides. The gear's left-click menu remains; right-click opens App Settings directly.
+- **Developer affordances are compiled out of release, not hidden.** Any control that must never ship (today: the all-resolutions override) lives in its own `UserControl` whose `Page` and `Compile` items are removed by `ytLive.csproj` when `$(Configuration) != 'Debug'`, and is added to its host at runtime inside `#if DEBUG` (`OverlayHost`). A collapsed row still exists in the visual tree of every shipped copy; "not compiled" is the only claim worth making. Label such rows with `Brush.Attention` (orange) while they are visible in Debug. Guard: `ytLive.Tests/OutputProfileTests.cs` asserts the build rule, and `Helpers/DevBuild.cs` is the single source for "is this a dev build".
- **Scene strip:** five fixed-size cards above the preview show the scene name and hotkey; the staged scene carries a `PROGRAM` badge, and staged/live borders use orange. Click stages a scene while idle; while streaming or recording it changes program output and starts the selected transition.
- **Preview rail:** stream info, YPP, Socials, and TEST are icon/color tabs on the preview's right edge. Socials opens an animated in-preview drawer; it is no longer a standalone modal window. The bottom live-controls row retains TRAX and the game/mic meters and controls.
- **Central monitor:** shows the staged scene while editing and the program scene when editing is off. Preview transitions snapshot and animate the outgoing picture in every mode; output transitions run while streaming or recording. Go Live itself remains a Cut.
diff --git a/ytLive.Tests/OutputProfileTests.cs b/ytLive.Tests/OutputProfileTests.cs
new file mode 100644
index 0000000..d84a257
--- /dev/null
+++ b/ytLive.Tests/OutputProfileTests.cs
@@ -0,0 +1,112 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using Xunit;
+using ytLive.Models;
+
+namespace ytLive.Tests;
+
+///
+/// The output-profile rule (2026-11-01): a build without Pro may emit at most 921,600 pixels
+/// at 30 fps, and this is the only place that decision is made.
+///
+/// The one thing that must never break: a cracked or unlicensed build silently gets a
+/// *smaller frame*, never a smaller feature set. If a future tier is added above the budget,
+/// the last test here fails instead of shipping it to everyone.
+///
+public class OutputProfileTests
+{
+ /// Mirror of MainViewModel.QualityOptions — the shipped tiers. Kept in
+ /// step deliberately: this is the table the budget has to cover.
+ private static readonly QualityOption[] Shipped =
+ {
+ new("1080p60", 60, 8.0, 1920, 1080),
+ new("1080p30", 30, 8.0, 1920, 1080),
+ new("720p60", 60, 6.0, 1280, 720),
+ new("720p30", 30, 6.0, 1280, 720),
+ new("Vertical 1080p30", 30, 8.0, 1080, 1920),
+ new("Vertical 1080p60", 60, 8.0, 1080, 1920),
+ };
+
+ private static QualityOption Tier(string name) => Shipped.Single(t => t.Display == name);
+
+ [Fact]
+ public void A_free_build_resolves_1080p60_down_to_720p30_with_its_own_bitrate()
+ {
+ var resolved = OutputProfile.Resolve(Tier("1080p60"), Shipped, pro: false);
+
+ Assert.Equal(1280, resolved.Width);
+ Assert.Equal(720, resolved.Height);
+ Assert.Equal(30, resolved.Fps);
+ // The bitrate follows the profile: a capped user is not offered an 8 Mbps budget.
+ Assert.Equal(6.0, resolved.Bitrate);
+ }
+
+ [Fact]
+ public void A_free_build_leaves_its_own_tier_untouched()
+ {
+ var requested = Tier("720p30");
+ Assert.Same(requested, OutputProfile.Resolve(requested, Shipped, pro: false));
+ }
+
+ [Fact]
+ public void Pro_passes_every_shipped_tier_through()
+ {
+ foreach (var tier in Shipped)
+ Assert.Same(tier, OutputProfile.Resolve(tier, Shipped, pro: true));
+ }
+
+ [Theory]
+ [InlineData("1080p60")]
+ [InlineData("1080p30")]
+ [InlineData("720p60")]
+ [InlineData("720p30")]
+ [InlineData("Vertical 1080p30")]
+ [InlineData("Vertical 1080p60")]
+ public void No_free_selection_can_emit_more_than_the_budget(string name)
+ {
+ var resolved = OutputProfile.Resolve(Tier(name), Shipped, pro: false);
+
+ Assert.True(resolved.Width * resolved.Height <= OutputProfile.FreeMaxPixels,
+ $"{resolved.Display} resolves to {resolved.Width * resolved.Height} px on a free build");
+ Assert.True(resolved.Fps <= OutputProfile.FreeMaxFps,
+ $"{resolved.Display} resolves to {resolved.Fps} fps on a free build");
+ }
+
+ [Fact]
+ public void An_empty_tier_table_still_yields_a_legal_frame()
+ {
+ var resolved = OutputProfile.Resolve(Tier("1080p60"), Array.Empty(), pro: false);
+
+ Assert.Equal(1280, resolved.Width);
+ Assert.Equal(720, resolved.Height);
+ Assert.Equal(30, resolved.Fps);
+ }
+
+ ///
+ /// The developer override must not exist in a shipped binary. A runtime test cannot prove
+ /// that (the test host is a Debug build), so this asserts the build rule itself: the
+ /// panel's XAML and code-behind are removed whenever the configuration is not Debug.
+ ///
+ [Fact]
+ public void The_developer_panel_is_excluded_from_the_release_compile()
+ {
+ var project = XDocument.Load(SourceAssert.RepoPath("ytLive.csproj"));
+ var guards = project.Descendants()
+ .Where(e => e.Name.LocalName is "Page" or "Compile")
+ .Where(e => (string?)e.Attribute("Remove") is string remove
+ && remove.Contains("DeveloperPanel", StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ Assert.Equal(2, guards.Length);
+ foreach (var guard in guards)
+ {
+ // The condition may sit on the item or on its ItemGroup — either excludes it.
+ var condition = (string?)guard.Attribute("Condition")
+ ?? (string?)guard.Parent?.Attribute("Condition");
+ Assert.NotNull(condition);
+ Assert.Contains("'$(Configuration)' != 'Debug'", condition, StringComparison.Ordinal);
+ }
+ }
+}
diff --git a/ytLive.csproj b/ytLive.csproj
index 6052d53..e2069ad 100644
--- a/ytLive.csproj
+++ b/ytLive.csproj
@@ -92,6 +92,14 @@
+
+
+
+
+
+