Skip to content

t1k:cocos:playable:parameter-audit

FieldValue
Moduleplayable
Version2.14.4
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.

Every pass here applies an already-stated rubric to a fixed item list — the canonical t1k-list-classifier shape (routing SSOT: rules/orchestration-rules.md § “Task-Type → Agent Routing”):

StepAgent
Run audit-parameters.cjs (Pass A+B) and classify each PlayableConfig key: WIRED-OK / UNWIRED / NO-OP / INTENTIONAL-EXTERNAL per the false-positive-trap rulest1k-list-classifier
Pass C: read the scene/MCP, build the prefab-override set, classify each @property ref WIRED-OK / NULL-REF / PARTIAL-REFt1k-list-classifier
Pass D: trace each logic param’s sink and classify effect=live / logic-dead per the single-hop rulet1k-list-classifier
Pass E: run the label-collision one-liner and classify each label AMBIGUOUS-LABEL / UNQUALIFIED-LABEL / cleant1k-list-classifier
--coverage: classify each manifest key READY / MISSING / DEAD / PARTIAL against the same statusest1k-list-classifier
Confirm a flagged LOGIC-DEAD sink before recommending deletion (the pass is explicitly “single-hop… confirm… before deleting”)t1k-debugger — reasoning tier, not cheap
Decide what to DO about a dead/drifted param — wire it, delete it, or leave it (the fix-table names the option, not the choice)stays with the orchestrating skill/user

This skill never edits config, controller, or scene (Gotchas: “Report-only”), so classification-only tooling is a natural fit — t1k-list-classifier carries no Edit/Write tool either. The one heuristic pass (Pass D, “single-hop, conservative”) is still cheap-tier for the classification itself; only the pre-deletion confirmation it explicitly calls for needs a reasoning tier.

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 — resolve the endpoint per t1k-cocos-playable-parameter-mcp/references/mcp-endpoint.md; the port is per project, so a refused probe on 3000 does not mean the editor is closed — 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.logoSprite ⇒ Logo) and onUpdate bodies. The script emits the property list; the skill body does this semantic mapping.

Pass E — ambiguous labels (deterministic, runs off the generated config)

Section titled “Pass E — ambiguous labels (deterministic, runs off the generated config)”

A param can be perfectly wired and still be unusable: if two children of one ObjectParameter render the same label, the operator cannot tell which row does what. This pass reads the GENERATED playable-config.json (not the TypeScript) so it audits exactly what the dashboard will show.

Terminal window
node -e 'const p=require("./assets/ParameterToolBuild/playable-config.json").parameters;
for(const[k,v]of Object.entries(p)){if(!v.parameters)continue;const s=new Map();
for(const[ck,cv]of Object.entries(v.parameters)){const l=cv.label;
if(s.has(l))console.log(`AMBIGUOUS-LABEL ${k}: "${l}" <- ${s.get(l)} AND ${ck}`);else s.set(l,ck);}}'
StatusMeaning
AMBIGUOUS-LABELTwo children of one parent share a label. Always a defect.
UNQUALIFIED-LABELA child label omits the parent or component token ("Color", "Type"). Not yet a collision, but becomes one the moment a second component lands on the node — and already unreadable in flat dashboard search results.

Fix by relabelling per label-convention.md — "{ParentLabel} — {Component} {Field}". Labels are cosmetic, so a relabel needs no rename map; regenerate BOTH profiles afterward. Report-only: never rename a KEY to resolve a label collision, that is a breaking change.

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. Bracket-table loops. const groups = [[PlayableConfig.X, "Metal_Cube"], ...] followed by for (const [param, kind] of groups) { param.onUpdate = ... } wires X through the destructured loop variable — the key sits in bracket element position (the binder recognizer is paren-preceded) and the onUpdate lands on the loop var, not on PlayableConfig.X. The script traces BOTH halves (tuple entry + destructure-wired loop) and reports via=table-loop. A table with no wiring loop is NOT wired — never flag a table-driven param dead without checking for the loop.
  4. 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.
  5. 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.
  6. SDK-managed params. Store-link / redirect params are intentionally not visually wired. Confirm via external reads before declaring dead.
  7. 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. expectedKeys in the manifest double as the naming contract: the scan/implement skills MUST emit exactly those top-level PlayableConfig keys or coverage reports MISSING. Full CLI examples, the manifest JSON schema, and the READY/MISSING/DEAD/PARTIAL value definitions: references/usage-examples.md.

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.
  • references/usage-examples.md — --coverage CLI examples + requirement-manifest JSON schema.
  • 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.