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:
+22
-25
@@ -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 |
|
||||
|
||||
+23
-31
@@ -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).
|
||||
|
||||
+21
-12
@@ -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)
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
namespace ytLive.Services.Entitlement;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
namespace ytLive.Services.Entitlement;
|
||||
|
||||
/// <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
|
||||
/// lease. Both answer this interface, and everything downstream — the output profile, the greyed
|
||||
/// 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>
|
||||
/// <para>Steam is the source of truth. Failure to reach Steam, load its native library, or
|
||||
/// resolve the paid app ID returns <c>false</c>.</para>
|
||||
/// </summary>
|
||||
public interface IEntitlement
|
||||
{
|
||||
/// <summary>True when this session may emit above the free output budget.</summary>
|
||||
bool IsPro { get; }
|
||||
bool IsAppOwned { get; }
|
||||
}
|
||||
|
||||
@@ -3,41 +3,50 @@ using Steamworks;
|
||||
namespace ytLive.Services.Entitlement;
|
||||
|
||||
/// <summary>
|
||||
/// The lifetime SKU: a Steam **DLC** on the free app, read locally from the Steam client.
|
||||
///
|
||||
/// <para><b>This is the whole purchase story.</b> 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.</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>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class SteamEntitlement : IEntitlement
|
||||
{
|
||||
/// <summary>The DLC's own app id, not the base app's. Zero = not configured yet, which reads
|
||||
/// as "no entitlement" rather than "try anyway".</summary>
|
||||
private readonly uint _dlcAppId;
|
||||
private readonly uint _paidAppId;
|
||||
private readonly Func<bool> _initializeSteam;
|
||||
private readonly Func<uint, bool> _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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <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 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
if (_available) SteamAPI.Shutdown();
|
||||
if (_available) _shutdownSteam();
|
||||
_available = false;
|
||||
_cached = null;
|
||||
_initAttempted = false;
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -4,44 +4,45 @@ using ytLive.Services.Entitlement;
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <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
|
||||
/// 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 <see cref="IsPremium"/>; nothing
|
||||
/// downstream of <see cref="HasProOutput"/> cares which one proved it.</para>
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
/// <summary>
|
||||
/// The single input to <see cref="Models.OutputProfile.Resolve"/>.
|
||||
///
|
||||
/// <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;
|
||||
private IEntitlement _entitlement = CreateEntitlement();
|
||||
private bool _isAppOwned;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// inject a fake through <see cref="EntitlementOverride"/>.
|
||||
/// </summary>
|
||||
private IEntitlement _entitlement = SteamEntitlement.NotConfigured();
|
||||
public bool IsAppOwned
|
||||
{
|
||||
get => _isAppOwned;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isAppOwned, value)) return;
|
||||
OnPropertyChanged(nameof(HasProOutput));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public bool HasProOutput => DeveloperMode || IsAppOwned;
|
||||
|
||||
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
|
||||
{
|
||||
get => _entitlement;
|
||||
set
|
||||
{
|
||||
_entitlement = value;
|
||||
OnPropertyChanged(nameof(HasProOutput));
|
||||
IsAppOwned = value.IsAppOwned;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+30
-29
@@ -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.
|
||||
|
||||
+9
-10
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
+8
-10
@@ -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."
|
||||
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -7,12 +7,10 @@ using ytLive.ViewModels;
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// <para>The rule that must never break: a missing paid app ID, Steam client, or native library
|
||||
/// fails closed.</para>
|
||||
/// </summary>
|
||||
// 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
|
||||
{
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
reference to it and compiles cleanly. -->
|
||||
<Page Remove="Controls\DeveloperPanel.xaml" />
|
||||
<Compile Remove="Controls\DeveloperPanel.xaml.cs" />
|
||||
<Compile Remove="Services\Entitlement\FakeEntitlement.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Steamworks runtime (scripts/steam-sdk.sh extracts it; steam/ is gitignored).
|
||||
|
||||
Reference in New Issue
Block a user