The shape of it
planThis 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.
What we measured
full corpus · 59 titles, all in scopeThis 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.
The strongest evidence: it's already been done once
confined to one franchiseCGameSpecificCollectionListener.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.
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
reframedCGameSpecificAchievementsComponent.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.
| Title | Theme | Achievement keys present |
|---|---|---|
| parkRanger18 | Park ranger | ornithologist, entomologist, conservationist, findFrosty… |
| cruiseDirector9 | Cruise ship | ornithologist, entomologist, conservationist, eagleLevels, eagleToy… |
| halloween4 | Halloween | ornithologist, 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
evidencedReading CGameSpecificMain.cpp across the corpus — 11 versions, but a chain, per What we measured — every difference falls into one of four buckets:
| What varies | Belongs in | Evidence |
|---|---|---|
| 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 workA 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.
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 provenDeclaring 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:
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:
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 designCollapsing 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.
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.
Projections
core ideaEverything downstream is generated, so a fact can never disagree with itself:
| Artefact | Today | After |
|---|---|---|
| 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 conditionIf an exit condition cannot be met, stop and re-plan rather than proceeding.
| Step | Phase | What 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 technicalSix 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 reference | Status in current Fission |
|---|---|
| plugins/fsCommerce/fsIBilling.h | Gone. No equivalent anywhere in the SDK. |
| plugins/fsAdvertising/src/fsCAdManagerDisplay.h | Gone. No equivalent anywhere in the SDK. |
| plugins/fsFacebook/fsIFacebook.h | Moved to libs/fsPlatform/social/. |
| fsCommerce/fsIFlurryManager.h | Moved to libs/fsPlatform/analytics/. |
| firstEntityWith<gfIHogLevel>() | Renamed firstEntityWithComponentsOfFamilyType. Unguarded in all six — this is the actual compile break. |
| fsInAppPurchase / fsInAppPurchaseProcess | Define 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.
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.