t1k:cocos:playable:parameter-audit
| Field | Value |
|---|---|
| Module | playable |
| Version | 2.14.4 |
| Effort | medium |
| 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
How to invoke
Section titled “How to invoke”/t1k:cocos:playable:parameter-audit[--config <PlayableConfig.ts>] [--controller <ParameterController.ts>] [--scene <MainScene.scene>] [--coverage <manifest.json>] [--no-fail] [--json]Cocos Playable Parameter Audit
Section titled “Cocos Playable Parameter Audit”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.
Executed by
Section titled “Executed by”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”):
| Step | Agent |
|---|---|
Run audit-parameters.cjs (Pass A+B) and classify each PlayableConfig key: WIRED-OK / UNWIRED / NO-OP / INTENTIONAL-EXTERNAL per the false-positive-trap rules | t1k-list-classifier |
Pass C: read the scene/MCP, build the prefab-override set, classify each @property ref WIRED-OK / NULL-REF / PARTIAL-REF | t1k-list-classifier |
Pass D: trace each logic param’s sink and classify effect=live / logic-dead per the single-hop rule | t1k-list-classifier |
Pass E: run the label-collision one-liner and classify each label AMBIGUOUS-LABEL / UNQUALIFIED-LABEL / clean | t1k-list-classifier |
--coverage: classify each manifest key READY / MISSING / DEAD / PARTIAL against the same statuses | t1k-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.
What “dead” means — three classes
Section titled “What “dead” means — three classes”| Status | Meaning | Detection |
|---|---|---|
| UNWIRED | In PlayableConfig, but no onUpdate/binder/auto-loop touches it. | Static (script, deterministic) |
| NO-OP | Wired with an effectively-empty onUpdate body ({}, whitespace, or only-comments — incl. commented-out bindings) AND read nowhere else. | Static (script, deterministic) |
| LOGIC-DEAD | Wired 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-REF | Wired through a scene @property ref that was never dragged (null) and not resolved via a prefab targetOverride. | Scene JSON / MCP (skill-driven) |
| PARTIAL-REF | Array @property (e.g. Sprite[]) shorter than the table’s default length. | Scene JSON / MCP (skill-driven) |
INTENTIONAL-EXTERNAL | Empty/no controller wiring, but the value IS read elsewhere (SDK logic). Not a defect. | Static (whole-codebase grep) |
WIRED-OK | A real apply path exists (and, for logic params, its sink writes a field something reads). | Static |
Decision Tree
Section titled “Decision Tree”| Intent | Path |
|---|---|
| ”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 |
Workflow
Section titled “Workflow”Pass A + B — static wiring & no-op (deterministic)
Section titled “Pass A + B — static wiring & no-op (deterministic)”node scripts/audit-parameters.cjs # auto-discovers PlayableConfig.ts + ParameterController.ts under cwdnode scripts/audit-parameters.cjs --json # machine outputThe 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 C — scene null-ref
Section titled “Pass C — scene null-ref”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:
- Parse
assets/scene/MainScene.scene(JSON). Find theParameterControllercomponent object; note its array indexCand read each@propertyfield. - Build the prefab-override set FIRST (mandatory — skipping it false-positives
every prefab-internal ref): collect each
propertyPath[0]from everycc.TargetOverrideInfowhosesource.__id__ === C. Field names in this set are wired at load time via the node’scc.PrefabInfo, even though their inline value isnull— they are WIRED-OK, never NULL-REF. - A field whose inline value is
nullAND 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. - Array fields (
galleryImages: Sprite[]) shorter than the bound table’sdefaultlength (and no override) → PARTIAL-REF (tail elements apply to nothing). - 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 on3000does not mean the editor is closed — usemanage_componentto 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.
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);}}'| Status | Meaning |
|---|---|
| AMBIGUOUS-LABEL | Two children of one parent share a label. Always a defect. |
| UNQUALIFIED-LABEL | A 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:
- Auto-wire loops.
setupAudioParamUpdates()doesObject.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→ typeaudio), NOT by category — soEnableBGM(aBooleanParameterin 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. - Binder fluent chains.
this.binder.button(PlayableConfig.CTA, () => this.ctaButton)wiresCTA. The script matches any(PlayableConfig.Xargument position (via=binder), so it survives binder-method renames. - Bracket-table loops.
const groups = [[PlayableConfig.X, "Metal_Cube"], ...]followed byfor (const [param, kind] of groups) { param.onUpdate = ... }wiresXthrough 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 onPlayableConfig.X. The script traces BOTH halves (tuple entry + destructure-wired loop) and reportsvia=table-loop. A table with no wiring loop is NOT wired — never flag a table-driven param dead without checking for the loop. - Nested params.
GalleryGroupcontains a nestedimagestable applied through the parent’sonUpdate. Nested params are not separate top-level entries and must not be audited as unwired — the script counts only top-levelPlayableConfigkeys. - Empty
onUpdatemay 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. - SDK-managed params. Store-link / redirect params are intentionally not visually wired. Confirm via external reads before declaring dead.
- Prefab targetOverrides (Pass C). An
@propertywhose target lives inside a prefab instance serializes as inlinenullon the controller; the real binding is acc.TargetOverrideInfoin the node’scc.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.
Output classification
Section titled “Output classification”The script prints a per-param table and a DEAD PARAMETERS summary
(UNWIRED + NO-OP). For each dead param, recommend the fix:
| Status | Fix |
|---|---|
| UNWIRED | Add a binder.*(PlayableConfig.X, () => this.ref) or PlayableConfig.X.onUpdate = ... in ParameterController.SetUpOnUpdate() — OR delete the param if obsolete. |
| NO-OP | Implement the empty onUpdate body (incl. un-commenting a commented-out binding), or delete the param. |
| LOGIC-DEAD | Wire 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-REF | Drag the target node/component onto the ParameterController’s @property slot in the editor (or wire it via prefab targetOverride). |
| PARTIAL-REF | Add 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
Section titled “References”references/detection-algorithm.md— full per-pass algorithm, scene-JSON ref schema, mapping rules, and the exact classification decision tree.references/usage-examples.md—--coverageCLI examples + requirement-manifest JSON schema.
Gotchas
Section titled “Gotchas”- 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
@propertyref was dragged; a param can beWIRED-OKin 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 inlinenulland are wired viacc.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 andObject.entries(PlayableConfig)loops — works for both flat-primitive andObjectParameter<XxxComponentParameter>projects. - Spread-composed configs are resolved. The Phase-1 UI/data split ships
export const PlayableConfig = { ...DataConfig, ...UIConfig }.parseConfigKeysfollows each...Identspread into itsconst 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 atotal=0audit 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-
.pkgbuildsource copies; or pass--config/--controllerexplicitly. - 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. @propertydecorators are paren-balanced, string-aware. A tooltip containing)(e.g.'Left button (pos + scale).') no longer truncates the property scan — every@propertyfield reachescontrollerPropertiesfor 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.
--coveragemaps each requirement to wholePlayableConfigkeys (expectedKeys) — it never descends into anObjectParameter’s sub-fields, so a requirement satisfied by a sub-field (e.g. Market Options’ 6 toggles in oneMarketOptionsobject) verifies only as “key exists + live”, not per-toggle (split into multiple top-level keys if you need per-sub-feature coverage). AndexpectedKeysis 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.