Casual Arts · implementation plan

Product Manifest Plan

Retiring the per-title CGameSpecific* files into Fission, and replacing the hand-copied lineage with one product manifest that every other artefact is projected from — including the descriptor the launchers read.

Status  Draft for review · rev. 3, full catalogue in scope Date  3 September 2026 Companion to the Catalogue Delivery Plan

The shape of it

plan
Summary The per-title game code is not game-specific. It resolves into a genre choice, a registration list that is redundant, and two numeric constants. Move the code into Fission, move the constants into a product manifest, and project everything else — CMake options, the shop record, the launcher's descriptor — out of that one file.

This is one piece of work with two visible outcomes: the retrofit treadmill stops, and the product descriptor from the delivery plan arrives as a projection rather than a new invention. The descriptor is not a separate feature to build; it is the manifest, serialised into the package.

Revision history Rev 1 sampled 3 titles. Rev 2 covered the full reachable corpus — 59 titles — but scoped the reconciliation work to the 10 titles considered live, treating the other 49 as retired. Rev 3 drops that scoping: every repo is being brought current and republished, so “archived” is a status to fix, not a reason to exclude a title. Numbers throughout now describe all 59. This also changed the read on the strongest evidence in the document — one file has already been through this exact refactor, in-house, in 23 of 58 titles, but confined to a single franchise rather than “stalled recently,” which is a sharper and more actionable finding than rev 2's.

What we measured

full corpus · 59 titles, all in scope

This pass covers the whole corpus reachable from archive/ plus the live catalogue — 59 titles — across christmas, cruiseDirector, easter, halloween, parkRanger and the cross-theme packs. Every number below now treats all 59 as one population: with every repo being brought current and republished, “archived” is a status to change, not a reason to exclude a title from this work.

scale16 of 21 CGameSpecific* files are ≥90% monoculture across all 59 titles — one version accounts for nine in ten copies. not configThe two “per-title” constants are not per-title. freeLevelCountSet(12) is identical in all 59 titles. Every cloudInit(…) call across the 36 titles that have one uses the same Steam app id, 2534010. These are constants, not configuration — the manifest fields for them collapse to a single default with no per-title override needed until proven otherwise. lineageCGameSpecificMain.cpp is 11 versions, but they form a chain, not 11 designs. The two largest clusters — 20 titles and 11 titles — differ by six lines: one include, one options-registration call, one cloudInit line. Reconciling “11 versions” sounds like 11 decisions; it is one incremental history read in the wrong direction. franchiseThe correct CollectionListener design already exists and has propagated to 23 of 58 titles — but only within one franchise. All 18 parkRanger titles carry it, plus 5 others. The other 35 titles, spanning every other franchise, still carry the old hardcoded version. See Stalled precedent. breaks6 of 59 titles fail to compile against current Fission — an unguarded call to the renamed firstEntityWith<T>(). Only one, halloween4, was live before this work started; the other five were sitting in archive/ and are now back in scope. See The six broken titles.

The strongest evidence: it's already been done once

confined to one franchise

CGameSpecificCollectionListener.cpp is not lagging config. It is two different designs, and one of them is already the fix this plan proposes.

The better copy reads collection rules — tag, achievement key, stat — from an asset file at runtime instead of hardcoding them:

fsCVariableTable config;
config.load(fsCResourceName("collectionTypes.txt", ...));
fsS32 i = 1;
fsStr tag = config.strGetWithDefault("tag" + fsStr(i), "");
while (!tag.emptyGet()) {
    sRule rule;
    rule.mTag           = tag;
    rule.mAchievementKey = config.strGetWithDefault(tag + ".achievementKey", "");
    rule.mStat           = config.strGetWithDefault(tag + ".stat", "");
    mRules.push_back(rule);
    tag = config.strGetWithDefault("tag" + fsStr(++i), "");
}

That is exactly the direction this whole plan argues for: logic in Fission, facts in data. It is not a recent, half-finished experiment — it is in 23 of the 58 titles that carry this file, each with its own assets/collectionTypes.txt. But the shape of those 23 is the finding: all 18 parkRanger titles, from parkRanger1 through parkRanger18, plus 5 borrowed into barrierReef, cruiseDirector9, and three early christmas titles.

It has never crossed into christmas, cruise, halloween, easter or the packs as a rule. The other 35 titles — every franchise except parkRanger, minus the five borrowers — still carry the old version: hardcoded tag strings, and a block of string surgery to normalise mobile layout suffixes like "BSL-drummer-m1" back to a base name so every device variant increments the same counter.

