Skip to content

t1k:cocos:playable:parameter-audit

FieldValue
Moduleplayable
Version0.15.0
Effortmedium
Tools

Keywords: audit, coverage, dead-parameter, no-op, null-ref, orphan-param, parameter-audit, parameter-validation, playable-config, requirement-coverage, requirement-gap, requirement-manifest, undragged, unused-parameter, unwired

/t1k:cocos:playable:parameter-audit
[--config <PlayableConfig.ts>] [--controller <ParameterController.ts>] [--scene <MainScene.scene>] [--coverage <manifest.json>] [--no-fail] [--json]

Reverse-validation for the flat-primitive parameter system. The forward skills (scan → composite → implement → mcp) create parameters; this skill finds parameters that do nothing — defined but with no path from a dashboard value change to any gameplay or visual effect.

This is the inverse of t1k-cocos-playable-parameter-implement, whose gotcha warns “a scanned node not wired in the controller will silently never apply” — but provides no detector. This skill is that detector.

StatusMeaningDetection
UNWIREDIn PlayableConfig, but no onUpdate/binder/auto-loop touches it.Static (script, deterministic)
NO-OPWired with an effectively-empty onUpdate body ({}, whitespace, or only-comments — incl. commented-out bindings) AND read nowhere else.Static (script, deterministic)
LOGIC-DEADWired with a non-empty body, but its only sink writes a flag/counter/config field that nothing reads (effect-reachability dead).Static (script, Pass D — single-hop heuristic)
NULL-REFWired through a scene @property ref that was never dragged (null) and not resolved via a prefab targetOverride.Scene JSON / MCP (skill-driven)
PARTIAL-REFArray @property (e.g. Sprite[]) shorter than the table’s default length.Scene JSON / MCP (skill-driven)
INTENTIONAL-EXTERNALEmpty/no controller wiring, but the value IS read elsewhere (SDK logic). Not a defect.Static (whole-codebase grep)
WIRED-OKA real apply path exists (and, for logic params, its sink writes a field something reads).Static
IntentPath
”Find parameters that don’t do anything”Run all 3 passes → Workflow
”Just the static wiring/no-op audit”Pass A+B only (scripts/audit-parameters.cjs)
“Check if scene refs were dragged”Pass C → Scene null-ref pass
”Are all REQUIRED params present & live?” (gap gate)--coverage <manifest>Forward-validation
”CI-friendly machine output”Add --json

Pass A + B — static wiring & no-op (deterministic)

Section titled “Pass A + B — static wiring & no-op (deterministic)”
Terminal window
node scripts/audit-parameters.cjs # auto-discovers PlayableConfig.ts + ParameterController.ts under cwd
node scripts/audit-parameters.cjs --json # machine output

The script classifies every PlayableConfig key as WIRED-OK / UNWIRED / NO-OP / LOGIC-DEAD / INTENTIONAL-EXTERNAL. It accounts for all three wiring mechanisms (see traps below) so audio/auto-wired params are not false-flagged.

Pass D — effect-reachability (logic params, deterministic)

Section titled “Pass D — effect-reachability (logic params, deterministic)”

A non-empty onUpdate proves a call happens, not that the call has effect. A “logic” param (no scene ref) copies its value into a bridge/store setter or a service; the game only changes if a reader consumes what was written. Pass D traces onUpdate body → sink → field written → is that field read anywhere? and flags LOGIC-DEAD when a param’s only sink writes a flag/counter/field that nothing reads. Service sinks (AudioService.*, SignalBus.fire, *View refresh) are treated as inherently effectful; scene-ref sinks defer to Pass C. The effect=live|logic-dead|unknown(via) fact is emitted per param. It’s a single-hop heuristic — confirm the named sink before deleting a LOGIC-DEAD param.

