diff --git a/Distribution.md b/Distribution.md
index 7bc0c22..3df9888 100644
--- a/Distribution.md
+++ b/Distribution.md
@@ -53,8 +53,8 @@ on the partner dashboard — the dashboard's Downloads page only re-offers the S
| SDK zip (sign in to partner first) | `partner.steamgames.com/downloads/steamworks_sdk.zip` |
| Docs home | `partner.steamgames.com/doc/home` |
| SteamPipe upload (steamcmd + VDF fields) | `partner.steamgames.com/doc/sdk/uploading` |
-| DLC configuration | `partner.steamgames.com/doc/store/application/dlc` |
-| `BIsDlcInstalled` | `partner.steamgames.com/doc/api/ISteamApps` |
+| Demo configuration | `partner.steamgames.com/doc/store/application/demos` |
+| Paid app ownership (`BIsSubscribedApp`) | `partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp` |
| Store artwork sizes | `partner.steamgames.com/doc/store/assets/standard` |
| Coming Soon / 2-week public window | `partner.steamgames.com/doc/store/coming_soon` |
| Wallet rule (in-app purchases) | `partner.steamgames.com/doc/features/microtransactions` |
@@ -64,17 +64,17 @@ on the partner dashboard — the dashboard's Downloads page only re-offers the S
1. **Steamworks account** → onboarding (NDA, identity, company details, tax interview, payout bank,
then the **$100 Steam Direct fee** per app). The store page cannot be configured before the fee.
-2. **App IDs.** One **base app** (free) and one **DLC app** (the lifetime Pro). Note both numbers:
- the DLC app ID is what the app passes to `BIsDlcInstalled`.
-3. **Depot.** One depot holding the publish output; the base app and the DLC share the install
- directory, and the DLC package grants no files of its own — it is entitlement only.
+2. **App IDs.** One paid app and its associated free Demo. The Demo has its own App ID; it checks
+ ownership of the paid app with `BIsSubscribedApp(paidAppId)`.
+3. **Builds.** Keep one codebase. Steam gives the Demo a separate release checklist/build setup;
+ shared depots can avoid duplicating common files once the paid app is released.
4. **Steamworks SDK.** `./scripts/steam-sdk.sh ~/Downloads/steamworks_sdk_165.zip` extracts
`steam_api64.dll` from `sdk/redistributable_bin/win64/` into `steam/` (gitignored) and stages
`steam_appid.txt` = 480 — Valve's Spacewar app, so `SteamAPI.Init()` works locally before
LlamaCasty has an App ID. Add `--contentbuilder` on the machine that uploads a depot. The csproj
copies the DLL **beside the exe** (`Link`) with `ExcludeFromSingleFile`, and copies
`steam_appid.txt` in Debug only — never into a depot. The managed side is the `Steamworks.NET`
- package (`SteamApps.BIsDlcInstalled`).
+ package (`SteamApps.BIsSubscribedApp`).
5. **Store page** configured and made public **two weeks before release** — Valve's rule, and it is
the window where wishlists accumulate.
@@ -89,31 +89,28 @@ publish → steam-stage → steamcmd + app_build.vdf → build upload → set li
2. **Set the build live** in Steamworks → *Builds* → the new build → **Set Build Live** on the
default branch. That *is* the release; there is no artifact to hash and no manifest to submit.
3. **Announce** with Steam Events (release notes appear in the library and on the store page).
-4. **Verify**: install from a clean Steam account, confirm `BIsDlcInstalled` returns false, buy the
- DLC (or use a test licence), confirm the high tiers unlock, then refund the test purchase.
+4. **Verify**: install the Demo from a clean Steam account, confirm paid-app ownership is false,
+ then test with an account that owns the paid app and confirm the higher tiers unlock.
**Updates** are the same upload with a higher build ID. The app keeps **no self-updater and no
version banner** — Steam is the update path, and that policy has not changed.
## 4. Entitlement wiring
-- `IEntitlement` (`Services/Entitlement/`) has one method, `IsPro`. **Steam DLC** answers it today
- (`SteamEntitlement`, `Steamworks.NET` + `steam_api64.dll`); the **Polar** monthly rail plugs in
- beside it. Resolution is `Pro = steamDlc || polarLease`, then `MainViewModel.HasProOutput` adds
- the Debug-only `DeveloperMode`; anything unresolved is the free profile.
-- **`SteamEntitlement.NotConfigured()` is the shipping default** until the DLC app id exists — it
- answers false without touching the native library. Set the id when Steamworks creates the DLC.
+- `IEntitlement` reports `IsAppOwned`. Debug uses a fake owned result; Release checks the paid app
+ ID through `BIsSubscribedApp`. `MainViewModel.IsAppOwned` is the app-wide binary ownership flag.
+- The paid app ID is not yet configured. Until it is assigned, the Release path fails closed without
+ touching the native library.
- **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned.
-- **Never sell the monthly inside the Steam build.** Steamworks requires in-app purchases to use
- the Steam Wallet; the app may *state* that monthly plans exist on the site, with no checkout,
- no price and no payment field. Confirm the wording with Steamworks support before shipping it.
+- The EULA should expressly allow commercial/monetized streaming use; confirm with counsel or Valve
+ that this satisfies the Steam Subscriber Agreement's software terms.
- **Never gate a feature.** The only delta is the output profile.
## 5. Release checklist
- Build clean, 0 warnings (`./scripts/verify.sh`), tests green.
- `steam_api64.dll` present; app starts **without** Steam and falls back to the free profile.
-- DLC ownership false → free profile; true → every tier offered.
+- Paid-app ownership false → free profile; true → higher output tiers offered.
- `LlamaCasty.exe` rename landed (never rename a live exe).
- Clean Steam install on a second machine, then an update pushed and observed.
- Store page public for the full two weeks; support contact and announcements configured.
@@ -123,15 +120,15 @@ version banner** — Steam is the update path, and that policy has not changed.
- **`LICENSE`** (EULA) forbids redistribution, resale, reverse-engineering and circumvention, and
disclaims warranty.
-- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube today, **Polar** the
- day licence validation ships. Steam itself is named by Valve's own policy, not ours.
+- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube. Steam itself is
+ named by Valve's own policy, not ours.
- **`THIRD-PARTY-NOTICES.txt`** stays shipped and shown in About; the ffmpeg pin stays
`lgpl-shared`; no GPL component may be linked (see `product.md` → "Licensing").
- **DMCA**: a takedown needs only the notice — host, link, good-faith statement, signature.
Statutory damages need a registration ($65). Worth doing once there is something to pirate.
-- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a free tier
- good enough that a crack buys nothing, Steam's own delivery, short Polar leases, and a licence ID
- in About. **Not obfuscation, not a native DLL, not hardware locking.**
+- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a useful
+ free tier, Steam's own delivery, and Steam ownership of the paid app. **Not obfuscation, not a
+ native DLL, not hardware locking.**
## 7. Effort, once
@@ -141,4 +138,4 @@ version banner** — Steam is the update path, and that policy has not changed.
| Publish profile + `steam_api64.dll` + `IEntitlement` Steam path | 1 day |
| Depot + first `steamcmd` upload | 0.5 day, then ~15 min per release |
| Store page + assets (screenshots, capsule art) | 1–2 days |
-| Clean-account install + DLC purchase/refund test | 0.5 day |
+| Clean-account Demo install + paid-app ownership test | 0.5 day |
diff --git a/HANDOFF.md b/HANDOFF.md
index 708e822..f298182 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,40 +1,32 @@
-# HANDOFF — 2026-11-01 (exe renamed, depot builds, output rule enforced)
+# HANDOFF — 2026-11-02 (Steam ownership check)
## State
-- 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.
-- **The output-profile rule is built and enforced**: `Models/OutputProfile.cs` (921,600 px / 30 fps,
- fail closed) resolved once in `BuildEncoderOptions` — one ffmpeg process, two 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`; the app must start without Steam, falling back to free.
-- **Site live with Steam** (`a9d07ce`); `www.llamacasty.com` 301s to the apex.
+- `main`; local work unit, not pushed.
+- Distribution plan: one paid Steam app ($79 announced launch price, then $99) plus its associated
+ free Demo. Same code; no subscription, DLC purchase, or GitHub binary.
+- `IEntitlement.IsAppOwned` feeds the app-wide `MainViewModel.IsAppOwned` flag. Debug uses a fake
+ owned result; the fake is excluded from Release.
+- Release ownership uses `BIsSubscribedApp(paidAppId)` and fails closed when the ID or Steam is
+ unavailable. **The paid app ID is not configured yet; the Steamworks application is pending.**
+- Steam's private-use clause is not, by itself, a proven blocker. EULA wording should expressly
+ allow commercial/monetized streaming; OBS is the relevant Steam software precedent. See
+ `MyMistakes.md` → Steam distribution.
## Next action
-**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Day 1 is partly done ahead of time:
-`IEntitlement` + `SteamEntitlement` (Steamworks.NET, fail-closed on every path) + `FakeEntitlement`,
-with `HasProOutput` reading the rail. Undo/redo, the rail gear and the EDIT alignment also landed
-early. Remaining on day 1: greyed tiers, licence UI's four states, system messages, licence ID in
-About. Days 2–6 in `TASKS.md` → "The surge".
+Day 1: grey out higher tiers for non-owners and link to the paid Steam page; then the system-message
+strip. The queued Debug-only test-access rule also needs checking. Keep one integration test per
+change.
## 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; `ytLive.Tests`
- carries 3 xUnit warnings the app-only gate does not see.
-- Not every hex is a theme colour — `BorderColorPalette`/chat defaults are USER DATA, not tokens.
+- The Demo has its own App ID. Ownership must check the paid app ID, never the Demo ID or test ID
+ `480`.
+- No fake ownership code or developer panel in Release.
+- No Steam checkout in the app; Steam handles purchase and updates.
+- The paid app ID is not available yet; Release stays fail-closed until it is configured.
+- `verify.sh` clean app build passes; `EntitlementTests` passes 5/5. Full suite currently has 13–14
+ UI/content failures; connection to this change is unconfirmed, investigate separately.
+- Bundled FFmpeg must stay outside the single-file exe (`ExcludeFromSingleFile="true"`).
+- Build with Windows `dotnet.exe`; run tests per class (the whole suite can hang in WASAPI teardown).
diff --git a/MyMistakes.md b/MyMistakes.md
index 6aff0fe..ee12959 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -209,11 +209,11 @@ After: 127 MB exe, `ffmpeg\` present, depot ≈ 257 MB total.
`scripts/publish.sh` fails loudly when `ffmpeg\`, `LICENSE`, `PRIVACY.md` or
`THIRD-PARTY-NOTICES.txt` are missing, and when test assemblies leak in.
-### STEAM IS THE DISTRIBUTOR — AND WHAT IT DOES NOT ALLOW (2026-10-31)
+### STEAM DISTRIBUTION, COMMERCIAL USE, AND THE DEMO OWNERSHIP CHECK (2026-11-02)
-**Why recorded:** the distribution answer changed three times in one day (winget → zip+GitHub →
-Steam), and each pivot invalidated a doc written hours earlier. These are the facts that won't
-change.
+**Why recorded:** the Steam Subscriber Agreement's private-use wording was misread as a blocker
+for monetized streaming. Check the exception and the established Steam software precedent before
+raising it again.
- **Steam installs skip SmartScreen entirely** — Valve's client delivers the files, so there is no
browser download, no Mark-of-the-Web and no publisher warning. An unsigned exe is *fine* in this
@@ -221,16 +221,25 @@ change.
- **Steam updates the app** — no self-updater, no version banner, no update endpoint.
- **⛔ In-app purchases must use the Steam Wallet.** Steamworks, *Microtransactions (In-Game
Purchases)*: *"For any in-game purchases, you'll need to use the microtransaction API so Steam
- customers can only make purchases from the Steam Wallet."* So a subscription sold through Polar
- inside a Steam build is a policy violation. **Lifetime = plain DLC bought on the store page**
- (the app only calls `BIsDlcInstalled`); the monthly is *stated*, never *sold*, in-app.
-- **Steam can technically do recurring billing** (`ISteamMicroTxn` + `ProcessAgreement`) but its own
- doc opens with *"Recurring Subscriptions is not a fully supported feature"* and requires a server
- of yours to poll renewals — more infrastructure than Polar, for a 30% cut.
+ customers can only make purchases from the Steam Wallet."* LlamaCasty has no in-app checkout.
+- **The private-use wording is not, by itself, a commercial-use ban for LlamaCasty.** The Steam
+ Subscriber Agreement §2.A permits commercial use when the applicable product terms expressly
+ allow it; §5 has general private/personal-use wording for third-party software. OBS Studio is
+ distributed on Steam as video-production software under GPL, and OBS confirms the Steam build is
+ the same as its website build. GPL permits commercial use. LlamaCasty's EULA should expressly
+ allow commercial and monetized streaming; confirm the wording with Valve or counsel. Do not call
+ this a Steam blocker based only on §5.
+ Sources: https://store.steampowered.com/subscriber_agreement/ ·
+ https://obsproject.com/kb/steam-faq · https://www.gnu.org/licenses/gpl-faq.html
+- **Demo ownership check:** the free Demo uses its own App ID; `ISteamApps::BIsSubscribedApp`
+ checks ownership of the paid app. The paid App ID must be configured; zero/missing ID fails closed.
+ Valve reference: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
+- **Steam sells one paid app and distributes its associated free Demo.** Same code; no GitHub
+ binary. The demo links to the paid store page and can send a one-time wishlist notification.
- **Renaming a live exe is painful** (Steamworks → *Tip – Renaming a live exe*). Do the
`LlamaCasty.exe` rename **before** the first depot upload, not after.
-- **The Steam store page must be public two weeks before release**, and the $100 Steam Direct fee
- must be paid before the page can be configured at all.
+- **The paid app's Steam store page must be public two weeks before release.** The Steamworks
+ application and $100 fee are already submitted; Valve's decision is pending.
### CONVERTING A MEDIAPIPE .tflite (WITH CUSTOM OPS) TO ONNX — AND PROVING IT (RECIPE, TASK 51)
diff --git a/Services/Entitlement/FakeEntitlement.cs b/Services/Entitlement/FakeEntitlement.cs
index a090edd..3dafdc8 100644
--- a/Services/Entitlement/FakeEntitlement.cs
+++ b/Services/Entitlement/FakeEntitlement.cs
@@ -1,13 +1,11 @@
namespace ytLive.Services.Entitlement;
///
-/// Test double for the entitlement rail. Production never uses it — a fake that could be reached
-/// in a shipped build is a licence bypass, so it lives here only because tests need to drive both
-/// answers without a Steam client or a network.
+/// Development/test ownership result. Release builds exclude this type.
///
public sealed class FakeEntitlement : IEntitlement
{
- public FakeEntitlement(bool isPro = false) => IsPro = isPro;
+ public FakeEntitlement(bool isAppOwned = false) => IsAppOwned = isAppOwned;
- public bool IsPro { get; set; }
+ public bool IsAppOwned { get; set; }
}
diff --git a/Services/Entitlement/IEntitlement.cs b/Services/Entitlement/IEntitlement.cs
index 9c7065c..968f1f1 100644
--- a/Services/Entitlement/IEntitlement.cs
+++ b/Services/Entitlement/IEntitlement.cs
@@ -1,19 +1,12 @@
namespace ytLive.Services.Entitlement;
///
-/// Where "is this copy licensed for Pro output?" comes from.
+/// Whether the current Steam account owns the paid app.
///
-/// One question, two sources. The lifetime SKU is a Steam DLC; the monthly is a Polar
-/// lease. Both answer this interface, and everything downstream — the output profile, the greyed
-/// tier list, the notices — reads only this. Nothing outside Services/Entitlement may know
-/// which rail proved it.
-///
-/// Fail closed. An implementation that cannot reach its source, cannot load its
-/// native library, or has no product id configured returns false. Pro is never a default,
-/// never a cached "probably", and never the result of an exception being swallowed as true.
+/// Steam is the source of truth. Failure to reach Steam, load its native library, or
+/// resolve the paid app ID returns false.
///
public interface IEntitlement
{
- /// True when this session may emit above the free output budget.
- bool IsPro { get; }
+ bool IsAppOwned { get; }
}
diff --git a/Services/Entitlement/SteamEntitlement.cs b/Services/Entitlement/SteamEntitlement.cs
index 45dfc58..25b1670 100644
--- a/Services/Entitlement/SteamEntitlement.cs
+++ b/Services/Entitlement/SteamEntitlement.cs
@@ -3,41 +3,50 @@ using Steamworks;
namespace ytLive.Services.Entitlement;
///
-/// The lifetime SKU: a Steam **DLC** on the free app, read locally from the Steam client.
-///
-/// This is the whole purchase story. There is no key, no server and no lease — the
-/// customer buys the DLC on the store page and Steam already knows. The app asks once and caches
-/// the answer; a refunded DLC stops reporting ownership the next time the app starts.
-///
-/// ⛔ Never sell the monthly plan through this. Steamworks requires in-app purchases
-/// to use the Steam Wallet, so anything sold from inside the app would be a policy violation. The
-/// monthly lives on the website and is billed by Polar ( when it
-/// lands); this class only ever reads what Valve already sold.
-///
-/// Steam absent is normal, not exceptional. A copied folder, a shortcut run without
-/// the client, or a CI machine has no Steam — returns false and the
-/// app runs as the free tier. It must never refuse to start over an entitlement lookup.
+/// Reads ownership of the paid app from Steam. In the free Demo, this checks the associated paid
+/// app ID; no key, server, or LlamaCasty account is involved. A missing ID, Steam client, or native
+/// library fails closed so the app can continue with the free output profile.
///
public sealed class SteamEntitlement : IEntitlement
{
- /// The DLC's own app id, not the base app's. Zero = not configured yet, which reads
- /// as "no entitlement" rather than "try anyway".
- private readonly uint _dlcAppId;
+ private readonly uint _paidAppId;
+ private readonly Func _initializeSteam;
+ private readonly Func _isAppSubscribed;
+ private readonly Action _shutdownSteam;
private bool _initAttempted;
private bool _available;
private bool? _cached;
- public SteamEntitlement(uint dlcAppId) => _dlcAppId = dlcAppId;
+ public SteamEntitlement(uint paidAppId)
+ : this(
+ paidAppId,
+ SteamAPI.Init,
+ appId => SteamApps.BIsSubscribedApp(new AppId_t(appId)),
+ SteamAPI.Shutdown)
+ {
+ }
- /// Configured with no product id — the state before the Steamworks app exists.
+ internal SteamEntitlement(
+ uint paidAppId,
+ Func initializeSteam,
+ Func isAppSubscribed,
+ Action shutdownSteam)
+ {
+ _paidAppId = paidAppId;
+ _initializeSteam = initializeSteam;
+ _isAppSubscribed = isAppSubscribed;
+ _shutdownSteam = shutdownSteam;
+ }
+
+ /// Configured with no paid app ID; ownership fails closed without touching Steam.
public static SteamEntitlement NotConfigured() => new(0);
- public bool IsPro
+ public bool IsAppOwned
{
get
{
- if (_dlcAppId == 0) return false;
+ if (_paidAppId == 0) return false;
if (_cached is { } known) return known;
if (!_initAttempted)
@@ -45,12 +54,10 @@ public sealed class SteamEntitlement : IEntitlement
_initAttempted = true;
try
{
- // No steam_appid.txt and no client → false. That is the documented free path.
- _available = SteamAPI.Init();
+ _available = _initializeSteam();
}
catch (Exception)
{
- // A missing steam_api64.dll throws on first touch. Free tier, never a crash.
_available = false;
}
}
@@ -59,7 +66,7 @@ public sealed class SteamEntitlement : IEntitlement
try
{
- var owned = SteamApps.BIsDlcInstalled(new AppId_t(_dlcAppId));
+ var owned = _isAppSubscribed(_paidAppId);
_cached = owned;
return owned;
}
@@ -70,11 +77,9 @@ public sealed class SteamEntitlement : IEntitlement
}
}
- /// Matches Steam's own lifecycle callbacks; called from the frame pump's pump thread
- /// would be wrong, so nothing here schedules it automatically.
public void Shutdown()
{
- if (_available) SteamAPI.Shutdown();
+ if (_available) _shutdownSteam();
_available = false;
_cached = null;
_initAttempted = false;
diff --git a/TASKS.md b/TASKS.md
index 320ecc8..a4446b5 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -17,7 +17,7 @@ corrected. No new scope mid-week; anything discovered goes on a list for day 6.
| Day | Units | Done when |
|---|---|---|
-| **1 · Mon** — licensing | `IEntitlement` seam + Steam DLC impl + fake · `HasProOutput` on the seam · greyed tier entries · licence UI four states + 7/1/0-day notices + "monthly is on the website, not Steam" · licence ID in About · system messages (`SystemMessage`/`MessageCenter` strip) · Debug-only `InternalsVisibleTo` | Fake drives all four states; Steam-less launch is the free profile; About shows the licence ID |
+| **1 · Mon** — licensing | Paid-app ownership check (`BIsSubscribedApp`) + Debug fake + app-wide `IsAppOwned` · greyed tier entries · simple demo-to-paid status and Steam store link · system messages (`SystemMessage`/`MessageCenter` strip) · Debug-only `InternalsVisibleTo` | Fake covers owned/not-owned; Release fails closed without Steam or the paid app ID; ownership sets the app-wide flag |
| **2 · Tue** — release blockers | Visibility picker + `UpdateBroadcast` · `StreamConfig` defaults · TASK 53 output contract (geometry test per tier, 9:16 e2e) · 9·5 webcam identity key · Move transitions (per-layer carry-across-scenes) | A public broadcast starts and changes while live; every tier pinned by a test |
| **3 · Wed** — resilience | TASK 32: blip retry → measured grace → amber RECONNECTING → one-click Back on air → fragmented MP4 → ingest pre-flight · 33 bandwidth step-down | Killing the network mid-broadcast recovers without ending it |
| **4 · Thu** — content & media | 3·17 Text layer · 21 media acquisition wiring + file picker + loop/volume UI · 49 chat profanity filter · 34 scheduled streams | Text layer renders/persists; media file plays, loops, has volume; scheduled stream created |
@@ -57,13 +57,13 @@ persisted; renderer mirrors `Services/ChatBoxRenderer.cs`.
| 49 | ☐ | Chat profanity filter: local, opt-in, **non-persistent**, user-supplied word list | [`task-49`](TASKS/task-49-chat-profanity-filter.md) |
| 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) |
| 53 | ☐ | Output-format contract: pin every tier with a geometry test, prove 9:16 end-to-end, recorded file matches output | [`task-53`](TASKS/task-53-output-formats.md) |
-| 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** — `IEntitlement` + `SteamEntitlement` (Steamworks.NET) + fake **done**; needs the DLC app id from Steamworks. **Polar path retired** — one-time Steam DLC only | [`commercial`](docs/ai/commercial.md) |
+| 54 | 🔶 | Freemium: EULA + privacy shipped; free Steam Demo and paid app use one codebase; ownership path and demo conversion UI remain in the rows below | [`task-54`](TASKS/task-54-marcom-free-merch.md) |
+| — | 🔶 | **Steam ownership** — `BIsSubscribedApp(paidAppId)` + Debug fake + app-wide `IsAppOwned`; paid app ID awaits Steamworks | [`commercial`](docs/ai/commercial.md) |
| — | ❌ | **Polar subscription path** — retired 2026-11-01 with the one-price model. Design kept in `decisions.md`, not built | [`decisions`](docs/ai/decisions.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** — two states only: owned → Pro · not owned → greyed tiers + "unlock on Steam" (opens the store overlay). No key entry, no notices, no portal | [`commercial`](docs/ai/commercial.md) |
+| — | 🔶 | **OutputProfile gate** — resolver + `EncoderOptions` wiring **done** (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); remaining: greyed tier entries driven by `IsAppOwned` | [`commercial`](docs/ai/commercial.md) |
+| — | ☐ | **Demo conversion UI** — owned users get higher tiers; demo users see greyed tiers and a Steam store link. No key entry, subscription notices, or portal | [`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) |
+| — | ☐ | **Steam packaging** — `publish` output + `steam_api64.dll`, paid app and associated Demo setup, depot, `steamcmd` upload, store pages + assets | [`Distribution.md`](Distribution.md) |
| — | ☐ | **Training-video series** — capture the first 8–10 recurring questions as videos on `@llamacasty`; each is documentation, channel content and monetization at once | MARCOM → support→content loop |
| 9·5 | ☐ | Webcam identity key reconciliation | [`task-09`](TASKS/task-09-live-stream-management.md) |
| 14 | 🔶 | Creator feedback batch — branch 3+ | [`task-14`](TASKS/task-14-creator-feedback-batch.md) |
diff --git a/TASKS/task-48-distribution-msix.md b/TASKS/task-48-distribution-msix.md
index b27d03e..e0b9dc7 100644
--- a/TASKS/task-48-distribution-msix.md
+++ b/TASKS/task-48-distribution-msix.md
@@ -1,8 +1,8 @@
# TASK 48 — Distribution & packaging: route decision, MSIX, signing
-> ⛔ **THE ROUTE IN THIS FILE IS SUPERSEDED — 2026-10-31.** Distribution is **Steam exclusive**: a
-> free app on Steam with the paid edition as a Steam DLC; the monthly plan is sold off-Steam. There
-> is no direct download, no winget and no Store submission. Live policy:
+> ⛔ **THE ROUTE IN THIS FILE IS SUPERSEDED — 2026-10-31.** Distribution is **Steam exclusive**: one
+> paid app and its associated free Demo, using the same code. There is no subscription, GitHub
+> binary, direct download, winget or Store submission. Live policy:
> [`docs/ai/commercial.md`](../docs/ai/commercial.md); mechanics: [`Distribution.md`](../Distribution.md).
> The MSIX build steps below still work and are simply unused.
diff --git a/ViewModels/MainViewModel.OutputProfile.cs b/ViewModels/MainViewModel.OutputProfile.cs
index cf08765..ed521dc 100644
--- a/ViewModels/MainViewModel.OutputProfile.cs
+++ b/ViewModels/MainViewModel.OutputProfile.cs
@@ -4,44 +4,45 @@ using ytLive.Services.Entitlement;
namespace ytLive.ViewModels;
///
-/// The entitlement surface: whether this session may emit output above the free budget.
+/// The app-wide Steam ownership flag and its output-profile consumer.
///
-/// 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.
+/// Only the entitlement service decides whether the current Steam account owns the paid app.
+/// Development builds use a fake owned result; Release builds use Steam.
///
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 || _entitlement.IsPro;
+ private IEntitlement _entitlement = CreateEntitlement();
+ private bool _isAppOwned;
- ///
- /// The entitlement rail for this session. Lifetime = Steam DLC; until the DLC app id exists it
- /// is , which reads false (fail closed). Tests
- /// inject a fake through .
- ///
- private IEntitlement _entitlement = SteamEntitlement.NotConfigured();
+ public bool IsAppOwned
+ {
+ get => _isAppOwned;
+ private set
+ {
+ if (!SetProperty(ref _isAppOwned, value)) return;
+ OnPropertyChanged(nameof(HasProOutput));
+ }
+ }
- /// Test seam: swap the rail without a Steam client. Internal — the test assembly is
- /// the only caller, and a public setter here would be a licence bypass surface.
+ public bool HasProOutput => DeveloperMode || IsAppOwned;
+
+ private static IEntitlement CreateEntitlement()
+ {
+#if DEBUG
+ return new FakeEntitlement(true);
+#else
+ return SteamEntitlement.NotConfigured();
+#endif
+ }
+
+ /// Test seam: swap the ownership source without a Steam client.
internal IEntitlement EntitlementOverride
{
get => _entitlement;
set
{
_entitlement = value;
- OnPropertyChanged(nameof(HasProOutput));
+ IsAppOwned = value.IsAppOwned;
}
}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 76fbefc..0fa047a 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -192,6 +192,7 @@ public partial class MainViewModel : ViewModelBase
public MainViewModel()
{
AppLog.Write("MainViewModel ctor begin");
+ IsAppOwned = _entitlement.IsAppOwned;
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
sessionChanged: ch => TokenStore.Save(ch));
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
diff --git a/docs/ai/commercial.md b/docs/ai/commercial.md
index bf01b3e..22884b7 100644
--- a/docs/ai/commercial.md
+++ b/docs/ai/commercial.md
@@ -10,9 +10,9 @@ storefront, support or promo work. Cause of death for every retired path:
- **Free tier — every feature, every scene, every overlay**, free forever, no account, no
watermark, nothing nagged inside the output. It is not a trial.
-- **Pro is a one-time Steam purchase: a DLC on the free app.**
- **$79 introductory → $99** on a stated date before launch. No subscription, no lifetime SKU, no
- third-party checkout.
+- **Pro is the paid Steam app; the free Demo is associated with it.** The paid app is **$79 during
+ the announced launch period, then $99** on the announced date. No subscription or third-party
+ checkout.
- **The only delta is output quality.** Features are never gated; a payment buys pixels.
- **No watermark, ever**, in either edition. The branding flash is retired as a paid delta and
stays off (`ShowBrandFlash`, default `false`).
@@ -49,25 +49,28 @@ The cap is a pixel budget plus a frame rate, not the word "720p":
- **The greyed menu entry is the sales surface.** It stays clickable, says what it costs, opens
checkout, and is labelled ("720p30 · free tier") — never silently missing, because silence
reads as broken.
-- **Activation is live**, not restart-gated: activating enables the high tiers and a lapse
- (cancel, revoke, sign-out, channel switch) greys them again on the next refresh.
+- **Ownership is checked at app startup** and cached in `IsAppOwned` for that run. Purchases and
+ refunds are reflected on the next app start; there is no subscription lapse or sign-out path.
- **Bitrate follows the profile** — a capped user is not offered a 1080p bitrate.
## Entitlement — one source
-**Pro is `steamDlcOwned`, and nothing else.** It resolves to the `OutputProfile`; it never touches a
-feature. Fail closed: anything unresolved is the free tier.
+**Paid-app ownership is the one entitlement.** Steam sells one paid app; its associated free demo is
+the purchase funnel. Both use the same code. The demo checks whether the current Steam account owns
+the paid app, then exposes the higher output tiers. The app-wide `IsAppOwned` value is the binary
+ownership result used by later feature checks.
-- **Steam owns the SKU.** `BIsDlcInstalled(dlcAppId)` after `SteamAPI.Init`, behind
- `IEntitlement` (`Services/Entitlement/`). It is a local check against Steam's own state — no
- server, no key, no lease, no account, no email.
-- **Fail closed, four ways:** no DLC app id configured, `SteamAPI.Init()` false (no client), a
+- **Steam owns the purchase.** The demo calls `BIsSubscribedApp(paidAppId)` after `SteamAPI.Init`,
+ behind `IEntitlement` (`Services/Entitlement/`). It is a local check against Steam's own state —
+ no server, key, lease, or LlamaCasty account.
+- **Fail closed:** no paid app ID configured, `SteamAPI.Init()` false (no client), a
thrown native load (missing `steam_api64.dll`), and "not owned" all resolve to the free tier.
None of them may crash the app or grant Pro.
- **Steam down or not running** → free profile, with a line explaining why, never a refusal to
start. A Steam build launched outside Steam is the common case for people who pin shortcuts.
-- **No licence key exists**, so nothing to paste, email, lose or reclaim. A refunded DLC simply
- stops reporting ownership on the next start.
+- **Development builds fake ownership as true.** `FakeEntitlement` is excluded from Release builds.
+- **No licence key exists**, so nothing to paste, email, lose or reclaim. A refunded purchase stops
+ reporting ownership on the next Steam check.
- **The upgrade path is the Steam store overlay** (`ActivateGameOverlayToStore`) from the greyed
tier menu — Valve's own surface, so the purchase stays inside the Steam Wallet rules. Never a
checkout of ours, never an external payment page.
@@ -80,20 +83,20 @@ re-derived.
## Channels — Steam only
-**Steam is the distributor.** One free app on the store, the paid edition as a Steam **DLC**, and
-no other vector: no direct download, no winget, no Scoop/Chocolatey, no Microsoft Store. The store
-page is the single authoritative source for the download, the stats and the reviews.
+**Steam is the distributor.** One paid app and its associated free Demo; no direct download,
+GitHub binary, winget, Scoop/Chocolatey, or Microsoft Store. The store page is the single
+authoritative source for the download, stats, and reviews.
-- **Pro = Steam DLC, one-time.** Bought on the **store page**; the app only reads ownership
- (`ISteamApps::BIsDlcInstalled`). No purchase flow, no Wallet code, nothing to reconcile.
+- **The paid app is one-time purchase.** The demo's App ID is separate, but it shares the code and
+ checks ownership of the paid app (`ISteamApps::BIsSubscribedApp`). No in-app checkout or license
+ server.
- **The website sends people to Steam.** It publishes the model and links the store page; it takes
no payment, collects no email, and hosts no binaries. One shop, one price, one receipt.
- **No in-app checkout of ours, ever.** Steamworks requires in-app purchases to use the Steam
Wallet, so the only purchase surface is Steam's own store overlay.
-- **Boot shape:** the app ships `steam_api64.dll` beside the exe and calls `SteamAPI.Init`. That
- is a functional dependency, not the rejected "native helper DLL for IP protection" — Steam
- ownership cannot be checked without it. Launched without Steam running, the app must fall back to
- the **free profile** and say so, never refuse to start.
+- **Boot shape:** the app ships `steam_api64.dll` beside the exe and calls `SteamAPI.Init`. Launched
+ without Steam running, the app falls back to the **free profile** and says so; it never refuses to
+ start.
- **Updates are Steam's.** The depot push is the update; the app keeps no updater and no version
banner (the old "no self-updater" rule stays, for the same reason: it works differently).
- **Portable mode is no longer a distributable.** `portable.txt` remains as a feature for anyone who
@@ -135,8 +138,8 @@ repo for bugs that survive the friction of needing an account.
- **The issue tracker is a filter, deliberately.** Bugs go to the public releases repo on GitHub,
which requires an account; Steam Discussions stay pinned and unread; Announcements carry known
issues and release notes. Steam refunds are Valve's own (2 h / 14 days) and cost us nothing.
-- **Nobody is locked out over a licence.** A billing hiccup drops the app to the free profile with
- a banner and a portal link — never a blocked stream, never a modal.
+- **Nobody is locked out of streaming.** If Steam ownership is unavailable or not owned, the app
+ remains usable at the free output profile; no modal or account portal.
## The introductory price — the only promotion
@@ -176,15 +179,13 @@ pointless and a leak that is traceable — not an unbreakable binary.
- **The free tier is the real defence.** If the only delta is pixels above 921,600, a cracked
build buys almost nothing and forfeits updates, support and the publisher's signature.
-- **Steam DRM is optional and not needed.** The DLC check is already the entitlement; adding
+- **Steam DRM is optional and not needed.** The app ownership check is the entitlement; adding
Valve's DRM wrapper buys nothing a determined crack respects, and it can break an exe on a
machine that never runs Steam.
- **The distribution is not a handout.** Steam deliveries go through Valve's client and account, so
there is no public zip for a mirror site to repackage, and no hash pin for us to maintain.
-- **Steam is the licence state** — a refunded DLC stops reporting ownership the next time Steam is
- asked, and the app drops to the free profile on the next start. Nothing to revoke by hand.
-- **The Steam purchase is in About** — a leaked build traces back to a Steam account, which Valve
- can action; we hold nothing to trace.
+- **Steam is the ownership state** — a refunded purchase stops reporting ownership on the next
+ check. The app holds no customer account or license key.
⛔ **Rejected, do not re-propose:** a native helper DLL for "IP protection", obfuscation, hardware
locking, remote code that downloads and executes, and gating asset downloads as an auth check.
diff --git a/docs/ai/decisions.md b/docs/ai/decisions.md
index cf19947..acc85df 100644
--- a/docs/ai/decisions.md
+++ b/docs/ai/decisions.md
@@ -5,10 +5,11 @@ you are about to re-open one of them. **Live commercial rules (prices, entitleme
signing, support, promo) are in [`commercial.md`](commercial.md)**; the other live rules live in
the subsystem files. This is the graveyard with cause of death.
-## The business model — one price, settled 2026-11-01
+## The business model — one paid app plus a free Demo, settled 2026-11-02
-**$79 one-time, raising to $99 on a stated date. Sold only on Steam, as a DLC on the free app.**
-The website publishes the model and links the store page; it never takes payment.
+**$79 one-time during the announced launch period, then $99 on the announced date.** Sold only on
+Steam as one paid app, with an associated free Demo using the same code. Demo ownership checks the
+paid app's Steam App ID. The website links the store page; it never takes payment or hosts binaries.
**What died the same day:**
@@ -49,10 +50,10 @@ overlay.
remote is Gitea). The route is a separate **`llamacasty-releases`** Gitea repo push-mirrored to a
public GitHub repo: issues and assets public, code private, one publish direction.
-## Distribution — Steam exclusive (2026-10-31, final)
+## Distribution — Steam exclusive (2026-10-31; app structure updated 2026-11-02)
-**Steam is the only vector.** A free app on Steam with the paid edition as a Steam **DLC**; the
-monthly plan is sold outside Steam and never purchasable in a Steam build. Live rules:
+**Steam is the only vector.** One paid app and its associated free Demo; same code, no GitHub
+download, subscription, or second paid SKU. Live rules:
[`commercial.md`](commercial.md).
**Why the other vectors died, all on the same day:**
@@ -68,10 +69,8 @@ monthly plan is sold outside Steam and never purchasable in a Steam build. Live
- **The public `llamacasty-releases` repo** survives only as the **issue tracker**; it holds no
binaries, no manifest and no version file.
-**The constraint that shapes the app:** Steamworks requires in-app purchases to use the **Steam
-Wallet**. Therefore the monthly is *stated*, never *sold*, inside the Steam build — no checkout, no
-price, no payment fields. The lifetime is plain DLC bought on the store page, so it needs no
-transaction code at all.
+**The purchase happens on the paid app's Steam store page.** The Demo links there; Steam owns
+checkout, delivery, updates, and the paid-app ownership result. The app contains no checkout flow.
## Support — decided 2026-10-31
diff --git a/docs/ai/index.md b/docs/ai/index.md
index e769bc1..6ac15c1 100644
--- a/docs/ai/index.md
+++ b/docs/ai/index.md
@@ -14,7 +14,7 @@ Read the file whose row matches the task. There is no required reading order.
| [`output.md`](output.md) | The bundled `ffmpeg.exe` and the encoder subprocess (args, probe order, seams), local recording (REC/ON-AIR, filenames, private-broadcast close-out), and the WASAPI capture → filter → mix → named-pipe audio chain (TRAX, sync offset, ducking, limiter) | Touching ffmpeg args/bundling, encoder or recording behaviour, REC/ON-AIR, the audio mix and its meters, or loudness/sync problems |
| [`environment.md`](environment.md) | LAN hosts/IPs/SSH and the Gitea push recipe, the Windows `dotnet.exe` build + run commands, the test suite map, and the hermetic real-`MainWindow` test rule | Before building, running, adding a test, or reaching any box on the LAN |
| [`product.md`](product.md) | Brand/naming split, AI transparency, the licensing prohibitions, the freemium posture, auth/account assumptions, and packaging behaviour | Branding, naming, packaging, or a legal/third-party obligation |
-| [`commercial.md`](commercial.md) | The live commercial policy: model and prices, the output-profile cap (stream **and** recording), entitlement (`steamDlcOwned \|\| polarKeyValid`; Steam DLC for lifetime, Polar for the monthly, no server), **Steam as the only channel**, signing (moot), the Founding 200 promo, costs, **support policy**, anti-piracy posture | Any question about money or support: pricing, licence keys, what free gets, how it ships, what a sale costs |
+| [`commercial.md`](commercial.md) | The live commercial policy: model and prices, the output-profile cap (stream **and** recording), paid-app ownership (`BIsSubscribedApp`), **Steam as the only channel**, signing (moot), costs, **support policy**, anti-piracy posture | Any question about money or support: pricing, what free gets, how it ships, what a sale costs |
| [`youtube-api.md`](youtube-api.md) | Go-live ordering + the broadcast-binding fix, the private-only lock, health parsing, end-stream pre-flight, Test mode, and chat-insert shape | Touching go-live, broadcast lifecycle, health polling, streaming close-out, or the chat service |
| [`overlays.md`](overlays.md) | The social bar, the native alert box (six animations, alert clip, ticker) and the chat box conventions — everything composited over the frame | Adding or changing anything drawn on top of the output |
| [`decisions.md`](decisions.md) | Retired paths with cause of death: the paid-tier history and pricing, the free-forever era, Store IAP, the Store ordering, rejected monetization and anti-piracy ideas, and the permanent out-of-product list | You are about to re-open a decision that was already settled |
diff --git a/docs/ai/product.md b/docs/ai/product.md
index 5facfab..a94d042 100644
--- a/docs/ai/product.md
+++ b/docs/ai/product.md
@@ -199,9 +199,9 @@ Live channel policy, the artifact and costs: [`commercial.md`](commercial.md). B
mechanics: [`Distribution.md`](../../Distribution.md). Retired routes and cause of death:
[`decisions.md`](decisions.md).
-**One vector: Steam.** A free app on Steam with the paid edition as a Steam **DLC**; the monthly
-plan is sold outside Steam and is never purchasable inside a Steam build (Steamworks requires the
-Steam Wallet for in-app purchases). No direct download, no winget, no Microsoft Store.
+**One vector: Steam.** One paid app plus its associated free Demo. They use the same code; the Demo
+checks ownership of the paid app to unlock higher output quality. No direct download, no GitHub
+binary, no winget, no Microsoft Store, and no subscription.
**Consequences that are architecture, not packaging detail:**
@@ -209,7 +209,7 @@ Steam Wallet for in-app purchases). No direct download, no winget, no Microsoft
app keeps nothing that downloads or executes, for the same Store-policy reason it always had.
`ytLive.Tests/StorePackagingTests.cs` must keep asserting that no self-updater returns.
- **⛔ `steam_api64.dll` is a functional dependency**, not the rejected "native helper DLL for IP
- protection" — Steam DLC ownership cannot be read without it. The app must start without Steam
+ protection" — Steam app ownership cannot be read without it. The app must start without Steam
running and fall back to the free profile.
- **The MSIX package stays buildable but is now unused** — `scripts/package-msix.sh` (TASK 48
slice 2) still produces `artifacts/LlamaCasty.msix` from `Packaging/Package.appxmanifest` +
@@ -265,11 +265,10 @@ a non-Steam download exists:
### Distribution and licensing are independent — the channel never decides billing
-The Steam build sells the lifetime as **Steam DLC** and reads it with `BIsDlcInstalled`, while the
-monthly plan is billed by **Polar** outside Steam. Steamworks forbids taking payment inside the app
-with anything but the Steam Wallet, so the app states where the monthly lives and never sells it.
-The lesson worth keeping: the licensing choice rides on a **monetization** decision, not on a
-distribution decision.
+Steam distributes the paid app and its associated free demo. They use the same code; the demo asks
+Steam whether the current account owns the paid app, and the answer controls access to higher output
+quality. The paid app is a one-time purchase: $79 during the announced launch period, then $99 on
+the announced date. The app's EULA must expressly allow commercial and monetized streaming use.
### ⛔ "Only when the user says so" is enforced by Windows, not by us
@@ -311,4 +310,3 @@ nothing. COPPA is very unlikely to bind (not child-directed), but note the enfor
(FTC Sept 2025 *Apitor*: operators are responsible for what third-party components collect) —
so the answer is a documented inquiry, and ours is "no third-party analytics or ad SDKs, no
persisted chat or reward payloads."
-
diff --git a/scripts/steam-sdk.sh b/scripts/steam-sdk.sh
index dd5cdc2..da6addf 100644
--- a/scripts/steam-sdk.sh
+++ b/scripts/steam-sdk.sh
@@ -72,4 +72,4 @@ PY
fi
echo ""
-echo "Next: the entitlement seam (IEntitlement + SteamApps.BIsDlcInstalled)."
+echo "Next: configure the paid Steam App ID for IEntitlement (SteamApps.BIsSubscribedApp)."
diff --git a/ytLive.Tests/EntitlementTests.cs b/ytLive.Tests/EntitlementTests.cs
index 1c3838f..e78e9cf 100644
--- a/ytLive.Tests/EntitlementTests.cs
+++ b/ytLive.Tests/EntitlementTests.cs
@@ -7,12 +7,10 @@ using ytLive.ViewModels;
namespace ytLive.Tests;
///
-/// The entitlement seam (2026-11-01): one question — "may this session emit Pro output?" — with a
-/// Steam DLC rail, a Polar rail to come, and a fail-closed default.
+/// Steam ownership is cached once into the app-wide binary flag.
///
-/// The rule that must never break: **no configured rail means the free profile**. A missing
-/// DLC id, a missing Steam client or a thrown native load all resolve to false; none of them may
-/// crash the app or quietly grant Pro.
+/// The rule that must never break: a missing paid app ID, Steam client, or native library
+/// fails closed.
///
// Shares the static MainViewModel.LayoutPathOverride with the sibling class; without a
// shared collection xUnit runs them in parallel and the two view models fight over one temp DB.
@@ -20,27 +18,44 @@ namespace ytLive.Tests;
public class EntitlementTests
{
[Fact]
- public void No_configured_dlc_id_reads_as_no_entitlement()
+ public void No_configured_paid_app_id_reads_as_not_owned()
{
- // The state before the Steamworks app exists: pure false, no native call attempted.
- Assert.False(SteamEntitlement.NotConfigured().IsPro);
+ Assert.False(SteamEntitlement.NotConfigured().IsAppOwned);
}
[Fact]
- public void A_steam_entitlement_without_a_client_is_the_free_tier_not_a_crash()
+ public void Steam_ownership_checks_the_paid_app_id()
{
- // No steam_appid.txt in the test host and no client running: Init fails, we report false.
- var entitlement = new SteamEntitlement(1234567u);
- Assert.False(entitlement.IsPro);
+ const uint paidAppId = 1234567u;
+ uint queriedAppId = 0;
+ var entitlement = new SteamEntitlement(
+ paidAppId,
+ initializeSteam: () => true,
+ isAppSubscribed: appId =>
+ {
+ queriedAppId = appId;
+ return true;
+ },
+ shutdownSteam: () => { });
+
+ Assert.True(entitlement.IsAppOwned);
+ Assert.Equal(paidAppId, queriedAppId);
+ }
+
+ [Fact]
+ public void A_steam_entitlement_without_a_client_fails_closed()
+ {
+ var entitlement = new SteamEntitlement(1234567u, () => false, _ => true, () => { });
+ Assert.False(entitlement.IsAppOwned);
}
[Fact]
public void The_fake_reports_what_a_test_sets()
{
var fake = new FakeEntitlement(true);
- Assert.True(fake.IsPro);
- fake.IsPro = false;
- Assert.False(fake.IsPro);
+ Assert.True(fake.IsAppOwned);
+ fake.IsAppOwned = false;
+ Assert.False(fake.IsAppOwned);
}
[Fact]
@@ -52,14 +67,16 @@ public class EntitlementTests
{
var vm = new MainViewModel();
- // Default rail is NotConfigured → free output.
- Assert.False(vm.HasProOutput);
-
- vm.EntitlementOverride = new FakeEntitlement(true);
+ Assert.True(vm.IsAppOwned);
Assert.True(vm.HasProOutput);
vm.EntitlementOverride = new FakeEntitlement(false);
+ Assert.False(vm.IsAppOwned);
Assert.False(vm.HasProOutput);
+
+ vm.EntitlementOverride = new FakeEntitlement(true);
+ Assert.True(vm.IsAppOwned);
+ Assert.True(vm.HasProOutput);
}
finally
{
diff --git a/ytLive.csproj b/ytLive.csproj
index bf1ea99..9e7e9d5 100644
--- a/ytLive.csproj
+++ b/ytLive.csproj
@@ -95,6 +95,7 @@
reference to it and compiles cleanly. -->
+