The tell christmasPack2 is the single most recently touched file in the entire 59-title corpus — 25 August 2026. It has the old hardcoded version. The newest title in the catalogue was built by copying from a sibling in its own franchise, not from the better design sitting one franchise over, because nothing routes a fix across that boundary except someone happening to know it exists. This is the drift mechanism working exactly as diagnosed, caught in the act on the newest commit in the tree.

Two conclusions. First, the pattern in this plan is not a novel proposal — it is finishing something already proven at 23-of-58, for as long as the parkRanger line has existed. Second, per-title copying cannot fix this on its own even when the fix is known: it propagates within a franchise and stops dead at the boundary, because there is no shared home for a design to live in once. That is the argument for Fission over discipline.

Achievements: not bespoke, just under-tidied

reframed

CGameSpecificAchievementsComponent.cpp was the one file expected to hold genuine per-title design — the corpus's stated exception. Reading it changes that: across the full corpus, 15 of 23 distinct achievement keys appear in at least 80% of the 58 titles that carry this file — the vocabulary is the same generic set on every title, regardless of theme. Only 5 keys are rare, and most of those belong to one or two titles.

TitleThemeAchievement keys present
parkRanger18Park rangerornithologist, entomologist, conservationist, findFrosty…
cruiseDirector9Cruise shipornithologist, entomologist, conservationist, eagleLevels, eagleToy…
halloween4Halloweenornithologist, entomologist, conservationist… (no theme-specific keys)

A cruise game and a Halloween game both unlock "ornithologist". This is not per-SKU design — it is one boilerplate achievement set, copied with small additive edits: the Christmas pack added instantReplay, cruiseDirector9 added eagleLevels/eagleToy and dropped findFrosty. The underlying trigger logic (a save-count threshold, a message handler on collection-end) is identical across all of them.

This softens rather than removes the caution from the first pass. The file does not need to stay bespoke C++ per title. It needs the same treatment as CollectionListener: trigger logic into Fission, and the achievement key list — the one axis that actually varies — into the manifest as a small per-title array. What looked like the hardest file to unify turns out to fit the same pattern as everything else, once read closely rather than counted.

The decomposition

evidenced

Reading CGameSpecificMain.cpp across the corpus — 11 versions, but a chain, per What we measured — every difference falls into one of four buckets:

What variesBelongs inEvidence
Base core class Manifest → CMake gfCHogCoreMain in the HOG titles, gfCSimpleCoreMain in travel. A genre axis — and it explains travel's whole cluster, because it is not a HOG title.
Component registration list Fission parkRanger18 registers eighteen components; travel registers two. The difference tracks which cores are linked, not a per-title decision. See On registration.
Two numeric constants Manifest freeLevelCountSet(12) — trial length. cloudInit(2534010) — a Steam app ID, which already lives in buildSettings.yml as app_ID and is duplicated here.
Feature #if blocks Fission Identical everywhere: fs3d, fsFacebook, fsFlurry, fsGameServices, fsGumroad, fsCloud. Pure SDK boilerplate sitting in game code.

Leftovers worth deleting on the way through: a collision callback in travel whose body is auto n = pData.mHit->nameGet(); int t = 0; — debugging residue keyed on an entity named "saddle" — and several blocks of commented-out code carried in every copy.

On registration

why the obvious design does not work

A manifest listing component names, driving registration, cannot work — worth saying before someone attempts it.

fsCComponentFactory::componentRegister<T>() is a template. It keys a std::map on T::mTypeId and inserts a lambda calling T::create. C++ cannot instantiate a template from a runtime string, so a name in YAML can never reach a type.

Instead A component that is linked should simply be registered, and each core should register its own. The per-title lists were never decisions — they were omissions that happened not to matter, because a title only instantiates what its scene data references. Registration is a map insert; registering a component no scene uses costs nothing.

So gameSpecificComponentsRegister() does not become manifest-driven in the sense of a string-to-type lookup. For the shared components it disappears entirely — each core module registers its own behind the same feature flags that decided whether they were compiled at all. It does not fully disappear for a title with a genuine local module; see How local code plugs in for what remains and why.

How local code plugs in

two mechanisms, already proven

Declaring a module in the manifest does not by itself make a file compile. It is worth being precise about the actual plumbing, because hogProject.cmake treats the CGameSpecific* files less uniformly than the earlier sections implied.