Pass A+B prove a param has an apply path in code. Pass C proves the scene @property it applies through is actually wired in the editor. The script prints controllerProperties: [...]. For each, read the scene and check the value is non-null:

  1. Parse assets/scene/MainScene.scene (JSON). Find the ParameterController component object; note its array index C and read each @property field.
  2. Build the prefab-override set FIRST (mandatory — skipping it false-positives every prefab-internal ref): collect each propertyPath[0] from every cc.TargetOverrideInfo whose source.__id__ === C. Field names in this set are wired at load time via the node’s cc.PrefabInfo, even though their inline value is null — they are WIRED-OK, never NULL-REF.
  3. A field whose inline value is null AND is not in the override set → the ref was never dragged → every param applied through it is NULL-REF (dead). An inline { "__id__": ... } pointing at a missing node is also NULL-REF.
  4. Array fields (galleryImages: Sprite[]) shorter than the bound table’s default length (and no override) → PARTIAL-REF (tail elements apply to nothing).
  5. Alternatively, with the Cocos MCP running (http://127.0.0.1:3000/mcp), use manage_component to read the live, post-resolution component values instead of the file — this sidesteps the targetOverride math entirely (the MCP returns the already-resolved refs).

Map each @property back to its param via the controller’s binder closures (() => this.logoSpriteLogo) and onUpdate bodies. The script emits the property list; the skill body does this semantic mapping.

False-positive traps (READ BEFORE flagging anything dead)

Section titled “False-positive traps (READ BEFORE flagging anything dead)”

A naive grep PlayableConfig.X audit is wrong. The detector must honor:

  1. Auto-wire loops. setupAudioParamUpdates() does Object.entries(PlayableConfig).forEach(... if (param.type === 'audio') ... onUpdate = ...). Audio params are wired by the loop, not by name — flagging them UNWIRED is a false positive. The script detects these loops and their predicate — keyed on category (category === 'Audio') or type (param.type === 'audio'). A type-keyed loop matches by the param’s constructor (new AudioParameter → type audio), NOT by category — so EnableBGM (a BooleanParameter in category Audio) is correctly NOT swept in by the audio loop. A loop the parser reads as “no predicate” would wire all params and mask every real dead param — so the predicate parse must cover both forms.
  2. Binder fluent chains. this.binder.button(PlayableConfig.CTA, () => this.ctaButton) wires CTA. The script matches any (PlayableConfig.X argument position (via=binder), so it survives binder-method renames.
  3. Nested params. GalleryGroup contains a nested images table applied through the parent’s onUpdate. Nested params are not separate top-level entries and must not be audited as unwired — the script counts only top-level PlayableConfig keys.
  4. Empty onUpdate may be intentional. RedirectAfterClicks.onUpdate = () => {} is empty by design — its value is read by SDK redirect logic elsewhere. The script greps the whole codebase; empty-onUpdate + external reads ⇒ INTENTIONAL-EXTERNAL, not NO-OP. Only empty-onUpdate AND zero external reads ⇒ NO-OP.
  5. SDK-managed params. Store-link / redirect params are intentionally not visually wired. Confirm via external reads before declaring dead.
  6. Prefab targetOverrides (Pass C). An @property whose target lives inside a prefab instance serializes as inline null on the controller; the real binding is a cc.TargetOverrideInfo in the node’s cc.PrefabInfo.targetOverrides (source.__id__ = controller component, propertyPath = field name). Reading only the inline value flags every such field NULL-REF — a guaranteed false positive. Resolve the override set before declaring any field null.

The script prints a per-param table and a DEAD PARAMETERS summary (UNWIRED + NO-OP). For each dead param, recommend the fix:

StatusFix
UNWIREDAdd a binder.*(PlayableConfig.X, () => this.ref) or PlayableConfig.X.onUpdate = ... in ParameterController.SetUpOnUpdate() — OR delete the param if obsolete.
NO-OPImplement the empty onUpdate body (incl. un-commenting a commented-out binding), or delete the param.
LOGIC-DEADWire a reader for the flag/counter the sink writes (the value is stored but never consumed), or delete the param. Confirm the named sink first.
NULL-REFDrag the target node/component onto the ParameterController’s @property slot in the editor (or wire it via prefab targetOverride).
PARTIAL-REFAdd the missing array elements to the @property list.

Forward-validation — requirement coverage (--coverage <manifest>)

Section titled “Forward-validation — requirement coverage (--coverage <manifest>)”

The passes above answer “are all PRESENT params live?” (reverse). Coverage answers the inverse“is every REQUIRED param present AND live?” — so a client’s feature list becomes an automated gap gate instead of a hand-maintained matrix. It reuses the exact same classify() statuses, so the two directions never disagree.

Terminal window
node scripts/audit-parameters.cjs --coverage requirement-manifest.json # human report + gate
node scripts/audit-parameters.cjs --coverage requirement-manifest.json --json # machine output
node scripts/audit-parameters.cjs --coverage requirement-manifest.json --no-fail # report only, exit 0

Manifest schema (SSOT, authored from the client requirement doc — requirements[].items[]):

{
"requirements": [
{ "id": 9, "feature": "CTA Button & App Icon", "phase": 2,
"items": [
{ "name": "CTA button", "required": true, "expectedKeys": ["CtaButton"], "match": "all" },
{ "name": "App icon", "required": true, "expectedKeys": ["AppIcon"] }
] }
]
}
  • expectedKeys = canonical top-level PlayableConfig key(s). The audit only sees top-level keys, so map to whole keys, not sub-fields. These names double as the naming contract: the scan/implement skills MUST emit exactly these keys or coverage reports MISSING (contract-first).
  • match: "all" (default) = every key present + live for READY · "any" = ≥1 live.
  • required: true (default) counts toward the gate; false = optional, reported, never fails.

Coverage values (per item): READY (present + WIRED-OK/INTENTIONAL-EXTERNAL) · MISSING (no expected key in config) · DEAD (present but UNWIRED/NO-OP/LOGIC-DEAD) · PARTIAL (some expected keys live but short of match).

Gate: exit 1 when any required item is not READY (suppress with --no-fail) — CI-friendly.

NULL-REF caveat: coverage is static, so a READY key can still be NULL-REF (undragged scene @property). Run Pass C (scene/MCP) on the READY set to confirm editor refs before declaring done.

  • references/detection-algorithm.md — full per-pass algorithm, scene-JSON ref schema, mapping rules, and the exact classification decision tree.
  • Report-only. This skill never edits config/controller/scene — it diagnoses. Apply fixes via t1k-cocos-playable-parameter-implement (code) or the editor (refs).
  • dead=0 is a pass, not a no-op run. The real template audits clean (14/14 wired) — that proves the tool doesn’t false-positive, not that it did nothing.
  • validateConfig (submodule) is NOT this. It only checks value sanity (range/select/hex). It does zero wiring/usage analysis — that is this skill’s gap.
  • Pass C needs the scene or MCP. Static passes (A+B) cannot see whether an editor @property ref was dragged; a param can be WIRED-OK in code yet dead because its ref is null. Always run Pass C before declaring a wired param healthy.
  • Inline null ≠ NULL-REF when a prefab override exists. If the controller node is/contains a prefab instance, refs to prefab-internal targets serialize as inline null and are wired via cc.TargetOverrideInfo (source.__id__ = controller, propertyPath = field). Always build the override set first (Pass C step 2); reading inline values alone false-positives every prefab-cross-referenced field. Same-scene refs (e.g. mainCamera) wire inline and need no override — that asymmetry is why “3 healthy / 9 dead” splits are usually a missed-override bug.
  • Composite/wrapper projects too. Detection keys on PlayableConfig.<Key> references and Object.entries(PlayableConfig) loops — works for both flat-primitive and ObjectParameter<XxxComponentParameter> projects.
  • Spread-composed configs are resolved. The Phase-1 UI/data split ships export const PlayableConfig = { ...DataConfig, ...UIConfig }. parseConfigKeys follows each ...Ident spread into its const Ident = {…} and inlines those keys (BFS, cycle-guarded, depth 50). Without this the config reads as ZERO params and every real param is a false MISSING/UNWIRED — so a total=0 audit on a spread-composed config is the symptom of an unresolved spread, not a clean project.
  • Run from a path under the project so auto-discovery finds the non-.pkgbuild source copies; or pass --config/--controller explicitly.
  • Empty body is detected by full-body parse, not a char window. A commented-out binding (onUpdate = (data) => { /* Bridge.setX(...) */ }) or a long-signature empty body reads as NO-OP — the body is parsed and comment-stripped, so the effective-empty case is caught regardless of param-signature length.
  • @property decorators are paren-balanced, string-aware. A tooltip containing ) (e.g. 'Left button (pos + scale).') no longer truncates the property scan — every @property field reaches controllerProperties for the Pass C scene map.
  • LOGIC-DEAD is single-hop. It confirms the sink’s immediate field has no reader; it does not chase a getter that exists but is itself never called (conservative — prefers a missed dead over a false dead). Treat it as a high-confidence lead to confirm, not an auto-delete.
  • Coverage granularity is top-level keys only, and manifest-honest. --coverage maps each requirement to whole PlayableConfig keys (expectedKeys) — it never descends into an ObjectParameter’s sub-fields, so a requirement satisfied by a sub-field (e.g. Market Options’ 6 toggles in one MarketOptions object) verifies only as “key exists + live”, not per-toggle (split into multiple top-level keys if you need per-sub-feature coverage). And expectedKeys is the naming contract: a param emitted under a different key than the manifest expects reads MISSING (correctly — the contract broke), not READY. Keep manifest + config keys in lockstep.