t1k:cocos:playable:parameter-audit
| Field | Value |
|---|---|
| Module | playable |
| Version | 0.15.0 |
| 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.
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 (
http://127.0.0.1:3000/mcp), 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.
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. - 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.
node scripts/audit-parameters.cjs --coverage requirement-manifest.json # human report + gatenode scripts/audit-parameters.cjs --coverage requirement-manifest.json --json # machine outputnode scripts/audit-parameters.cjs --coverage requirement-manifest.json --no-fail # report only, exit 0Manifest 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-levelPlayableConfigkey(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
Section titled “References”references/detection-algorithm.md— full per-pass algorithm, scene-JSON ref schema, mapping rules, and the exact classification decision tree.
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.