# hogProject.cmake — unconditional, no EXISTS check
list(APPEND GAME_SOURCE
    "${FISSION_GAME_SRC_DIR_ABS}/CGameSpecificCollectionListener.cpp"
    "${FISSION_GAME_SRC_DIR_ABS}/CGameSpecificMain.cpp"
    "${FISSION_GAME_SRC_DIR_ABS}/CGameSpecificProfileSavedData.cpp"
)

# the other eight — genuinely optional, present if the file exists
set(optional_game_sources
    CAchievementEffectScreen.cpp  CGameSpecificAchievementsComponent.cpp
    CGameSpecificOptionsGui.cpp   CGameSpecificPaywallGui.cpp
    CCollectionScreen.cpp         CWildlifeScreen.cpp
    CInGameShopGui.cpp            CPhotoAlbum.cpp
)
foreach(source_file IN LISTS optional_game_sources)
    if(EXISTS "${FISSION_GAME_SRC_DIR_ABS}/${source_file}")
        list(APPEND GAME_SOURCE "${FISSION_GAME_SRC_DIR_ABS}/${source_file}")
    endif()
endforeach()

Three files — Main, CollectionListener, ProfileSavedData — are mandated by path, not by convention. Delete them from a title with nothing else changed and the build breaks. The other eight, CPhotoAlbum included, are picked up only if present, and silently skipped otherwise.

Genuinely local code needs nothing new

This case is already solved, proven today by barrierReef's photo feature, using two mechanisms at once:

via SDK listCPhotoAlbum matches an SDK optional name, so it is picked up automatically because it exists in barrierReef/src/. Nothing to configure. via title CMakeCPhotoCameraGui, CPhotoCaptureComponent, CPhotoFiles are not in the SDK's fixed list at all. barrierReef's own CMakeLists.txt hand-appends them to GAME_HEADER/GAME_SOURCE before include(hogProject) runs.

A one-off title writes files into src/ and either matches an existing optional name or gets a couple of append lines in its own CMakeLists.txt. The only change this plan makes here is who writes those append lines: generated by rustTools from the manifest's modules: block, not hand-typed — so the class itself stays exactly as hand-authored as it is today, and only the wiring is generated.

Registration follows the same shape. A module's class still has to call componentFactoryGet()->componentRegister<T>() somewhere, because C++ cannot discover it from a manifest string (On registration). The generator emits exactly one line per declared module — componentRegister<CPhotoAlbum>() for modules: { photo_album: true } — alongside whatever automatic Fission-core registration already happens. A title with zero modules gets zero extra lines.

What actually needs to change: the three required files

This is a correction to the phases below, not just a clarification. “No CGameSpecific* file outside Fission” is not achieved by deleting Main, CollectionListener and ProfileSavedData alone — hogProject.cmake would then fail to find files it unconditionally lists. Two ways to actually get there:

A — fuller fixFold the three required files into the same EXISTS-checked list as the other eight, and give gfCHogCoreMain a directly-instantiable default so App::create() does not need a title-provided CGameSpecificMain at all unless one genuinely exists. This is what “no file outside Fission” actually means, but it is an SDK-level change to hogProject.cmake and to Fission's app-bootstrap path, not a per-title one. B — interim fixLeave hogProject.cmake untouched. Have rustTools template the three required files into src/ from the manifest right before configure — never committed, never hand-edited, satisfying the existing hardcoded paths without any of it being a source of drift. Ships sooner, touches nothing at the SDK level.

Sequence B first — it is lower-risk and proves the pattern — and treat A as a follow-on once B is running in the catalogue. Phase 01's exit condition below is written against B; it should be revisited once A is scheduled.

The manifest

one file per title
# park-ranger-18/product.yml
#
# publisher, flavour, locale and version are deliberately NOT here. They
# are stamped in per build run — game.yml's publisherCode/flavour/
# projectBranch pipeline parameters, plus an auto-incremented build
# number — and baked into the app settings version string the running
# game reads to know who published it, under what flavour, in what
# language, as which build. That string already exists: it's the same
# encoding release/src/descriptor.rs decodes from a build's filename,
# {project}-{publisher}-{flavour}-{locale}-{platform}-{engine}-{versioncode}.
# The manifest describing a product and the pipeline stamping one build
# of it are different questions; putting the second set of facts here
# would make this file a second, driftable copy of what the pipeline
# already owns outright.
product:    park-ranger-18
caption:    Vacation Adventures: Park Ranger 18
core:       hog              # hog | simple | time | tilemap

features:                    # → FISSION_* CMake options
  spriter:           true
  render_to_texture: true
  3d:                false
  movies:            false

trial:
  free_level_count: 12       # catalogue default — was freeLevelCountSet(12) in
                              # all 59 titles; override only if a title needs to differ

