feat(entitlement): pivot to paid app ownership

Check Steam ownership of the paid app with BIsSubscribedApp; keep Debug fake ownership out of Release. Ref: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
This commit is contained in:
2026-11-02 09:18:03 -08:00
parent 54902cb838
commit ff5e8d94ae
17 changed files with 229 additions and 217 deletions
+22 -25
View File
@@ -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` | | SDK zip (sign in to partner first) | `partner.steamgames.com/downloads/steamworks_sdk.zip` |
| Docs home | `partner.steamgames.com/doc/home` | | Docs home | `partner.steamgames.com/doc/home` |
| SteamPipe upload (steamcmd + VDF fields) | `partner.steamgames.com/doc/sdk/uploading` | | SteamPipe upload (steamcmd + VDF fields) | `partner.steamgames.com/doc/sdk/uploading` |
| DLC configuration | `partner.steamgames.com/doc/store/application/dlc` | | Demo configuration | `partner.steamgames.com/doc/store/application/demos` |
| `BIsDlcInstalled` | `partner.steamgames.com/doc/api/ISteamApps` | | Paid app ownership (`BIsSubscribedApp`) | `partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp` |
| Store artwork sizes | `partner.steamgames.com/doc/store/assets/standard` | | Store artwork sizes | `partner.steamgames.com/doc/store/assets/standard` |
| Coming Soon / 2-week public window | `partner.steamgames.com/doc/store/coming_soon` | | Coming Soon / 2-week public window | `partner.steamgames.com/doc/store/coming_soon` |
| Wallet rule (in-app purchases) | `partner.steamgames.com/doc/features/microtransactions` | | 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, 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. 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: 2. **App IDs.** One paid app and its associated free Demo. The Demo has its own App ID; it checks
the DLC app ID is what the app passes to `BIsDlcInstalled`. ownership of the paid app with `BIsSubscribedApp(paidAppId)`.
3. **Depot.** One depot holding the publish output; the base app and the DLC share the install 3. **Builds.** Keep one codebase. Steam gives the Demo a separate release checklist/build setup;
directory, and the DLC package grants no files of its own — it is entitlement only. 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 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_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 `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 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 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` `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 5. **Store page** configured and made public **two weeks before release** — Valve's rule, and it is
the window where wishlists accumulate. 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 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. 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). 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 4. **Verify**: install the Demo from a clean Steam account, confirm paid-app ownership is false,
DLC (or use a test licence), confirm the high tiers unlock, then refund the test purchase. 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 **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. version banner** — Steam is the update path, and that policy has not changed.
## 4. Entitlement wiring ## 4. Entitlement wiring
- `IEntitlement` (`Services/Entitlement/`) has one method, `IsPro`. **Steam DLC** answers it today - `IEntitlement` reports `IsAppOwned`. Debug uses a fake owned result; Release checks the paid app
(`SteamEntitlement`, `Steamworks.NET` + `steam_api64.dll`); the **Polar** monthly rail plugs in ID through `BIsSubscribedApp`. `MainViewModel.IsAppOwned` is the app-wide binary ownership flag.
beside it. Resolution is `Pro = steamDlc || polarLease`, then `MainViewModel.HasProOutput` adds - The paid app ID is not yet configured. Until it is assigned, the Release path fails closed without
the Debug-only `DeveloperMode`; anything unresolved is the free profile. touching the native library.
- **`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.
- **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned. - **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 EULA should expressly allow commercial/monetized streaming use; confirm with counsel or Valve
the Steam Wallet; the app may *state* that monthly plans exist on the site, with no checkout, that this satisfies the Steam Subscriber Agreement's software terms.
no price and no payment field. Confirm the wording with Steamworks support before shipping it.
- **Never gate a feature.** The only delta is the output profile. - **Never gate a feature.** The only delta is the output profile.
## 5. Release checklist ## 5. Release checklist
- Build clean, 0 warnings (`./scripts/verify.sh`), tests green. - Build clean, 0 warnings (`./scripts/verify.sh`), tests green.
- `steam_api64.dll` present; app starts **without** Steam and falls back to the free profile. - `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). - `LlamaCasty.exe` rename landed (never rename a live exe).
- Clean Steam install on a second machine, then an update pushed and observed. - 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. - 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 - **`LICENSE`** (EULA) forbids redistribution, resale, reverse-engineering and circumvention, and
disclaims warranty. disclaims warranty.
- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube today, **Polar** the - **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube. Steam itself is
day licence validation ships. Steam itself is named by Valve's own policy, not ours. named by Valve's own policy, not ours.
- **`THIRD-PARTY-NOTICES.txt`** stays shipped and shown in About; the ffmpeg pin stays - **`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"). `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. - **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. 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 - **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a useful
good enough that a crack buys nothing, Steam's own delivery, short Polar leases, and a licence ID free tier, Steam's own delivery, and Steam ownership of the paid app. **Not obfuscation, not a
in About. **Not obfuscation, not a native DLL, not hardware locking.** native DLL, not hardware locking.**
## 7. Effort, once ## 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 | | Publish profile + `steam_api64.dll` + `IEntitlement` Steam path | 1 day |
| Depot + first `steamcmd` upload | 0.5 day, then ~15 min per release | | Depot + first `steamcmd` upload | 0.5 day, then ~15 min per release |
| Store page + assets (screenshots, capsule art) | 1–2 days | | 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 |
+23 -31
View File
@@ -1,40 +1,32 @@
# HANDOFF — 2026-11-01 (exe renamed, depot builds, output rule enforced) # HANDOFF — 2026-11-02 (Steam ownership check)
## State ## State
- Branch `main`, level with `origin` through `8f79b20`; this unit is local. - `main`; local work unit, not pushed.
- **The shipped exe is `LlamaCasty.exe`** (`AssemblyName` renamed; four pack URIs fixed — the docs - Distribution plan: one paid Steam app ($79 announced launch price, then $99) plus its associated
said three, `KeepBroadcastDialog.xaml:5` was the fourth). Guarded by `AssemblyIdentityTests`. free Demo. Same code; no subscription, DLC purchase, or GitHub binary.
- **`scripts/publish.sh`** stages `artifacts/steam-stage` and refuses to finish if `ffmpeg\`, the - `IEntitlement.IsAppOwned` feeds the app-wide `MainViewModel.IsAppOwned` flag. Debug uses a fake
licence, privacy or notices file is missing, or test assemblies leaked in. Output 257 MB. owned result; the fake is excluded from Release.
- **The output-profile rule is built and enforced**: `Models/OutputProfile.cs` (921,600 px / 30 fps, - Release ownership uses `BIsSubscribedApp(paidAppId)` and fails closed when the ID or Steam is
fail closed) resolved once in `BuildEncoderOptions` — one ffmpeg process, two outputs. unavailable. **The paid app ID is not configured yet; the Steamworks application is pending.**
`MainViewModel.DeveloperMode` bypasses it in Debug only. - Steam's private-use clause is not, by itself, a proven blocker. EULA wording should expressly
- **Distribution is Steam-exclusive** (2026-10-31): free app + paid edition as a Steam **DLC**; the allow commercial/monetized streaming; OBS is the relevant Steam software precedent. See
**$10/month** plan sold off-Steam by Polar, *stated, never sold*, in-app. Entitlement is `MyMistakes.md` → Steam distribution.
`steamDlcOwned || polarKeyValid`; the app must start without Steam, falling back to free.
- **Site live with Steam** (`a9d07ce`); `www.llamacasty.com` 301s to the apex.
## Next action ## Next action
**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Day 1 is partly done ahead of time: Day 1: grey out higher tiers for non-owners and link to the paid Steam page; then the system-message
`IEntitlement` + `SteamEntitlement` (Steamworks.NET, fail-closed on every path) + `FakeEntitlement`, strip. The queued Debug-only test-access rule also needs checking. Keep one integration test per
with `HasProOutput` reading the rail. Undo/redo, the rail gear and the EDIT alignment also landed change.
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".
## Landmines ## Landmines
- **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content** — without it the publisher bundles - The Demo has its own App ID. Ownership must check the paid app ID, never the Demo ID or test ID
libav\*.dll into the exe and the app ships unable to find ffmpeg. Recipe in `MyMistakes.md`. `480`.
- **Dev affordances are compiled out, not hidden** — `DeveloperPanel` is removed from the Release - No fake ownership code or developer panel in Release.
build; `Helpers/DevBuild.cs` is the single source for the flag (never `const`). - No Steam checkout in the app; Steam handles purchase and updates.
- **Nothing may gate a feature.** The only paid delta is the output profile; `ShowBrandFlash` stays - The paid app ID is not available yet; Release stays fail-closed until it is configured.
`false` and entitlement must never enable it. - `verify.sh` clean app build passes; `EntitlementTests` passes 5/5. Full suite currently has 13–14
- **No checkout inside a Steam build** — the Wallet rule. The monthly is named, never sold, in-app. UI/content failures; connection to this change is unconfirmed, investigate separately.
- **The org access token never ships.** `organization_id` is public; the token is not. - Bundled FFmpeg must stay outside the single-file exe (`ExcludeFromSingleFile="true"`).
- **The site publishes the model, never the numbers**, and never takes payment or links a checkout. - Build with Windows `dotnet.exe`; run tests per class (the whole suite can hang in WASAPI teardown).
- **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.
+21 -12
View File
@@ -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 `scripts/publish.sh` fails loudly when `ffmpeg\`, `LICENSE`, `PRIVACY.md` or
`THIRD-PARTY-NOTICES.txt` are missing, and when test assemblies leak in. `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 → **Why recorded:** the Steam Subscriber Agreement's private-use wording was misread as a blocker
Steam), and each pivot invalidated a doc written hours earlier. These are the facts that won't for monetized streaming. Check the exception and the established Steam software precedent before
change. raising it again.
- **Steam installs skip SmartScreen entirely** — Valve's client delivers the files, so there is no - **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 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. - **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 - **⛔ 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 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 customers can only make purchases from the Steam Wallet."* LlamaCasty has no in-app checkout.
inside a Steam build is a policy violation. **Lifetime = plain DLC bought on the store page** - **The private-use wording is not, by itself, a commercial-use ban for LlamaCasty.** The Steam
(the app only calls `BIsDlcInstalled`); the monthly is *stated*, never *sold*, in-app. Subscriber Agreement §2.A permits commercial use when the applicable product terms expressly
- **Steam can technically do recurring billing** (`ISteamMicroTxn` + `ProcessAgreement`) but its own allow it; §5 has general private/personal-use wording for third-party software. OBS Studio is
doc opens with *"Recurring Subscriptions is not a fully supported feature"* and requires a server distributed on Steam as video-production software under GPL, and OBS confirms the Steam build is
of yours to poll renewals — more infrastructure than Polar, for a 30% cut. 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 - **Renaming a live exe is painful** (Steamworks → *Tip – Renaming a live exe*). Do the
`LlamaCasty.exe` rename **before** the first depot upload, not after. `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 - **The paid app's Steam store page must be public two weeks before release.** The Steamworks
must be paid before the page can be configured at all. 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) ### CONVERTING A MEDIAPIPE .tflite (WITH CUSTOM OPS) TO ONNX — AND PROVING IT (RECIPE, TASK 51)
+3 -5
View File
@@ -1,13 +1,11 @@
namespace ytLive.Services.Entitlement; namespace ytLive.Services.Entitlement;
/// <summary> /// <summary>
/// Test double for the entitlement rail. Production never uses it — a fake that could be reached /// Development/test ownership result. Release builds exclude this type.
/// 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.
/// </summary> /// </summary>
public sealed class FakeEntitlement : IEntitlement 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; }
} }
+4 -11
View File
@@ -1,19 +1,12 @@
namespace ytLive.Services.Entitlement; namespace ytLive.Services.Entitlement;
/// <summary> /// <summary>
/// Where "is this copy licensed for Pro output?" comes from. /// Whether the current Steam account owns the paid app.
/// ///
/// <para><b>One question, two sources.</b> The lifetime SKU is a Steam DLC; the monthly is a Polar /// <para>Steam is the source of truth. Failure to reach Steam, load its native library, or
/// lease. Both answer this interface, and everything downstream — the output profile, the greyed /// resolve the paid app ID returns <c>false</c>.</para>
/// tier list, the notices — reads only this. Nothing outside <c>Services/Entitlement</c> may know
/// which rail proved it.</para>
///
/// <para><b>Fail closed.</b> An implementation that cannot reach its source, cannot load its
/// native library, or has no product id configured returns <c>false</c>. Pro is never a default,
/// never a cached "probably", and never the result of an exception being swallowed as true.</para>
/// </summary> /// </summary>
public interface IEntitlement public interface IEntitlement
{ {
/// <summary>True when this session may emit above the free output budget.</summary> bool IsAppOwned { get; }
bool IsPro { get; }
} }
+33 -28
View File
@@ -3,41 +3,50 @@ using Steamworks;
namespace ytLive.Services.Entitlement; namespace ytLive.Services.Entitlement;
/// <summary> /// <summary>
/// The lifetime SKU: a Steam **DLC** on the free app, read locally from the Steam client. /// 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
/// <para><b>This is the whole purchase story.</b> There is no key, no server and no lease — the /// library fails closed so the app can continue with the free output profile.
/// 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.</para>
///
/// <para><b>⛔ Never sell the monthly plan through this.</b> 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 (<see cref="PolarEntitlement"/> when it
/// lands); this class only ever reads what Valve already sold.</para>
///
/// <para><b>Steam absent is normal, not exceptional.</b> A copied folder, a shortcut run without
/// the client, or a CI machine has no Steam — <see cref="SteamAPI.Init"/> returns false and the
/// app runs as the free tier. It must never refuse to start over an entitlement lookup.</para>
/// </summary> /// </summary>
public sealed class SteamEntitlement : IEntitlement public sealed class SteamEntitlement : IEntitlement
{ {
/// <summary>The DLC's own app id, not the base app's. Zero = not configured yet, which reads private readonly uint _paidAppId;
/// as "no entitlement" rather than "try anyway".</summary> private readonly Func<bool> _initializeSteam;
private readonly uint _dlcAppId; private readonly Func<uint, bool> _isAppSubscribed;
private readonly Action _shutdownSteam;
private bool _initAttempted; private bool _initAttempted;
private bool _available; private bool _available;
private bool? _cached; 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)
{
}
/// <summary>Configured with no product id — the state before the Steamworks app exists.</summary> internal SteamEntitlement(
uint paidAppId,
Func<bool> initializeSteam,
Func<uint, bool> isAppSubscribed,
Action shutdownSteam)
{
_paidAppId = paidAppId;
_initializeSteam = initializeSteam;
_isAppSubscribed = isAppSubscribed;
_shutdownSteam = shutdownSteam;
}
/// <summary>Configured with no paid app ID; ownership fails closed without touching Steam.</summary>
public static SteamEntitlement NotConfigured() => new(0); public static SteamEntitlement NotConfigured() => new(0);
public bool IsPro public bool IsAppOwned
{ {
get get
{ {
if (_dlcAppId == 0) return false; if (_paidAppId == 0) return false;
if (_cached is { } known) return known; if (_cached is { } known) return known;
if (!_initAttempted) if (!_initAttempted)
@@ -45,12 +54,10 @@ public sealed class SteamEntitlement : IEntitlement
_initAttempted = true; _initAttempted = true;
try try
{ {
// No steam_appid.txt and no client → false. That is the documented free path. _available = _initializeSteam();
_available = SteamAPI.Init();
} }
catch (Exception) catch (Exception)
{ {
// A missing steam_api64.dll throws on first touch. Free tier, never a crash.
_available = false; _available = false;
} }
} }
@@ -59,7 +66,7 @@ public sealed class SteamEntitlement : IEntitlement
try try
{ {
var owned = SteamApps.BIsDlcInstalled(new AppId_t(_dlcAppId)); var owned = _isAppSubscribed(_paidAppId);
_cached = owned; _cached = owned;
return owned; return owned;
} }
@@ -70,11 +77,9 @@ public sealed class SteamEntitlement : IEntitlement
} }
} }
/// <summary>Matches Steam's own lifecycle callbacks; called from the frame pump's pump thread
/// would be wrong, so nothing here schedules it automatically.</summary>
public void Shutdown() public void Shutdown()
{ {
if (_available) SteamAPI.Shutdown(); if (_available) _shutdownSteam();
_available = false; _available = false;
_cached = null; _cached = null;
_initAttempted = false; _initAttempted = false;
+6 -6
View File
@@ -17,7 +17,7 @@ corrected. No new scope mid-week; anything discovered goes on a list for day 6.
| Day | Units | Done when | | 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 | | **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 | | **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 | | **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) | | 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) | | 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) | | 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) | | 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) |
| — | 🔶 | **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) | | — | 🔶 | **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) | | — | ❌ | **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) | | — | 🔶 | **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) |
| — | ☐ | **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) | | — | ☐ | **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) | | — | ☐ | **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 | | — | ☐ | **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) | | 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) | | 14 | 🔶 | Creator feedback batch — branch 3+ | [`task-14`](TASKS/task-14-creator-feedback-batch.md) |
+3 -3
View File
@@ -1,8 +1,8 @@
# TASK 48 — Distribution & packaging: route decision, MSIX, signing # TASK 48 — Distribution & packaging: route decision, MSIX, signing
> ⛔ **THE ROUTE IN THIS FILE IS SUPERSEDED — 2026-10-31.** Distribution is **Steam exclusive**: a > ⛔ **THE ROUTE IN THIS FILE IS SUPERSEDED — 2026-10-31.** Distribution is **Steam exclusive**: one
> free app on Steam with the paid edition as a Steam DLC; the monthly plan is sold off-Steam. There > paid app and its associated free Demo, using the same code. There is no subscription, GitHub
> is no direct download, no winget and no Store submission. Live policy: > binary, direct download, winget or Store submission. Live policy:
> [`docs/ai/commercial.md`](../docs/ai/commercial.md); mechanics: [`Distribution.md`](../Distribution.md). > [`docs/ai/commercial.md`](../docs/ai/commercial.md); mechanics: [`Distribution.md`](../Distribution.md).
> The MSIX build steps below still work and are simply unused. > The MSIX build steps below still work and are simply unused.
+27 -26
View File
@@ -4,44 +4,45 @@ using ytLive.Services.Entitlement;
namespace ytLive.ViewModels; namespace ytLive.ViewModels;
/// <summary> /// <summary>
/// The entitlement surface: whether this session may emit output above the free budget. /// The app-wide Steam ownership flag and its output-profile consumer.
/// ///
/// <para>Deliberately a separate partial from <c>License.cs</c>: that file owns *how* a /// <para>Only the entitlement service decides whether the current Steam account owns the paid app.
/// licence is validated (Polar today), this one owns *what the answer means* for output. /// Development builds use a fake owned result; Release builds use Steam.</para>
/// Steam's DLC check and Polar's lease both collapse into <see cref="IsPremium"/>; nothing
/// downstream of <see cref="HasProOutput"/> cares which one proved it.</para>
/// </summary> /// </summary>
public partial class MainViewModel public partial class MainViewModel
{ {
/// <summary> private IEntitlement _entitlement = CreateEntitlement();
/// The single input to <see cref="Models.OutputProfile.Resolve"/>. private bool _isAppOwned;
///
/// <para><b>Fail closed:</b> 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.</para>
///
/// <para><b>Developer mode never ships:</b> <see cref="DeveloperMode"/> is only ever
/// settable inside a development build (see <see cref="DevBuild"/>), so this expression
/// reduces to <c>IsPremium</c> in a Release binary.</para>
/// </summary>
public bool HasProOutput => IsPremium || DeveloperMode || _entitlement.IsPro;
/// <summary> public bool IsAppOwned
/// The entitlement rail for this session. Lifetime = Steam DLC; until the DLC app id exists it {
/// is <see cref="SteamEntitlement.NotConfigured"/>, which reads false (fail closed). Tests get => _isAppOwned;
/// inject a fake through <see cref="EntitlementOverride"/>. private set
/// </summary> {
private IEntitlement _entitlement = SteamEntitlement.NotConfigured(); if (!SetProperty(ref _isAppOwned, value)) return;
OnPropertyChanged(nameof(HasProOutput));
}
}
/// <summary>Test seam: swap the rail without a Steam client. Internal — the test assembly is public bool HasProOutput => DeveloperMode || IsAppOwned;
/// the only caller, and a public setter here would be a licence bypass surface.</summary>
private static IEntitlement CreateEntitlement()
{
#if DEBUG
return new FakeEntitlement(true);
#else
return SteamEntitlement.NotConfigured();
#endif
}
/// <summary>Test seam: swap the ownership source without a Steam client.</summary>
internal IEntitlement EntitlementOverride internal IEntitlement EntitlementOverride
{ {
get => _entitlement; get => _entitlement;
set set
{ {
_entitlement = value; _entitlement = value;
OnPropertyChanged(nameof(HasProOutput)); IsAppOwned = value.IsAppOwned;
} }
} }
+1
View File
@@ -192,6 +192,7 @@ public partial class MainViewModel : ViewModelBase
public MainViewModel() public MainViewModel()
{ {
AppLog.Write("MainViewModel ctor begin"); AppLog.Write("MainViewModel ctor begin");
IsAppOwned = _entitlement.IsAppOwned;
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret, _youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
sessionChanged: ch => TokenStore.Save(ch)); sessionChanged: ch => TokenStore.Save(ch));
_youtubeStream = new YouTubeStreamService(_youtubeAuth); _youtubeStream = new YouTubeStreamService(_youtubeAuth);
+30 -29
View File
@@ -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 - **Free tier — every feature, every scene, every overlay**, free forever, no account, no
watermark, nothing nagged inside the output. It is not a trial. watermark, nothing nagged inside the output. It is not a trial.
- **Pro is a one-time Steam purchase: a DLC on the free app.** - **Pro is the paid Steam app; the free Demo is associated with it.** The paid app is **$79 during
**$79 introductory → $99** on a stated date before launch. No subscription, no lifetime SKU, no the announced launch period, then $99** on the announced date. No subscription or third-party
third-party checkout. checkout.
- **The only delta is output quality.** Features are never gated; a payment buys pixels. - **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 - **No watermark, ever**, in either edition. The branding flash is retired as a paid delta and
stays off (`ShowBrandFlash`, default `false`). 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 - **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 checkout, and is labelled ("720p30 · free tier") — never silently missing, because silence
reads as broken. reads as broken.
- **Activation is live**, not restart-gated: activating enables the high tiers and a lapse - **Ownership is checked at app startup** and cached in `IsAppOwned` for that run. Purchases and
(cancel, revoke, sign-out, channel switch) greys them again on the next refresh. 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. - **Bitrate follows the profile** — a capped user is not offered a 1080p bitrate.
## Entitlement — one source ## Entitlement — one source
**Pro is `steamDlcOwned`, and nothing else.** It resolves to the `OutputProfile`; it never touches a **Paid-app ownership is the one entitlement.** Steam sells one paid app; its associated free demo is
feature. Fail closed: anything unresolved is the free tier. 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 - **Steam owns the purchase.** The demo calls `BIsSubscribedApp(paidAppId)` after `SteamAPI.Init`,
`IEntitlement` (`Services/Entitlement/`). It is a local check against Steam's own state — no behind `IEntitlement` (`Services/Entitlement/`). It is a local check against Steam's own state —
server, no key, no lease, no account, no email. no server, key, lease, or LlamaCasty account.
- **Fail closed, four ways:** no DLC app id configured, `SteamAPI.Init()` false (no client), a - **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. 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. 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 - **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. 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 - **Development builds fake ownership as true.** `FakeEntitlement` is excluded from Release builds.
stops reporting ownership on the next start. - **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 - **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 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. checkout of ours, never an external payment page.
@@ -80,20 +83,20 @@ re-derived.
## Channels — Steam only ## Channels — Steam only
**Steam is the distributor.** One free app on the store, the paid edition as a Steam **DLC**, and **Steam is the distributor.** One paid app and its associated free Demo; no direct download,
no other vector: no direct download, no winget, no Scoop/Chocolatey, no Microsoft Store. The store GitHub binary, winget, Scoop/Chocolatey, or Microsoft Store. The store page is the single
page is the single authoritative source for the download, the stats and the reviews. authoritative source for the download, stats, and reviews.
- **Pro = Steam DLC, one-time.** Bought on the **store page**; the app only reads ownership - **The paid app is one-time purchase.** The demo's App ID is separate, but it shares the code and
(`ISteamApps::BIsDlcInstalled`). No purchase flow, no Wallet code, nothing to reconcile. 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 - **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 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 - **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. 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 - **Boot shape:** the app ships `steam_api64.dll` beside the exe and calls `SteamAPI.Init`. Launched
is a functional dependency, not the rejected "native helper DLL for IP protection" — Steam without Steam running, the app falls back to the **free profile** and says so; it never refuses to
ownership cannot be checked without it. Launched without Steam running, the app must fall back to start.
the **free profile** and say so, never refuse to start.
- **Updates are Steam's.** The depot push is the update; the app keeps no updater and no version - **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). 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 - **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, - **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 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. 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 - **Nobody is locked out of streaming.** If Steam ownership is unavailable or not owned, the app
a banner and a portal link — never a blocked stream, never a modal. remains usable at the free output profile; no modal or account portal.
## The introductory price — the only promotion ## 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 - **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. 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 Valve's DRM wrapper buys nothing a determined crack respects, and it can break an exe on a
machine that never runs Steam. machine that never runs Steam.
- **The distribution is not a handout.** Steam deliveries go through Valve's client and account, so - **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. 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 - **Steam is the ownership state** — a refunded purchase stops reporting ownership on the next
asked, and the app drops to the free profile on the next start. Nothing to revoke by hand. check. The app holds no customer account or license key.
- **The Steam purchase is in About** — a leaked build traces back to a Steam account, which Valve
can action; we hold nothing to trace.
⛔ **Rejected, do not re-propose:** a native helper DLL for "IP protection", obfuscation, hardware ⛔ **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. locking, remote code that downloads and executes, and gating asset downloads as an auth check.
+9 -10
View File
@@ -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 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 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.** **$79 one-time during the announced launch period, then $99 on the announced date.** Sold only on
The website publishes the model and links the store page; it never takes payment. 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:** **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 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. 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 **Steam is the only vector.** One paid app and its associated free Demo; same code, no GitHub
monthly plan is sold outside Steam and never purchasable in a Steam build. Live rules: download, subscription, or second paid SKU. Live rules:
[`commercial.md`](commercial.md). [`commercial.md`](commercial.md).
**Why the other vectors died, all on the same day:** **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 - **The public `llamacasty-releases` repo** survives only as the **issue tracker**; it holds no
binaries, no manifest and no version file. binaries, no manifest and no version file.
**The constraint that shapes the app:** Steamworks requires in-app purchases to use the **Steam **The purchase happens on the paid app's Steam store page.** The Demo links there; Steam owns
Wallet**. Therefore the monthly is *stated*, never *sold*, inside the Steam build — no checkout, no checkout, delivery, updates, and the paid-app ownership result. The app contains no checkout flow.
price, no payment fields. The lifetime is plain DLC bought on the store page, so it needs no
transaction code at all.
## Support — decided 2026-10-31 ## Support — decided 2026-10-31
+1 -1
View File
@@ -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 | | [`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 | | [`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 | | [`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 | | [`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 | | [`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 | | [`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 |
+8 -10
View File
@@ -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: mechanics: [`Distribution.md`](../../Distribution.md). Retired routes and cause of death:
[`decisions.md`](decisions.md). [`decisions.md`](decisions.md).
**One vector: Steam.** A free app on Steam with the paid edition as a Steam **DLC**; the monthly **One vector: Steam.** One paid app plus its associated free Demo. They use the same code; the Demo
plan is sold outside Steam and is never purchasable inside a Steam build (Steamworks requires the checks ownership of the paid app to unlock higher output quality. No direct download, no GitHub
Steam Wallet for in-app purchases). No direct download, no winget, no Microsoft Store. binary, no winget, no Microsoft Store, and no subscription.
**Consequences that are architecture, not packaging detail:** **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. 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. `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 - **⛔ `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. running and fall back to the free profile.
- **The MSIX package stays buildable but is now unused** — `scripts/package-msix.sh` (TASK 48 - **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` + 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 ### 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 Steam distributes the paid app and its associated free demo. They use the same code; the demo asks
monthly plan is billed by **Polar** outside Steam. Steamworks forbids taking payment inside the app Steam whether the current account owns the paid app, and the answer controls access to higher output
with anything but the Steam Wallet, so the app states where the monthly lives and never sells it. quality. The paid app is a one-time purchase: $79 during the announced launch period, then $99 on
The lesson worth keeping: the licensing choice rides on a **monetization** decision, not on a the announced date. The app's EULA must expressly allow commercial and monetized streaming use.
distribution decision.
### ⛔ "Only when the user says so" is enforced by Windows, not by us ### ⛔ "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) — (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 so the answer is a documented inquiry, and ours is "no third-party analytics or ad SDKs, no
persisted chat or reward payloads." persisted chat or reward payloads."
+1 -1
View File
@@ -72,4 +72,4 @@ PY
fi fi
echo "" echo ""
echo "Next: the entitlement seam (IEntitlement + SteamApps.BIsDlcInstalled)." echo "Next: configure the paid Steam App ID for IEntitlement (SteamApps.BIsSubscribedApp)."
+36 -19
View File
@@ -7,12 +7,10 @@ using ytLive.ViewModels;
namespace ytLive.Tests; namespace ytLive.Tests;
/// <summary> /// <summary>
/// The entitlement seam (2026-11-01): one question — "may this session emit Pro output?" — with a /// Steam ownership is cached once into the app-wide binary flag.
/// Steam DLC rail, a Polar rail to come, and a fail-closed default.
/// ///
/// <para>The rule that must never break: **no configured rail means the free profile**. A missing /// <para>The rule that must never break: a missing paid app ID, Steam client, or native library
/// DLC id, a missing Steam client or a thrown native load all resolve to false; none of them may /// fails closed.</para>
/// crash the app or quietly grant Pro.</para>
/// </summary> /// </summary>
// Shares the static MainViewModel.LayoutPathOverride with the sibling class; without a // 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. // 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 public class EntitlementTests
{ {
[Fact] [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().IsAppOwned);
Assert.False(SteamEntitlement.NotConfigured().IsPro);
} }
[Fact] [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. const uint paidAppId = 1234567u;
var entitlement = new SteamEntitlement(1234567u); uint queriedAppId = 0;
Assert.False(entitlement.IsPro); 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] [Fact]
public void The_fake_reports_what_a_test_sets() public void The_fake_reports_what_a_test_sets()
{ {
var fake = new FakeEntitlement(true); var fake = new FakeEntitlement(true);
Assert.True(fake.IsPro); Assert.True(fake.IsAppOwned);
fake.IsPro = false; fake.IsAppOwned = false;
Assert.False(fake.IsPro); Assert.False(fake.IsAppOwned);
} }
[Fact] [Fact]
@@ -52,14 +67,16 @@ public class EntitlementTests
{ {
var vm = new MainViewModel(); var vm = new MainViewModel();
// Default rail is NotConfigured → free output. Assert.True(vm.IsAppOwned);
Assert.False(vm.HasProOutput);
vm.EntitlementOverride = new FakeEntitlement(true);
Assert.True(vm.HasProOutput); Assert.True(vm.HasProOutput);
vm.EntitlementOverride = new FakeEntitlement(false); vm.EntitlementOverride = new FakeEntitlement(false);
Assert.False(vm.IsAppOwned);
Assert.False(vm.HasProOutput); Assert.False(vm.HasProOutput);
vm.EntitlementOverride = new FakeEntitlement(true);
Assert.True(vm.IsAppOwned);
Assert.True(vm.HasProOutput);
} }
finally finally
{ {
+1
View File
@@ -95,6 +95,7 @@
reference to it and compiles cleanly. --> reference to it and compiles cleanly. -->
<Page Remove="Controls\DeveloperPanel.xaml" /> <Page Remove="Controls\DeveloperPanel.xaml" />
<Compile Remove="Controls\DeveloperPanel.xaml.cs" /> <Compile Remove="Controls\DeveloperPanel.xaml.cs" />
<Compile Remove="Services\Entitlement\FakeEntitlement.cs" />
</ItemGroup> </ItemGroup>
<!-- Steamworks runtime (scripts/steam-sdk.sh extracts it; steam/ is gitignored). <!-- Steamworks runtime (scripts/steam-sdk.sh extracts it; steam/ is gitignored).