feat(skills): ship ue-design-skills bundle, licensing and delivery gate
Phase 0 of the handoff plan, as a marketplace rather than a flat skills/ directory. Content moved out of the LyraResearch archive and depersonalised: addresses stay in the archive, recipes ship. - plugins/ue-design-skills: 17 skills, 232 failure-mode entries, each with the six required fields; catalog.json as the harness-neutral source of truth and .claude-plugin/ as one adapter over it. - _gate: 16 rules, one poisoned fixture per rule, plus surface coverage so a declared file cannot silently miss the line rules. - ADR-0002 (harness-neutral bundle behind a marketplace) and ADR-0003 (split licensing: CC BY-ND 4.0 prose, Apache-2.0 code and metadata). - LICENSE files at both levels, CONTRIBUTING.md, docs/licensing-options.md as the material the licence decision grew from. Verified: gate.py 0 violations; test_gate.py 16/16 rules redden on their fixtures with a clean baseline and 2 root files reaching the line rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
---
|
||||
name: ue-cosmetics-and-teams
|
||||
description: >-
|
||||
Design or review modular character cosmetics and team systems in Unreal
|
||||
Engine: replicated cosmetic intent with local realization, controller-owned
|
||||
selection versus pawn-owned realization, fast-array entries, dedicated-server
|
||||
exclusion, tag-driven mesh and animation selection, one authoritative team
|
||||
identifier with subscribed mirrors, team relationship and damage policy, and
|
||||
data-driven material presentation. Use when building skins, modular
|
||||
characters, team assignment, friendly fire, hit-marker filtering or
|
||||
team-coloured UI and effects.
|
||||
---
|
||||
|
||||
# UE cosmetics and teams
|
||||
|
||||
Two invariants, one per half:
|
||||
|
||||
> Replicate cosmetic **intent**, never the spawned presentation.
|
||||
|
||||
> Store team membership **once**, authoritatively; every other copy is a mirror
|
||||
> with a subscription.
|
||||
|
||||
Measured patterns from a reference product: [patterns](references/patterns.md).
|
||||
Detection recipes: [failure modes](references/failure-modes.md).
|
||||
|
||||
These two systems are in one skill because they meet at exactly one point —
|
||||
presentation — and the most common design error is letting them meet anywhere
|
||||
else.
|
||||
|
||||
Related skills: `ue-data-driven-architecture`, `ue-gas-architecture`,
|
||||
`ue-gameplay-messaging`, `ue-modular-gameplay`, `ue-multiplayer-authority`.
|
||||
|
||||
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
|
||||
|
||||
---
|
||||
|
||||
# Part I — Modular cosmetics
|
||||
|
||||
## 1. Split persistent intent from pawn realization
|
||||
|
||||
A controller-level component owns the selection, so it survives pawn death and
|
||||
respawn. A pawn-level component realizes it on the current body.
|
||||
|
||||
```text
|
||||
controller component pawn component
|
||||
├─ desired part list ├─ replicated part list
|
||||
├─ listens for pawn changes ├─ spawns local presentation components
|
||||
└─ applies and removes on the ├─ computes cosmetic tags
|
||||
current pawn, keeping └─ notifies body and animation selection
|
||||
handles
|
||||
```
|
||||
|
||||
If the only record of a player's appearance lives on the pawn, it dies with the
|
||||
pawn. That is the whole reason for the split.
|
||||
|
||||
### Possession transfer
|
||||
|
||||
On a possession change, in this order: remove owned parts from the old pawn;
|
||||
find the component on the new pawn; add owned parts; retain the returned handles.
|
||||
**Each owner removes only its own parts** — cheats, developer settings and real
|
||||
content coexist in one list, distinguished by a source field rather than by
|
||||
separate lists.
|
||||
|
||||
---
|
||||
|
||||
## 2. Define a part as data, and make it incapable of gameplay
|
||||
|
||||
A minimal part: a class to spawn, a socket to attach to, and a collision mode
|
||||
that defaults to none.
|
||||
|
||||
Default presentation safety: collision off unless explicitly requested; no
|
||||
ability system, attributes or authority inside the cosmetic actor; no replicated
|
||||
gameplay state; validated attach target; locally tracked handle.
|
||||
|
||||
### The guarantee is the net-mode guard, not the convention
|
||||
|
||||
Nothing in the type system prevents putting a gameplay-capable actor into a part
|
||||
class. The real isolation comes from one line: **presentation is not spawned at
|
||||
all on a dedicated server.** The server therefore cannot trace against, collide
|
||||
with, or be influenced by cosmetics, because they do not exist there.
|
||||
|
||||
Be precise about the limit of that guarantee: on a listen server or in
|
||||
standalone, the parts *do* exist on the authority. Recipe: CT-01.
|
||||
|
||||
---
|
||||
|
||||
## 3. Replicate intent with a fast array
|
||||
|
||||
The replicated entry holds the intent — class and socket. It must not hold the
|
||||
local handle or the spawned component. Put both of those in the same struct as
|
||||
**non-replicated** fields and let the annotation express the split, rather than
|
||||
maintaining two parallel types.
|
||||
|
||||
Callbacks realize changes locally: post-add spawns, pre-remove destroys,
|
||||
post-change rebuilds.
|
||||
|
||||
### Decide what "change" means before you need it
|
||||
|
||||
A common and defensible implementation makes post-change destroy and respawn the
|
||||
actor, because propagating a diff into an already-spawned actor is hard. That is
|
||||
fine and it is a decision with a cost: every edit is a full rebuild, so a system
|
||||
that edits parts frequently will hitch. Recipe: CT-02.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tag-driven selection: rules plus a default
|
||||
|
||||
Parts contribute tags — body style, animation style, armour set. A selection set
|
||||
maps required tags to a mesh or an animation layer, with a fallback:
|
||||
|
||||
1. gather tags from all active parts;
|
||||
2. walk the rules in order;
|
||||
3. the first rule whose required tags are **all** present wins;
|
||||
4. otherwise use the default.
|
||||
|
||||
This microscopic pattern — an array of (asset, required tags) plus a default,
|
||||
first match wins — is about ten lines and is worth copying wholesale. It is the
|
||||
same shape whether it selects a mesh, a physics asset or an animation layer.
|
||||
|
||||
### Its one sharp edge
|
||||
|
||||
**Priority is array order.** There are no weights and no specificity ranking, so
|
||||
a broad rule placed above a narrow one silently shadows it, and the symptom is a
|
||||
wrong mesh rather than an error. Document the ordering requirement next to the
|
||||
array, and validate that no rule's tag set is a subset of an earlier rule's.
|
||||
Recipe: CT-03.
|
||||
|
||||
### Do not let cosmetic tags become gameplay tags
|
||||
|
||||
They may share a type. They must not share authority. A tag contributed by a
|
||||
cosmetic part is client-realizable data; nothing that decides gameplay may read
|
||||
it without an explicit, reviewed promotion.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cosmetic lifecycle tests
|
||||
|
||||
Add and remove one part; several owners contributing; controller changes pawn;
|
||||
pawn destroyed before controller cleanup; replicated add, remove and change; late
|
||||
join reconstructs everything; **dedicated server spawns zero presentation
|
||||
actors**; listen server does not realize twice; invalid part class logs rather
|
||||
than crashes; incomplete tag set falls back.
|
||||
|
||||
---
|
||||
|
||||
# Part II — Teams
|
||||
|
||||
## 6. One authoritative source, and loud mirrors
|
||||
|
||||
Put the team identifier on the player state: it survives respawn, already
|
||||
replicates, and belongs to player identity rather than to a body.
|
||||
|
||||
Controllers, pawns and local players may mirror it. The rules that make mirroring
|
||||
safe:
|
||||
|
||||
- mirrors **subscribe** to the source and re-subscribe when the source object
|
||||
changes;
|
||||
- a write to a mirror is **rejected loudly**, not silently ignored;
|
||||
- a pawn may hold its own value only while unpossessed, and must say so in the
|
||||
error when it refuses.
|
||||
|
||||
The second rule is the one teams get wrong. A silent no-op setter on a mirror is
|
||||
indistinguishable from a working setter until something depends on it. Log an
|
||||
error naming the real owner. Recipe: CT-06.
|
||||
|
||||
---
|
||||
|
||||
## 7. Resolve team from an arbitrary object, in one place
|
||||
|
||||
Centralize the cascade so damage, UI, AI and targeting cannot each grow their own
|
||||
version:
|
||||
|
||||
1. the object implements the team interface;
|
||||
2. a pawn resolves through its controller or player state;
|
||||
3. an actor resolves through its **instigator** — this is what lets projectiles
|
||||
and effects carry the shooter's team without owning one;
|
||||
4. otherwise unassigned.
|
||||
|
||||
### Return a relationship, not a boolean
|
||||
|
||||
```text
|
||||
same team / different teams / indeterminate
|
||||
```
|
||||
|
||||
A boolean conflates "enemy" with "unknown", and the caller then has to guess.
|
||||
Every caller must handle the third case explicitly — and "indeterminate means
|
||||
allowed" is the failure that ships. See `ue-multiplayer-authority` for why an
|
||||
error path that permits is worse than one that denies.
|
||||
|
||||
---
|
||||
|
||||
## 8. One damage policy, used by both authority and prediction
|
||||
|
||||
One function answers "may this instigator damage this target". The damage
|
||||
calculation multiplies by its result as a scalar rather than branching, so the
|
||||
team rule participates in the formula like any other modifier.
|
||||
|
||||
**The same function decides whether to show a hit marker.** That single choice
|
||||
removes the entire class of "the client showed a hit and the server dealt no
|
||||
damage" bugs, because there is no second implementation to disagree with.
|
||||
|
||||
### Make the policy configurable before you need it
|
||||
|
||||
Friendly fire, self-damage, damage to unassigned actors and per-mode overrides
|
||||
are policy inputs. If they are hard-coded inside the damage calculation, a mode
|
||||
that wants different rules requires editing the calculation.
|
||||
|
||||
Watch for the shape where a header comment promises settings that the body does
|
||||
not implement — the comment is a design that was not built, and it reads as a
|
||||
feature. Recipe: CT-07.
|
||||
|
||||
---
|
||||
|
||||
## 9. Presentation as a named parameter bag
|
||||
|
||||
A team display asset should hold **named material parameters** — scalars,
|
||||
colours, textures, a short name — rather than a set of assets. Consumers ask for
|
||||
a parameter; materials are authored to expect the names. Adding a team then costs
|
||||
one asset instead of a duplicated material tree.
|
||||
|
||||
Apply it to meshes, effects and UI. Applying to an actor should include child
|
||||
actors by default — that is precisely what carries team colour onto cosmetic
|
||||
parts, and it is the only place the two halves of this skill touch.
|
||||
|
||||
### Two traps in the applying code
|
||||
|
||||
**Colour conversion drops alpha.** Converting a linear colour to a three-element
|
||||
vector to feed a material parameter silently discards the fourth channel. If any
|
||||
team parameter encodes opacity, it is gone. Recipe: CT-09.
|
||||
|
||||
**A viewer-relative parameter that is ignored.** If the API accepts a viewer
|
||||
identity — "always show my own team as blue" — then the implementation must use
|
||||
it. An accepted-and-ignored parameter is worse than an absent one: it makes the
|
||||
feature look supported and unimplementable at the same time. Recipe: CT-10.
|
||||
|
||||
---
|
||||
|
||||
## 10. Replicate intent, realize locally — the shared principle
|
||||
|
||||
| Domain | Replicated intent | Local realization |
|
||||
|---|---|---|
|
||||
| cosmetics | part definitions | spawned components, meshes, materials |
|
||||
| teams | team identifier | colours, UI, effects, nameplates |
|
||||
| composition | selected definition | activated plugins and actions |
|
||||
|
||||
Realization must be **idempotent**: applying the same intent twice produces one
|
||||
result. This is what makes late join deterministic from compact state.
|
||||
|
||||
---
|
||||
|
||||
## 11. Validation checklist
|
||||
|
||||
### Cosmetics
|
||||
|
||||
- [ ] part class is presentation-only and valid for the body;
|
||||
- [ ] socket exists, or a fallback is defined;
|
||||
- [ ] collision disabled by default;
|
||||
- [ ] handle and spawned component are non-replicated;
|
||||
- [ ] dedicated server realizes nothing;
|
||||
- [ ] every owner retains removal handles;
|
||||
- [ ] possession transfer removes then applies, exactly once;
|
||||
- [ ] rule ordering documented, fallback present, no shadowed rules;
|
||||
- [ ] no gameplay decision reads a cosmetic tag.
|
||||
|
||||
### Teams
|
||||
|
||||
- [ ] one authoritative source, authority-only writes;
|
||||
- [ ] mirrors subscribe, re-subscribe, and refuse writes loudly;
|
||||
- [ ] resolution is centralized and includes the instigator step;
|
||||
- [ ] the indeterminate relationship is handled explicitly at every caller;
|
||||
- [ ] damage and hit feedback call the same policy function;
|
||||
- [ ] friendly fire and self-damage are configurable and tested;
|
||||
- [ ] "private" team data is actually filtered, or is renamed;
|
||||
- [ ] display parameters exist in the target materials;
|
||||
- [ ] a viewer-relative API uses the viewer.
|
||||
|
||||
---
|
||||
|
||||
## 12. Keep them separate
|
||||
|
||||
They meet at one seam:
|
||||
|
||||
```text
|
||||
team identifier → display asset → material parameters on cosmetic parts
|
||||
```
|
||||
|
||||
Do not let cosmetics own the team identifier, and do not let the team system own
|
||||
part selection. Separation is what allows skins independent of teams, players
|
||||
with cosmetics and no team, team recolour over any skin, and viewer-relative
|
||||
presentation without touching authoritative state.
|
||||
|
||||
Combine them only inside a small presentation adapter — never in authority or
|
||||
replication ownership.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
The findings behind the failure modes come from a source audit of Epic's Lyra
|
||||
Starter Game on Unreal Engine 5.6 — roughly 1300 lines of cosmetics and 1800 of
|
||||
teams, read rather than run. Several of the wiring call sites live in Blueprint
|
||||
graphs, which are binary and were not read; those are marked open rather than
|
||||
concluded, and the skill is written so that none of its claims depend on them.
|
||||
|
||||
Source addresses stay in the research archive. Each entry carries a stable
|
||||
identifier (`CT-01`, `CT-02`, …) resolving back to the audited location there.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
One project, one engine version, one workspace. The limits are specific and worth
|
||||
repeating: the components are added to actors from data assets that were not
|
||||
read, the team colour application has no C++ caller, and the animation-layer
|
||||
selection is invoked from a Blueprint. Absence of a C++ caller is **not** absence
|
||||
of a caller — see the failure modes for how to check properly before deleting
|
||||
anything.
|
||||
@@ -0,0 +1,406 @@
|
||||
# Failure modes: cosmetics and teams
|
||||
|
||||
Eleven ways an appearance system or a team system produces the wrong result
|
||||
without producing an error.
|
||||
|
||||
The shared property differs slightly between the two halves, and both are worth
|
||||
stating.
|
||||
|
||||
**Cosmetics fail by looking almost right.** A wrong mesh, a missing part or a
|
||||
duplicated accessory is a visual outcome, and every visual outcome is plausible
|
||||
to code. Nothing can assert that a character looks correct, so these defects are
|
||||
found by people, late, and reported as "the skin is broken".
|
||||
|
||||
**Teams fail by permitting.** Every interesting team defect resolves to
|
||||
"indeterminate", and the branch that handles indeterminate almost always allows
|
||||
the action, because denying it would have blocked something during development.
|
||||
|
||||
Recipes use `rg` from a project source root and were executed against the audited
|
||||
project while this file was written.
|
||||
|
||||
---
|
||||
|
||||
## Cosmetics
|
||||
|
||||
### CT-01 - The isolation guarantee is one line, and it is narrower than it looks
|
||||
|
||||
**Mechanism.** Cosmetic presentation is excluded from the dedicated server by a
|
||||
single net-mode check at the spawn site. That check is the entire reason cosmetic
|
||||
content cannot influence authoritative simulation.
|
||||
|
||||
**Why it is silent.** It works. On a dedicated server no presentation actor
|
||||
exists, so none can collide, block a trace or tick. The guarantee holds exactly
|
||||
where it is claimed.
|
||||
|
||||
**Why the obvious check misses it.** The guarantee is usually restated as
|
||||
"cosmetics cannot affect gameplay", which is a stronger claim than the code
|
||||
makes. On a listen server or in standalone the parts **do** exist on the
|
||||
authority, with whatever collision and components their class carries. Nothing in
|
||||
the type system prevents a part class from containing an ability system or
|
||||
enabled collision — the constraint is content discipline plus one `if`.
|
||||
|
||||
**Symptom.** A cosmetic accessory that blocks a shot, or a part actor that ticks
|
||||
expensively, in exactly the configuration used for local playtesting — and never
|
||||
on the dedicated server where the "real" testing happens.
|
||||
|
||||
**Detect.** Find the guard, confirm there is only one, and check what part
|
||||
classes are permitted to contain:
|
||||
|
||||
```bash
|
||||
rg -n "NM_DedicatedServer|IsNetMode" --glob "*.cpp" <cosmetics-module>/
|
||||
rg -n "CollisionMode|SetActorEnableCollision" --glob "*.cpp" --glob "*.h" \
|
||||
<cosmetics-module>/
|
||||
```
|
||||
|
||||
In the audited project the first command returns **exactly one line** for the
|
||||
whole module. That is elegant and it is also the entire safety boundary, which is
|
||||
worth knowing before relying on it.
|
||||
|
||||
**Guardrail.** State the guarantee accurately: "not present on a dedicated
|
||||
server", not "cannot affect gameplay". Validate part classes at author time —
|
||||
reject any that contain an ability system component, replicated properties or
|
||||
enabled collision by default.
|
||||
|
||||
---
|
||||
|
||||
### CT-02 - Every change rebuilds the actor
|
||||
|
||||
**Mechanism.** The replication callback for a changed entry destroys the spawned
|
||||
presentation and spawns a new one, because propagating a diff into a live actor
|
||||
is hard and rarely needed.
|
||||
|
||||
**Why it is silent.** It is correct. The result after a change is exactly the
|
||||
result after an add, which is what the system promises. The cost is a rebuild,
|
||||
and a rebuild looks identical to a first build.
|
||||
|
||||
**Why the obvious check misses it.** The implementation is deliberate and
|
||||
commented as such. Reviewing it shows a decision, not a defect. The consequence
|
||||
only appears at a usage frequency nobody has yet — a system that changes parts
|
||||
rarely and one that changes them per second are the same code.
|
||||
|
||||
**Symptom.** A hitch on every cosmetic edit. Discovered when a feature starts
|
||||
mutating parts at runtime — a preview screen, a colour cycler, a progression
|
||||
system — long after the mechanism was reviewed.
|
||||
|
||||
**Detect.** Read what the change callback does, and compare it with the add path:
|
||||
|
||||
```bash
|
||||
rg -n -A12 "PostReplicatedChange" --glob "*.cpp" . | rg -i "destroy|spawn|update"
|
||||
```
|
||||
|
||||
If change is implemented as destroy plus spawn, treat the mechanism as
|
||||
add/remove only and design callers accordingly.
|
||||
|
||||
**Guardrail.** Document the cost where authors will see it. If frequent edits are
|
||||
planned, add a real update path for the fields that can change in place, and keep
|
||||
the rebuild for the ones that cannot.
|
||||
|
||||
---
|
||||
|
||||
### CT-03 - Rule order is priority, and nothing says so
|
||||
|
||||
**Mechanism.** A selection set is an array of rules, each with a set of required
|
||||
tags, plus a default. The first rule whose tags are all present wins. Rules are
|
||||
not sorted by specificity.
|
||||
|
||||
**Why it is silent.** Every possible input produces a result — either a rule or
|
||||
the default. There is no "no match" state to report, and the result is always a
|
||||
valid asset.
|
||||
|
||||
**Why the obvious check misses it.** The array looks like a set, and sets have no
|
||||
order. A reviewer checking "are the rules correct?" checks each rule in
|
||||
isolation, where each is correct. The defect exists only in the relationship
|
||||
between a broad rule and a narrower one below it, which requires comparing every
|
||||
pair.
|
||||
|
||||
**Symptom.** A character with a specific tag combination gets the generic mesh.
|
||||
Nobody notices for weeks, because it is the *right kind* of mesh.
|
||||
|
||||
**Detect.** Find selection sets and check for shadowing — a rule whose tag set is
|
||||
a subset of an earlier rule's:
|
||||
|
||||
```bash
|
||||
rg -n -B2 -A6 "SelectBest|RequiredTags" --glob "*.h" --glob "*.cpp" .
|
||||
```
|
||||
|
||||
Then, for each set, verify the shadowing property in a test rather than by
|
||||
reading: for every pair of rules `i < j`, assert that rule `i`'s tags are not a
|
||||
subset of rule `j`'s.
|
||||
|
||||
**Guardrail.** Write the ordering rule as a comment on the array and enforce the
|
||||
subset check in validation. A rule that can never win is a content bug the
|
||||
compiler cannot see.
|
||||
|
||||
---
|
||||
|
||||
### CT-04 - The realization path re-applies unconditionally
|
||||
|
||||
**Mechanism.** Recomputing appearance sets the mesh on every change broadcast,
|
||||
relying on the setter to be cheap when the value has not changed — while passing
|
||||
a flag that forces a pose reinitialization.
|
||||
|
||||
**Why it is silent.** The result is correct. The mesh is right, the pose is
|
||||
right, and the cost is invisible at the frequency the system is normally used.
|
||||
|
||||
**Why the obvious check misses it.** The code carries a comment explaining that
|
||||
the setter is a no-op when the mesh is unchanged, which answers the question a
|
||||
reviewer would ask. The forced flag is a separate argument on the same call, and
|
||||
it is not what the comment is about.
|
||||
|
||||
**Symptom.** A visible pose pop or an animation reset whenever anything about
|
||||
cosmetics changes, including changes that do not affect the mesh at all.
|
||||
|
||||
**Detect.** Check the arguments of the re-application call, not just the call:
|
||||
|
||||
```bash
|
||||
rg -n -B6 "SetSkeletalMesh\(" --glob "*.cpp" . | rg "bReinit|true|Broadcast"
|
||||
```
|
||||
|
||||
A hardcoded reinitialization flag on a path that runs on every change is the
|
||||
finding.
|
||||
|
||||
**Guardrail.** Compare before applying, and pass the reinitialization flag only
|
||||
when the mesh actually changed. Idempotent realization means "applying twice
|
||||
costs nothing", not merely "applying twice is correct".
|
||||
|
||||
---
|
||||
|
||||
### CT-05 - Everything is a hard reference
|
||||
|
||||
**Mechanism.** Part classes and selection-set assets are hard references, so the
|
||||
entire cosmetic catalogue is loaded with whatever holds the rules.
|
||||
|
||||
**Why it is silent.** Hard references always resolve. Nothing is ever missing,
|
||||
nothing ever fails to load, and every asset is available the moment it is needed.
|
||||
|
||||
**Why the obvious check misses it.** Reference correctness is what reviews check,
|
||||
and hard references are maximally correct. The cost is memory residency, which
|
||||
belongs to a different discipline and a different person, and which does not
|
||||
appear in any test of the cosmetics system.
|
||||
|
||||
**Symptom.** Memory proportional to the size of the cosmetic catalogue rather
|
||||
than to what is worn, discovered during a platform memory pass and attributed to
|
||||
content rather than to a reference-type decision.
|
||||
|
||||
**Detect.** Count hard versus soft references in the cosmetic data types:
|
||||
|
||||
```bash
|
||||
rg -n "TSubclassOf<|TObjectPtr<" --glob "*.h" <cosmetics-module>/ | wc -l
|
||||
rg -n "TSoftClassPtr<|TSoftObjectPtr<" --glob "*.h" <cosmetics-module>/ | wc -l
|
||||
```
|
||||
|
||||
A large first number with a zero second is the finding. See
|
||||
`ue-asset-loading-and-memory` for what this costs and how to measure it.
|
||||
|
||||
**Guardrail.** Cosmetic catalogues are the textbook case for soft references and
|
||||
bundles: large, optional, and mostly unworn. Decide per field and record the
|
||||
decision in metadata.
|
||||
|
||||
---
|
||||
|
||||
## Teams
|
||||
|
||||
### CT-06 - The mirror setter that silently does nothing
|
||||
|
||||
**Mechanism.** Team identity is authoritative on one object and mirrored on
|
||||
several others. The mirrors implement the interface's setter because they must,
|
||||
and the implementation does nothing.
|
||||
|
||||
**Why it is silent.** Doing nothing is correct — the mirror is not the owner. The
|
||||
call succeeds, returns, and the value is unchanged.
|
||||
|
||||
**Why the obvious check misses it.** The setter exists and is implemented. Any
|
||||
review asking "does this type support setting a team?" answers yes. The caller
|
||||
has no return value to check and no reason to suspect the write did not land.
|
||||
|
||||
**Symptom.** Code that sets a team on a controller or a pawn and observes the old
|
||||
value. The investigation goes to replication, then to ordering, then eventually to
|
||||
the setter.
|
||||
|
||||
**Detect.** Read the bodies of every team setter and classify them:
|
||||
|
||||
```bash
|
||||
rg -n -A4 "::SetGenericTeamId|::SetTeamId" --glob "*.cpp" . \
|
||||
| rg -B1 "UE_LOG|ensure|^\s*\}"
|
||||
```
|
||||
|
||||
The audited project does this **correctly** and is worth copying: the mirror
|
||||
setters log an error naming the real owner rather than returning quietly. That
|
||||
one line converts an invisible failure into a searchable one.
|
||||
|
||||
**Guardrail.** A mirror's setter logs an error identifying the authoritative
|
||||
owner. Never implement a no-op setter to satisfy an interface.
|
||||
|
||||
---
|
||||
|
||||
### CT-07 - The header promises a policy the body does not implement
|
||||
|
||||
**Mechanism.** The damage-permission function is documented as taking friendly
|
||||
fire settings into account. The body contains no settings and no lookup.
|
||||
|
||||
**Why it is silent.** The behaviour is coherent — allies are always safe — and
|
||||
that is the right default for most modes. Nothing malfunctions.
|
||||
|
||||
**Why the obvious check misses it.** The comment *is* the documentation. A team
|
||||
adopting the system reads the header, concludes friendly fire is supported, and
|
||||
plans a mode around it. Discovering otherwise requires reading a body that looks
|
||||
finished.
|
||||
|
||||
**Symptom.** A mode that needs friendly fire is scoped as a configuration change
|
||||
and turns out to be a design change, in a function every damage path depends on.
|
||||
|
||||
**Detect.** Compare the promise with the implementation:
|
||||
|
||||
```bash
|
||||
rg -n -i "friendly fire" --glob "*.h" --glob "*.cpp" .
|
||||
rg -n -i "bFriendlyFire|bAllowFriendly|FriendlyFireSetting" --glob "*.h" --glob "*.cpp" .
|
||||
```
|
||||
|
||||
In the audited project the first command finds the promise in a header comment
|
||||
and the second returns **zero results project-wide**. A promise with no
|
||||
implementing symbol is the finding, and this recipe generalises to any header
|
||||
comment describing configurability.
|
||||
|
||||
**Guardrail.** Treat a comment describing behaviour as a claim requiring a
|
||||
symbol. If the setting does not exist, the comment describes a plan and must say
|
||||
so.
|
||||
|
||||
---
|
||||
|
||||
### CT-08 - Indeterminate resolves to permitted
|
||||
|
||||
**Mechanism.** Team comparison correctly returns three states. The damage rule
|
||||
treats the indeterminate case as allowed when the target satisfies an unrelated
|
||||
condition — in the audited project, having an ability system component.
|
||||
|
||||
**Why it is silent.** It exists to make something work: an unassigned training
|
||||
target must be damageable. It succeeds at that, and the permissive branch is
|
||||
never reached by any assigned actor during normal play.
|
||||
|
||||
**Why the obvious check misses it.** The function has three branches and handles
|
||||
all three, so it passes any review asking whether the indeterminate case is
|
||||
handled. It *is* handled — permissively — and the marker in the code says
|
||||
"temporary".
|
||||
|
||||
**Symptom.** Any actor with an ability system and no team assignment is damageable
|
||||
by anyone. Harmless while the only such actor is a target dummy; a rule violation
|
||||
the moment a neutral faction, a destructible objective or an NPC exists.
|
||||
|
||||
**Detect.** Find every consumer of the relationship enum and read its
|
||||
indeterminate branch:
|
||||
|
||||
```bash
|
||||
rg -n -B4 -A10 "InvalidArgument|Indeterminate|NoTeam" --glob "*.cpp" . \
|
||||
| rg "return true|= true|Allow"
|
||||
rg -n -i "//\s*@?TODO.*(temporary|until)" --glob "*.cpp" .
|
||||
```
|
||||
|
||||
The second command is the high-yield one: a permissive branch that its own author
|
||||
marked temporary is the strongest possible confirmation that the finding is real
|
||||
and known.
|
||||
|
||||
**Guardrail.** Failure to determine must deny. Give the target dummy a team
|
||||
instead of giving every unassigned actor an exemption — solve the specific
|
||||
problem in data, not the general rule in code.
|
||||
|
||||
---
|
||||
|
||||
### CT-09 - A colour conversion that drops alpha
|
||||
|
||||
**Mechanism.** A four-channel colour is converted to a three-element vector to be
|
||||
passed as a material parameter. The fourth channel is discarded.
|
||||
|
||||
**Why it is silent.** Three of four channels arrive correctly, so the colour is
|
||||
right. Alpha is frequently unused in team colours, making the loss invisible for
|
||||
as long as nobody encodes anything in it.
|
||||
|
||||
**Why the obvious check misses it.** The conversion is one token inside an
|
||||
otherwise correct call. Both types are colours, both are correct, and the review
|
||||
question — "is the parameter set?" — is answered yes.
|
||||
|
||||
**Symptom.** A team parameter that encodes opacity or a blend factor silently
|
||||
reads as its default. In the same file, the effects-system path preserves all four
|
||||
channels, so the same asset behaves differently in two places.
|
||||
|
||||
**Detect.** Find lossy conversions at parameter-setting sites:
|
||||
|
||||
```bash
|
||||
rg -n "SetVectorParameterValue\w*\(.*FVector\(" --glob "*.cpp" .
|
||||
rg -n "SetVariableLinearColor|SetVectorParameterValue" --glob "*.cpp" .
|
||||
```
|
||||
|
||||
Two applicators for the same data with different channel counts is the finding.
|
||||
In the audited project the material paths convert and the effects path does not.
|
||||
|
||||
**Guardrail.** Pass four channels where the API accepts them. If a conversion is
|
||||
unavoidable, assert or document that alpha is unused.
|
||||
|
||||
---
|
||||
|
||||
### CT-10 - An accepted parameter that is ignored
|
||||
|
||||
**Mechanism.** The display-asset accessor takes a viewer identity so that
|
||||
presentation can be relative — "my team always looks blue". The implementation
|
||||
does not read it.
|
||||
|
||||
**Why it is silent.** The function returns the correct absolute asset. Every
|
||||
caller gets a valid result, and the feature the parameter implies has simply
|
||||
never been exercised.
|
||||
|
||||
**Why the obvious check misses it.** The signature and the header comment
|
||||
describe the feature completely. Confirming absence requires reading the body,
|
||||
where the parameter is unused — and an unused parameter produces no warning
|
||||
because it is part of a virtual-looking public API.
|
||||
|
||||
**Symptom.** A team implementing viewer-relative colours writes correct calling
|
||||
code and observes absolute colours. The bug appears to be in their code.
|
||||
|
||||
**Detect.** For any parameter that names a feature, check that the body reads it:
|
||||
|
||||
```bash
|
||||
P='ViewerTeamId'
|
||||
rg -n "\b$P\b" --glob "*.h" --glob "*.cpp" .
|
||||
```
|
||||
|
||||
Occurrences only in the declaration and the definition's signature — with none in
|
||||
the body — is the finding. In the audited project the body carries a comment
|
||||
stating the parameter is currently ignored, which is honest and still ships an
|
||||
API that cannot do what it claims.
|
||||
|
||||
**Guardrail.** Do not accept a parameter you do not use. Remove it, or implement
|
||||
it, or make the function name state the limitation. This is the same defect class
|
||||
as an ordering field that never sorts — see `ue-ui-architecture`, UI-01.
|
||||
|
||||
---
|
||||
|
||||
### CT-11 - "Private" that is not filtered
|
||||
|
||||
**Mechanism.** Team data is split into a public and a private actor to express a
|
||||
replication boundary. The private one has no filtering; it is an empty subclass
|
||||
with a note that privacy is not implemented.
|
||||
|
||||
**Why it is silent.** Everything works. Both actors replicate, both carry their
|
||||
data, and the split is structurally correct — it is only the *filtering* that is
|
||||
absent.
|
||||
|
||||
**Why the obvious check misses it.** The architecture is visible and right: two
|
||||
types, two names, a clear intent. Reading the class names answers the question.
|
||||
Confirming means noticing that the private subclass has no body.
|
||||
|
||||
**Symptom.** Data placed in the private actor because it is private is replicated
|
||||
to every client. Nothing indicates this until someone inspects network traffic —
|
||||
or until a competitor does.
|
||||
|
||||
**Detect.** Check that the privacy-named type actually filters:
|
||||
|
||||
```bash
|
||||
rg -n -A15 "class \w*PrivateInfo|class \w*Private\w*" --glob "*.h" .
|
||||
rg -n "IsNetRelevantFor|GetLifetimeReplicatedProps|COND_" --glob "*.cpp" . \
|
||||
| rg -i "private"
|
||||
```
|
||||
|
||||
An empty subclass, or one with no relevancy or condition logic, is the finding.
|
||||
|
||||
**Guardrail.** A name is not a mechanism. Either implement the filtering — via
|
||||
relevancy, replication conditions or a replication graph — or rename the type to
|
||||
what it is. In the meantime, do not put anything in it that matters.
|
||||
@@ -0,0 +1,288 @@
|
||||
# Patterns: cosmetics and teams in a measured reference product
|
||||
|
||||
A worked example of both halves of `SKILL.md` against one reference project read
|
||||
as source. The two systems are presented together because the audit found what
|
||||
the skill claims: they touch at exactly one point, and both are well built on
|
||||
either side of it.
|
||||
|
||||
This file is unusually positive. That is the finding, not a tone: the
|
||||
architecture here is the strongest in the audited project, and the defects are
|
||||
narrow and specific. Separating "this design is worth copying" from "this
|
||||
implementation is finished" is the entire job.
|
||||
|
||||
Markers: **[measured]** — read in source; **[derived]** — conclusion from
|
||||
measured facts; **[open]** — not answerable from source, because the call site is
|
||||
in a binary asset.
|
||||
|
||||
---
|
||||
|
||||
## 1. The two-level split, and what it buys
|
||||
|
||||
```text
|
||||
controller component owns the desired part list, survives respawn
|
||||
│ on possession change: remove from old, apply to new, keep handles
|
||||
▼
|
||||
pawn component replicated list, spawns presentation, computes tags
|
||||
```
|
||||
|
||||
The controller component subscribes to possession changes **only on the
|
||||
authority**, and if a pawn already exists at subscription time it invokes its own
|
||||
handler with a null previous pawn **[measured]**. That second detail is the
|
||||
pattern's whole robustness: the same code path handles "pawn already here" and
|
||||
"pawn arrives later", so ordering stops mattering.
|
||||
|
||||
Transfer removes by handle from the old pawn and re-applies to the new, guarding
|
||||
against double-add by checking whether the handle is already valid **[measured]**.
|
||||
|
||||
**[derived]** This generalises well beyond appearance. Any state that belongs to
|
||||
the *player* and is realized on the *body* — loadouts, perks, temporary buffs —
|
||||
has the same shape and the same failure mode if you skip it: the state dies with
|
||||
the pawn.
|
||||
|
||||
The link from controller to pawn is found dynamically each time, with no cache
|
||||
**[measured]**. If the pawn has no cosmetics component the lookup returns null and
|
||||
nothing happens, silently — a tolerance that is correct for optional cosmetics and
|
||||
would be wrong for anything required.
|
||||
|
||||
---
|
||||
|
||||
## 2. Replicating intent, with the split declared in the type
|
||||
|
||||
One structure carries both server-only and client-only fields, distinguished by
|
||||
annotation rather than by having two types **[measured]**:
|
||||
|
||||
| Field | Replicated | Lives on |
|
||||
|---|---|---|
|
||||
| the part itself: class plus socket | yes | server → clients |
|
||||
| the handle returned to the caller | **no** | server only |
|
||||
| the spawned presentation component | **no** | client only |
|
||||
|
||||
**[derived]** This is the cleanest available demonstration of "replicate intent,
|
||||
realize locally". The wire carries a class reference and a socket name; the
|
||||
receiving machine reconstructs the appearance. Late join is deterministic because
|
||||
the state is compact and complete.
|
||||
|
||||
Change handling is deliberately coarse: the change callback destroys and respawns
|
||||
rather than propagating a diff, and the authors say so in a comment
|
||||
**[measured]**. Recipe: CT-02.
|
||||
|
||||
Part identity is defined as the pair (class, socket) and deliberately **excludes**
|
||||
the collision mode from the comparison **[measured]** — so two requests differing
|
||||
only in collision are the same part. **[derived]** That is a real decision about
|
||||
what identity means, made once, in the comparison function where it belongs.
|
||||
|
||||
---
|
||||
|
||||
## 3. The isolation guarantee, stated precisely
|
||||
|
||||
One line excludes presentation from the dedicated server, and it is the only such
|
||||
line in the module **[measured]**.
|
||||
|
||||
**[derived]** The consequence is strong and worth stating exactly: on a dedicated
|
||||
server the cosmetic actors do not exist, so they cannot collide, block a trace,
|
||||
tick, or influence the authoritative simulation in any way. That is a real
|
||||
architectural guarantee obtained for one `if`.
|
||||
|
||||
It is also narrower than the sentence people repeat. On a listen server or in
|
||||
standalone the parts exist on the authority, with whatever their class contains,
|
||||
and nothing in the type system prevents a part class from carrying an ability
|
||||
system or enabled collision. The other protections — collision defaulting to off,
|
||||
authority-only mutators, no ability-system includes anywhere in the module
|
||||
**[measured]** — are conventions and content discipline. Recipe: CT-01.
|
||||
|
||||
**The honest form:** *not present on a dedicated server*, not *cannot affect
|
||||
gameplay*.
|
||||
|
||||
---
|
||||
|
||||
## 4. The ten-line pattern worth stealing
|
||||
|
||||
An array of rules, each an asset plus a set of required tags, plus a default.
|
||||
Gather tags, walk the rules in order, first rule whose tags are **all** present
|
||||
wins, otherwise the default **[measured]**.
|
||||
|
||||
It appears at least three times in the audited project — body mesh, forced
|
||||
physics asset, and weapon animation layer **[measured]** — and costs roughly ten
|
||||
lines each time.
|
||||
|
||||
**[derived]** Its sharp edge is that priority is array order, with no weights and
|
||||
no specificity ranking. A broad rule above a narrow one silently shadows it, and
|
||||
the failure produces a plausible wrong asset rather than an error. Recipe: CT-03.
|
||||
|
||||
The tag namespace is declared in config and is deliberately small — a body style
|
||||
and two animation styles **[measured]**. **[derived]** Small is the right size:
|
||||
every tag in this namespace multiplies the rule matrix that has to be reasoned
|
||||
about.
|
||||
|
||||
---
|
||||
|
||||
## 5. Where the two systems touch
|
||||
|
||||
Exactly one place, and the code says so. The cosmetics component broadcasts a
|
||||
change with a comment noting that observers may need to apply team colouring
|
||||
**[measured]**; the team display asset applies material parameters to an actor
|
||||
**and its child actors by default** **[measured]** — and cosmetic parts are child
|
||||
actors.
|
||||
|
||||
**[derived]** That default is what carries team colour onto modular parts. It is
|
||||
one boolean default, and it is the seam.
|
||||
|
||||
**[open]** The actual call is made from a Blueprint: the applying function has no
|
||||
C++ caller. Which leads to the most important methodological point in this file.
|
||||
|
||||
---
|
||||
|
||||
## 6. What could not be answered, and why that matters
|
||||
|
||||
Four things in this subsystem have **no C++ call site** **[measured]**:
|
||||
|
||||
- who applies team colours to an actor;
|
||||
- who selects the weapon animation layer from cosmetic tags;
|
||||
- where the cosmetics component is added to a pawn;
|
||||
- where the controller component is added to a controller.
|
||||
|
||||
All four are wired in Blueprint or data assets, which are binary and were not
|
||||
read.
|
||||
|
||||
**[derived]** The correct conclusion is *"the wiring is in a place this audit
|
||||
cannot see"*, and the incorrect one — available, tempting, and wrong — is *"this
|
||||
function is unused"*. Both components are marked as spawnable from Blueprint
|
||||
**[measured]**, which is positive evidence for the first reading.
|
||||
|
||||
This is the general rule from `ue-reference-project-adoption`, met in its natural
|
||||
habitat: **absence of a C++ caller is not absence of a caller.** Anyone deleting
|
||||
"dead" code in a project with a large Blueprint surface needs the editor's
|
||||
reference viewer, not a text search.
|
||||
|
||||
---
|
||||
|
||||
## 7. Teams: one source, mirrors that refuse loudly
|
||||
|
||||
Membership lives on the player state, replicated push-based, authority-only, with
|
||||
a non-authority write logged as an error **[measured]**.
|
||||
|
||||
Every mirror — controller, bot controller, local player, pawn — reads through to
|
||||
the source, and their setters **log an error naming the real owner** rather than
|
||||
silently doing nothing **[measured]**.
|
||||
|
||||
**[derived]** This is the single most copyable line in the team system. A silent
|
||||
no-op setter on a mirror is indistinguishable from a working one until something
|
||||
depends on it; an error line converts an invisible failure into a searchable one,
|
||||
at a cost of one statement. Recipe: CT-06.
|
||||
|
||||
The pawn mirror is more subtle and also correct: it accepts a direct write **only
|
||||
while unpossessed**, and refuses with an explanatory error once a controller owns
|
||||
it **[measured]**. On possession it takes the controller's value and subscribes to
|
||||
its change delegate; on unpossession it unsubscribes and calls an overridable hook
|
||||
whose default is "no team" **[measured]**.
|
||||
|
||||
**[derived]** That hook is an extension point placed exactly where a neutral
|
||||
faction would need it, which is the difference between a design and an
|
||||
implementation that happens to work.
|
||||
|
||||
---
|
||||
|
||||
## 8. Resolution, comparison, and the one permissive branch
|
||||
|
||||
Resolution is a four-step cascade — interface, instigator, team-info actor,
|
||||
associated player state **[measured]**. **[derived]** The instigator step is what
|
||||
lets projectiles and area effects carry the shooter's team without owning one; a
|
||||
design that omits it grows a team field on every projectile.
|
||||
|
||||
Comparison returns three states rather than a boolean **[measured]**, which is
|
||||
correct and is what makes the next finding visible at all.
|
||||
|
||||
The damage rule permits four ways **[measured]**, and the third is the finding:
|
||||
when the relationship is indeterminate, damage is allowed if the target has an
|
||||
ability system component — with an author's comment marking it temporary until a
|
||||
training dummy gets a team assignment.
|
||||
|
||||
**[derived]** It exists to make one specific actor damageable and it generalises
|
||||
to every unassigned actor with an ability system. Harmless while the only such
|
||||
actor is a dummy; a rule violation the moment a neutral faction or a destructible
|
||||
objective exists. **Solve it in data — give the dummy a team — rather than in the
|
||||
rule.** Recipe: CT-08.
|
||||
|
||||
Friendly fire is promised by a header comment and implemented nowhere: a
|
||||
project-wide search for any friendly-fire symbol returns **zero** **[measured]**.
|
||||
Allies are unconditionally safe. Recipe: CT-07.
|
||||
|
||||
---
|
||||
|
||||
## 9. One policy function, two consumers — the pattern to copy
|
||||
|
||||
The same permission function is called by the authoritative damage calculation
|
||||
and by the client-side decision to show a hit marker **[measured]**.
|
||||
|
||||
**[derived]** This removes an entire bug class by construction. "The client showed
|
||||
a hit and the server dealt no damage" requires two implementations of one rule to
|
||||
disagree, and here there is only one implementation. Any project with predicted
|
||||
feedback should copy this arrangement before copying anything else in the team
|
||||
system.
|
||||
|
||||
The damage calculation consumes the result as a **0-or-1 multiplier** in the
|
||||
formula rather than as an early return **[measured]**. **[derived]** The team rule
|
||||
becomes an ordinary participant in the arithmetic, alongside distance and surface
|
||||
attenuation, which keeps the execution pipeline uniform and makes the rule easy to
|
||||
soften later — a scalar between zero and one is already expressible.
|
||||
|
||||
---
|
||||
|
||||
## 10. Presentation as named parameters
|
||||
|
||||
The display asset is a bag of named material parameters — scalars, colours,
|
||||
textures, a short name — not a set of assets **[measured]**. Materials are
|
||||
authored to expect the names, so adding a team costs one asset rather than a
|
||||
duplicated material tree.
|
||||
|
||||
Two defects in the applying code, both narrow:
|
||||
|
||||
- **Alpha is dropped** by the material paths, which convert a four-channel colour
|
||||
to a three-element vector, while the effects path preserves all four
|
||||
**[measured]**. The same asset therefore behaves differently in two consumers.
|
||||
Recipe: CT-09.
|
||||
- **A viewer identity parameter is accepted and ignored**, with a comment saying
|
||||
so **[measured]**. The signature and header describe viewer-relative colouring;
|
||||
the implementation returns the absolute asset. Recipe: CT-10.
|
||||
|
||||
Reactive binding is done well: an observation action broadcasts **immediately on
|
||||
activation** so a late subscriber gets current state rather than waiting for the
|
||||
next change, and a second action re-subscribes correctly when the observed team
|
||||
changes **[measured]**. **[derived]** Both are small patterns that eliminate a
|
||||
race, and both are reusable anywhere state is observed through a key that can
|
||||
itself change.
|
||||
|
||||
---
|
||||
|
||||
## 11. What to copy, in order
|
||||
|
||||
1. **One policy function for authority and prediction** (§9). Highest value,
|
||||
lowest cost, removes a whole bug class.
|
||||
2. **Loud mirrors** (§7). One log line per setter.
|
||||
3. **Controller-owned intent, pawn-owned realization** (§1), including the
|
||||
already-possessed case.
|
||||
4. **Non-replicated fields inside a replicated entry** (§2) — one type instead of
|
||||
two.
|
||||
5. **Rules-plus-default selection** (§4), with a shadowing check added.
|
||||
6. **Immediate first broadcast on subscription** (§10).
|
||||
|
||||
What to fix while copying: the indeterminate branch (CT-08), the friendly-fire
|
||||
gap (CT-07), the alpha conversion (CT-09), the ignored viewer parameter (CT-10),
|
||||
and the privacy that is a name rather than a mechanism (CT-11).
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
Measured against Epic's Lyra Starter Game on Unreal Engine 5.6: roughly 1300
|
||||
lines of cosmetics across 11 files and 1800 lines of teams across 22, read as
|
||||
source in a single workspace. Source addresses stay in the research archive that
|
||||
produced this skill; each `CT-` identifier resolves back to the audited location
|
||||
there.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
Four wiring call sites are in Blueprint or data assets and were not read; they are
|
||||
marked open above and nothing here depends on them. Everything else describes one
|
||||
project on one engine version. Re-run the recipes against your own tree before
|
||||
trusting any specific claim.
|
||||
Reference in New Issue
Block a user