modules:                     # optional per-title code, e.g. barrierReef
  photo_album: false

achievements:                 # the axis that actually varies — see Achievements
  - conservationist
  - ornithologist
  - entomologist
  - findFrosty                # this title's one theme-specific key
  # ...remaining shared catalogue set

collection_rules: assets/collectionTypes.txt   # read at runtime by Fission's
                                                 # CollectionListener — already in 23
                                                 # of 58 titles; see Precedent

markets:
  steam:  { app_id: 2534010 }   # catalogue default — was cloudInit(2534010) in
                                  # every title that had one; override only for a
                                  # title with its own Steam listing
  store:  { entitlement: store }
  itch:   { entitlement: none }

Nothing here is new information. All of it exists today, spread across CMakeLists.txt options, buildSettings.yml, game.yml parameters, the DynamoDB catalogue record, an asset file already in 23 titles, and constants buried in C++. The manifest's job is to hold each fact once — and per What we measured, most of these fields will carry the same catalogue default everywhere and only need a per-title value where the corpus actually shows one.

Escape hatch: a genuine one-off stays possible

by design

Collapsing the boilerplate does not collapse the range. Three things in this plan exist specifically to keep a wholly unique product on the table, not as a side effect of the design but as a stated requirement of it.

modulesmodules: is the real escape hatch. barrierReef's photo feature already proves it — genuine bespoke C++, gated behind a manifest flag, in the same pattern as FISSION_SPRITER and FISSION_3D. Collapsing the shared files does not stop a title from declaring a module and shipping code that exists nowhere else in the catalogue. corecore: is already an axis, not a fixed choice. travel proves it — gfCSimpleCoreMain instead of gfCHogCoreMain, a different genre entirely, sitting in the same corpus today. standaloneThe delivery plan's architecture is standalone-first, deliberately. A one-off can use a different engine, a different build target, and skip most of this manifest pattern, shipping through the same market axis as everything else.

The only thing that stays required is orthogonal to what a product is: if it links Fission, it builds against a current ref, per the CI rule in What we measured. A one-off with its own engine sits outside that check entirely, because it isn't linking Fission at all.

The one real risk modules could become a dumping ground if the bar for “genuine feature” softens — see Risks & stop conditions. That is a governance question, not an architectural one: someone has to own the call on what earns a module versus what is actually just another retrofit-lag copy in disguise.

Projections

core idea

Everything downstream is generated, so a fact can never disagree with itself:

ArtefactTodayAfter
CMake options Hand-edited per title Generated from features and core by the existing rustTools mutation step.
product.json Does not exist; launchers guess Emitted into the package by the release step, merging two sources: manifest facts (core, features, achievements, entitlement, the runtime field) plus the build's own injected identity — publisher, flavour, locale, version, build number — decoded from that run's build name. Neither source alone is the descriptor; the release step is where they meet.
Shop catalogue record Hand-seeded, flat paths, two buckets Refreshed by the release step from the manifest plus the versioned store/{publisher}/{project}/ prefix.
buildSettings.yml Per-market identity blocks Generated from markets, or kept and read by the manifest — either way, one owner.

This is what makes the descriptor cheap. It is not a new format to design and maintain; it is the manifest with the build's resolved facts folded in. And it is what makes the store producer-agnostic — a third-party product is one whose manifest we never saw, arriving with a descriptor we did not generate.

Phases

each one has an exit condition

If an exit condition cannot be met, stop and re-plan rather than proceeding.

StepPhaseWhat happens
01 Prove it on one title Take parkRanger18 — a mainstream HOG title on the largest cluster. Move the eight genuinely optional files into Fission behind the existing feature-flag pattern and delete them from the repo. For the three hogProject.cmake requires unconditionally — Main, CollectionListener, ProfileSavedData — template them into src/ from the manifest at build time instead (fix B in How local code plugs in); leave hogProject.cmake itself untouched for now. Put the two constants in the manifest. Change nothing else.

Exit parkRanger18 builds and runs identically with no hand-maintained src/CGameSpecific* file — the three required ones exist only as build output.

02 Reconcile the lineage Every one of the 59 titles is being brought current, so the reconciliation covers all of them, not a live subset. Split by file: CollectionListener needs no classification — the correct design is already known and proven at 23-of-58 (see Stalled precedent), so this is a mechanical rollout of an existing pattern to the other 35, not a decision. CGameSpecificMain.cpp (11 versions) and Achievements (12 versions) do need classification: for each of the 23 clusters, decide whether it is retrofit lag (take the newest) or genuine configuration (move to the manifest), and record the decision. This is the audit trail that justifies deleting the copies.

Exit every Main and Achievements difference classified, nothing left unsure; the CollectionListener rollout scoped as a plain propagation task.

03 Roll across the catalogue Apply to all 59 titles. Mechanical — and rustTools already mutates sources and manifests as a build step, so it can drive the deletion and the template-generation from How local code plugs in rather than doing it by hand. The 6 titles with the API-rename break (see The six broken titles) get the rename as part of this pass; their separate billing/ads feature question does not need to block it.

Exit no hand-maintained CGameSpecific* file in any title, except barrierReef's photo module; the three SDK-required files exist only as generated build output everywhere.

04 Emit the descriptor Add product.json generation to the release packaging step. Point both launchers at it and delete their executable-hunting heuristics — find_app_bundle in the Tauri client, detect_launch_target in the Rust one.

Exit a fresh install launches with no directory scanning anywhere in the path.

05 Close the shop gap Fix the shop. / store. bucket inconsistency, have the shop resolve the versioned prefix the release pipeline already writes, and have the release step refresh the catalogue record from the manifest.

Exit publishing a build makes it downloadable with no manual DynamoDB editing.

06 Enforce currency in CI With the game-specific files gone, “is this title current?” becomes answerable: it is the SDK ref it built against. Assert it in the manifest schema and fail builds that lag.

Exit a deliberately stale manifest fails the build.

The six broken titles

product question, not just technical

Six titles are written against an SDK that no longer exists: christmasPuzzler2, easter1, easter2, halloween2, halloween3, and halloween4. Under the earlier “live vs archived” framing only halloween4 mattered; with every repo being brought current and republished, all six are in scope and share the same two problems.

What they referenceStatus in current Fission
plugins/fsCommerce/fsIBilling.hGone. No equivalent anywhere in the SDK.
plugins/fsAdvertising/src/fsCAdManagerDisplay.hGone. No equivalent anywhere in the SDK.
plugins/fsFacebook/fsIFacebook.hMoved to libs/fsPlatform/social/.
fsCommerce/fsIFlurryManager.hMoved to libs/fsPlatform/analytics/.
firstEntityWith<gfIHogLevel>()Renamed firstEntityWithComponentsOfFamilyType. Unguarded in all six — this is the actual compile break.
fsInAppPurchase / fsInAppPurchaseProcessDefine names have diverged between the two.

The two problems are independent. The rename is a five-minute mechanical fix and needs no design input — it is folded into the general roll-out in phase 03. The missing headers do not.

Product question The two missing headers are billing and AdMob advertising. Those subsystems have been removed from Fission entirely, so if these six titles shipped with in-app purchases or ads, that functionality no longer has an implementation to port to. Someone needs to decide what each of these six titles is now — ships without that feature, or gets a new implementation — before anyone estimates the work. That decision is the same size whether it is asked once for a batch of six or once per title; asking it once, now, is cheaper.

Sequence the billing/ads decision after the general catalogue roll-out, so it lands on a settled target rather than a moving one. It does not block the roll-out itself.

Risks & stop conditions

watch these
highestA “lag” difference turns out to be deliberate SKU behaviour. The most likely real failure. Mitigation is phase 02's audit trail: classify every difference explicitly, and treat anything unsure as configuration rather than assuming it away. A wrong call here ships a behaviour change to a live title. buildRegister-everything trips the double-registration assert. componentRegister asserts on a duplicate type id, so moving registration into cores may surface a component registered twice. Cheap to find — it fires on startup in non-release builds — but it will fire during phase 01. driftThe photo module sets a precedent that gets abused. barrierReef's feature is genuine per-title code and needs somewhere to live. The risk is that “optional module” becomes the new dumping ground and the extension-point story repeats. Keep the bar explicit: a module needs a real feature behind it, not a tweak. scopeCreep into the engine backend decision. This plan is deliberately independent of Axmol versus SDL3. Nothing here should wait on that, and nothing here should pre-empt it.

Deliberately not doing

scope boundaries
noNot building a name-driven component registry. It cannot work through a template, and register-everything makes it unnecessary. noNot moving the extension points to script. The pure data-driven answer is a programme, not a step, and the measurements say it would buy very little — there is barely any per-title logic left to move. noNot deciding the shared runtime here. This work makes it feasible by driving per-title C++ toward zero, but it stays a first-party packaging option to adopt later. noNot retiring a launcher here. Both clients get pointed at the descriptor. Which one survives is a separate decision, and the descriptor makes it cheaper either way.