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,435 @@
|
||||
---
|
||||
name: ue-gameplay-messaging
|
||||
description: >-
|
||||
Design, implement or review a typed tag-addressed pub/sub message bus in
|
||||
Unreal Engine: subsystem scope and lifetime, struct type checks, exact versus
|
||||
hierarchical channel matching, listener handles and ownership, parity between
|
||||
the C++ and Blueprint APIs, the local-versus-network boundary, and migration
|
||||
from direct dependencies. Use when decoupling gameplay from UI, feedback,
|
||||
telemetry or processors, or when debugging duplicate, stale, mismatched or
|
||||
wrongly replicated messages.
|
||||
---
|
||||
|
||||
# UE gameplay messaging
|
||||
|
||||
The invariant:
|
||||
|
||||
> Messages announce facts inside one process. They do not own state, ordering,
|
||||
> persistence, authority or replication.
|
||||
|
||||
Measured patterns from a reference product: [patterns](references/patterns.md).
|
||||
Seventeen detection recipes for silent bus failures:
|
||||
[failure modes](references/failure-modes.md).
|
||||
|
||||
Read the failure modes before adopting a bus implementation. Each entry names the
|
||||
mechanism, why it stays silent, why the obvious check misses it, and a command you
|
||||
can run against your own tree.
|
||||
|
||||
Related skills: `ue-gameplay-tag-governance`, `ue-ui-architecture`,
|
||||
`ue-gas-architecture`, `ue-multiplayer-authority`, `ue-data-driven-architecture`.
|
||||
|
||||
Method, not architecture — how to check any claim in this bundle before repeating it: `ue-evidence-discipline`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Choose the bus scope explicitly
|
||||
|
||||
A bus implemented as a GameInstance subsystem:
|
||||
|
||||
- is shared by every world belonging to that GameInstance;
|
||||
- survives map travel;
|
||||
- is destroyed at GameInstance teardown;
|
||||
- is local to one process;
|
||||
- does **not** replicate.
|
||||
|
||||
That scope suits:
|
||||
|
||||
- gameplay event → HUD and feedback;
|
||||
- local processors and telemetry;
|
||||
- decoupling feature plugins from the base module;
|
||||
- cross-system notifications where no receiver owns the sender.
|
||||
|
||||
It is unsuitable as the source of truth for:
|
||||
|
||||
- inventory, team or score state;
|
||||
- guaranteed ordered workflows;
|
||||
- cross-process communication;
|
||||
- persistence;
|
||||
- anything an RPC should carry.
|
||||
|
||||
### The state-versus-event test
|
||||
|
||||
Ask: **"if a listener subscribes one second late, must it know the current
|
||||
value?"**
|
||||
|
||||
- **yes** → replicated property or state component, plus a change notification;
|
||||
- **no** → a transient message may be appropriate.
|
||||
|
||||
"An elimination occurred" is an event. "The current team score" is state. A bus
|
||||
that is asked to answer the second question will appear to work for every
|
||||
listener that happened to be subscribed early, which is every listener during
|
||||
development.
|
||||
|
||||
---
|
||||
|
||||
## 2. Storage model and handle identity
|
||||
|
||||
```text
|
||||
map: channel tag -> channel listener list
|
||||
|
||||
channel listener list
|
||||
├─ monotonically increasing handle ID (local to THIS channel)
|
||||
└─ listeners[]
|
||||
├─ callback
|
||||
├─ weak expected struct type
|
||||
├─ had-valid-type flag
|
||||
├─ handle ID
|
||||
└─ exact / partial match
|
||||
```
|
||||
|
||||
Registration returns a handle carrying the subsystem, the channel, and the
|
||||
channel-local ID. **The handle is the ownership receipt.** The caller, or an RAII
|
||||
wrapper, retains it and unregisters when its lifecycle ends.
|
||||
|
||||
### The handle identity rule
|
||||
|
||||
If IDs are allocated per channel, then **every internal removal must use both the
|
||||
registration channel and the ID.** Never search another channel by ID alone.
|
||||
|
||||
This is not a stylistic point. A hierarchical broadcast walks several channels in
|
||||
one call; code written inside that walk has two plausible variables in scope — the
|
||||
channel the message was sent on, and the channel currently being visited — and
|
||||
they are the same value only on the first iteration. Recipe: MB-01.
|
||||
|
||||
---
|
||||
|
||||
## 3. Typed broadcast
|
||||
|
||||
Template the C++ API and resolve the struct type at compile time on both sides.
|
||||
Store the expected type at registration.
|
||||
|
||||
On broadcast, the payload is deliverable when:
|
||||
|
||||
```text
|
||||
broadcast struct IsChildOf listener expected struct
|
||||
```
|
||||
|
||||
which permits a listener registered for a base struct to receive derived
|
||||
payloads, and makes exact equality a special case.
|
||||
|
||||
### Type mismatch policy
|
||||
|
||||
- log the channel, the sent struct and the expected struct;
|
||||
- skip that listener;
|
||||
- never reinterpret incompatible bytes;
|
||||
- never abort the rest of the broadcast;
|
||||
- add development-only assertions and tests.
|
||||
|
||||
A path that disables the type check entirely — registering with a null expected
|
||||
type — must be private and documented. If it is reachable from Blueprint through
|
||||
an empty type pin, it is not private. Recipe: MB-03.
|
||||
|
||||
### Payload lifetime
|
||||
|
||||
The bus should pass a pointer to the caller's stack object rather than copying.
|
||||
That is the right performance decision and it creates one hard rule:
|
||||
**a listener may not retain the payload pointer after its callback returns.**
|
||||
Copy what you need. Recipe: MB-04.
|
||||
|
||||
---
|
||||
|
||||
## 4. Exact versus hierarchical matching
|
||||
|
||||
Channels are tags, so a broadcast can walk from the sent channel up through its
|
||||
parents:
|
||||
|
||||
```text
|
||||
Event.Combat.Elimination
|
||||
→ Event.Combat
|
||||
→ Event
|
||||
```
|
||||
|
||||
On the first tag every listener is called regardless of match type; on ancestors,
|
||||
only listeners that registered for partial matching.
|
||||
|
||||
**Default to exact.** It minimises accidental traffic and type ambiguity. Use
|
||||
partial matching for analytics, debug logging, and aggregators that genuinely
|
||||
want a subtree.
|
||||
|
||||
### The hierarchy type gate
|
||||
|
||||
Every descendant channel observed through one parent must share a compatible base
|
||||
payload struct. Otherwise partial matching converts a semantic hierarchy into a
|
||||
stream of type errors. Document the payload contract at each parent tag — this is
|
||||
the same rule as parent-tag contracts in `ue-gameplay-tag-governance`, applied to
|
||||
payload types instead of semantics.
|
||||
|
||||
### Cost
|
||||
|
||||
Walking parents costs one map lookup per tag depth on **every** broadcast, even
|
||||
when no partial listeners exist anywhere. If the listener array is copied per
|
||||
non-empty bucket for mutation safety, that copy includes a callable object per
|
||||
listener, which may allocate. On a high-frequency channel this is measurable.
|
||||
Recipe: MB-16.
|
||||
|
||||
---
|
||||
|
||||
## 5. Broadcast safely under mutation
|
||||
|
||||
Callbacks may unregister themselves or others. Copy the listener array before
|
||||
iterating, then invoke the copy — and then **define the resulting semantics
|
||||
explicitly**, because they are observable:
|
||||
|
||||
- a listener removed during this broadcast may still receive this one message;
|
||||
- a listener added during this broadcast receives from the next one;
|
||||
- ordering is not guaranteed and must not be assumed.
|
||||
|
||||
Do not let registration order become an accidental priority contract. If removal
|
||||
uses a swap-with-last strategy, order changes as a side effect of unrelated
|
||||
unsubscribes. Recipe: MB-11.
|
||||
|
||||
---
|
||||
|
||||
## 6. Registration forms and ownership
|
||||
|
||||
Provide three forms: a raw callable, a UObject member bound through a weak
|
||||
reference, and a parameter struct for match type and future options.
|
||||
|
||||
The trap is that these three are not equally expressive. If the most convenient
|
||||
form — the UObject member overload — cannot express the match type, then the
|
||||
match type is effectively unavailable to the code that would most benefit from
|
||||
it, and a project can ship a fully implemented hierarchical matching feature that
|
||||
nothing uses. Recipe: MB-05.
|
||||
|
||||
### Ownership patterns
|
||||
|
||||
| Owner | Retain | Release |
|
||||
|---|---|---|
|
||||
| C++ component | handle as a member | `EndPlay` / deinit |
|
||||
| subsystem | handles in a container | `Deinitialize` |
|
||||
| feature action | per activation context | on deactivation |
|
||||
| async operation | inside the action object | completion, cancel or destruction |
|
||||
|
||||
**A weak callback is not an unsubscription.** It prevents a call into a dead
|
||||
object; the listener record and its callable stay in the map, and accumulate.
|
||||
Recipe: MB-08.
|
||||
|
||||
### Teardown must not assert
|
||||
|
||||
An accessor that asserts when there is no world is the wrong tool inside
|
||||
`EndPlay`, `NativeDestruct` or any other teardown path, where the world may
|
||||
already be gone. Prefer unregistering through the handle, which resolves the
|
||||
subsystem weakly and no-ops when it is dead. Recipe: MB-09.
|
||||
|
||||
---
|
||||
|
||||
## 7. Blueprint parity
|
||||
|
||||
A wildcard struct pin lets Blueprint send arbitrary payloads while C++ receives a
|
||||
type plus bytes. An async listen node stores the channel, payload type and match
|
||||
type, registers on activation, copies the payload into typed output storage, and
|
||||
unregisters on cancel or destruction.
|
||||
|
||||
### Keep the two APIs semantically identical
|
||||
|
||||
This is the rule most often broken, because the two paths are written at
|
||||
different times by different people:
|
||||
|
||||
- if C++ accepts derived payloads, Blueprint must too;
|
||||
- if C++ logs a mismatch, Blueprint must log it as well.
|
||||
|
||||
A silent Blueprint skip produces "the node never fires", which every developer
|
||||
first investigates as a wrong tag. Pick one contract, document it, and test both
|
||||
APIs against the same matrix. Recipe: MB-02.
|
||||
|
||||
---
|
||||
|
||||
## 8. Messaging is not replication
|
||||
|
||||
No broadcast crosses the network. For networked facts the shape is always:
|
||||
|
||||
```text
|
||||
server-authoritative state change
|
||||
→ replicated property / RPC / replicated fast array
|
||||
→ receiving side broadcasts a LOCAL message
|
||||
→ local UI, feedback and processors react
|
||||
```
|
||||
|
||||
This keeps transport and local fan-out separate, and it is the only arrangement
|
||||
in which a late joiner is correct: the state replicates, and the message is
|
||||
merely a notification that it changed.
|
||||
|
||||
**Never treat a client-local message as proof of a server-side fact.** A message
|
||||
may request UI behaviour. It is not evidence of damage, score, ownership or team
|
||||
membership. Recipe: MB-15.
|
||||
|
||||
---
|
||||
|
||||
## 9. Payload design
|
||||
|
||||
A message struct is a compact immutable fact:
|
||||
|
||||
```text
|
||||
verb / channel context
|
||||
instigator
|
||||
target
|
||||
magnitude
|
||||
context tags
|
||||
optional IDs or weak references
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- prefer IDs and weak references over large object graphs;
|
||||
- carry enough context that a listener needs no callback into the sender;
|
||||
- do not embed mutable ownership;
|
||||
- initialise every field — a payload never passes through serialisation, so
|
||||
there are no free defaults;
|
||||
- version deliberately if the same struct is persisted or replicated elsewhere;
|
||||
- one parent channel hierarchy shares one base payload.
|
||||
|
||||
### Do not send commands disguised as events
|
||||
|
||||
```text
|
||||
bad: message "please give the player a weapon"
|
||||
good: service call performs the grant; message "weapon equipped"
|
||||
```
|
||||
|
||||
A command has exactly one accountable handler and a failure result. An event has
|
||||
zero or many listeners and no failure path at all. Sending a command as an event
|
||||
means nobody is responsible for it happening, and nobody notices when it does
|
||||
not. Recipe: MB-14.
|
||||
|
||||
---
|
||||
|
||||
## 10. Channel naming and the string contract
|
||||
|
||||
Name channels so bus traffic is distinguishable from other tags:
|
||||
|
||||
```text
|
||||
<System>.<Domain>.Message[.<Detail>]
|
||||
```
|
||||
|
||||
The word in the middle separates channels from the rest of the vocabulary and
|
||||
gives a natural place for a partial-match subscription.
|
||||
|
||||
**The channel string is the public contract, and the compiler does not check it.**
|
||||
If publisher and subscribers each declare their own file-local tag constant for
|
||||
the same string, the engine deduplicates by string and everything works — until
|
||||
one of them is edited. Put shared channels in one header owned by the publisher.
|
||||
Recipe: MB-10.
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration recipe
|
||||
|
||||
When decoupling a direct dependency:
|
||||
|
||||
1. Name the fact in the past tense.
|
||||
2. Define its payload struct and channel tag.
|
||||
3. Keep authoritative mutation in the existing owner.
|
||||
4. Broadcast only **after** the mutation succeeds.
|
||||
5. Move independent side effects to listeners, one at a time.
|
||||
6. Retain and release every handle.
|
||||
7. Test zero, one and multiple listeners.
|
||||
8. Verify no late subscriber needs the state it missed.
|
||||
|
||||
Good first migrations: combat event to HUD feed and audio; inventory delta to a
|
||||
notification widget; phase transition to local presentation.
|
||||
|
||||
Do not migrate tight request/response code merely to avoid a function reference.
|
||||
|
||||
---
|
||||
|
||||
## 12. Review checklist
|
||||
|
||||
- [ ] Message is an event, not durable state and not a command.
|
||||
- [ ] Subsystem scope matches the required lifetime.
|
||||
- [ ] Payload is a struct with a documented contract and initialised fields.
|
||||
- [ ] Parent channels have a compatible payload hierarchy.
|
||||
- [ ] Exact matching is the default; partial matching is justified per site.
|
||||
- [ ] Registration returns a handle and the caller retains it.
|
||||
- [ ] UObject callbacks bind weakly.
|
||||
- [ ] Unregistration happens on every lifecycle exit, including teardown paths.
|
||||
- [ ] Teardown does not call an asserting accessor.
|
||||
- [ ] Broadcast tolerates mutation from inside a callback.
|
||||
- [ ] C++ and Blueprint type semantics are identical and both log mismatches.
|
||||
- [ ] No listener ordering is assumed without an explicit contract.
|
||||
- [ ] Replication and RPC transport are separate from local fan-out.
|
||||
- [ ] Shared channel tags live in one owned header.
|
||||
|
||||
---
|
||||
|
||||
## 13. Tests
|
||||
|
||||
### Type matrix
|
||||
|
||||
- exact same struct;
|
||||
- derived broadcast to base listener;
|
||||
- base broadcast to derived listener — rejected;
|
||||
- unrelated struct — logged and skipped;
|
||||
- expired expected struct type;
|
||||
- C++ and Blueprint produce the same result for every row above.
|
||||
|
||||
### Channel matrix
|
||||
|
||||
- exact listener, exact broadcast;
|
||||
- parent with partial matching receives a child broadcast;
|
||||
- parent with exact matching does not;
|
||||
- sibling does not;
|
||||
- a nested parent chain invokes each intended listener exactly once.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- unregister before broadcast;
|
||||
- unregister from inside a callback;
|
||||
- owner destroyed without an explicit unregister;
|
||||
- async node cancelled;
|
||||
- map travel with a GameInstance-scoped bus — listener count returns to baseline;
|
||||
- feature activation and deactivation cycle, twice;
|
||||
- duplicate registration is either documented or detected.
|
||||
|
||||
### Network boundary
|
||||
|
||||
- a server-side broadcast is invisible to clients;
|
||||
- replicated transport triggers exactly one client-local rebroadcast;
|
||||
- a listen-server process does not process both the server and client path.
|
||||
|
||||
---
|
||||
|
||||
## 14. When not to add a bus
|
||||
|
||||
Use a direct interface, service or delegate when:
|
||||
|
||||
- exactly one receiver must handle the call;
|
||||
- the caller needs a return value or a failure reason;
|
||||
- ordering is part of correctness;
|
||||
- sender and receiver share ownership and lifetime;
|
||||
- the state must be queryable after the event.
|
||||
|
||||
A bus reduces coupling only when the fact genuinely has independent, optional
|
||||
observers. Used anywhere else it hides control flow and weakens correctness —
|
||||
it converts a compile-time dependency into a runtime one that nothing verifies.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
The findings behind the failure modes come from a line-by-line source audit of
|
||||
the gameplay message router plugin shipped with Epic's Lyra Starter Game on
|
||||
Unreal Engine 5.6, together with its use in that project. The plugin's runtime
|
||||
module is roughly a thousand lines and was read in full, which is why several
|
||||
claims here are negative results ("there is no networking in this module") stated
|
||||
with confidence rather than hedged.
|
||||
|
||||
Source addresses stay in the research archive that produced this skill; what
|
||||
ships is the detection recipe. Each entry carries a stable identifier (`MB-01`,
|
||||
`MB-02`, …) that resolves back to the audited location in that archive.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
The measured material refers to one plugin at one engine version in one
|
||||
workspace. Blueprint graphs were not read — they are binary — so every statement
|
||||
about Blueprint *usage* in that project is bounded by what C++ could show, and is
|
||||
marked as open rather than concluded. Re-run the recipes against your own tree
|
||||
before trusting any specific claim.
|
||||
@@ -0,0 +1,646 @@
|
||||
# Failure modes: gameplay messaging
|
||||
|
||||
Seventeen ways a tag-addressed message bus stops delivering, over-delivers, or
|
||||
delivers into a void, without reporting anything useful.
|
||||
|
||||
The shared property of this list: **a bus converts a compile-time dependency into
|
||||
a runtime one, and then does not check it.** The publisher compiles whether or not
|
||||
anyone listens; the listener compiles whether or not anyone publishes. Every
|
||||
entry below is a consequence of that trade, and the observable is almost always
|
||||
the same — nothing happens, or something happens twice.
|
||||
|
||||
Recipes use `rg`. Two conventions worth stating once, because both cost real time
|
||||
during the audit that produced this file:
|
||||
|
||||
- **Restrict searches to source globs.** An unrestricted search of a built tree
|
||||
matches compiled debug symbols, which are not code. In the measured reference a
|
||||
search for a suspected dead field returned four hits; three were `.pdb` files
|
||||
and the source truth was one. A count that includes build output is not a count
|
||||
of your code.
|
||||
- **Most recipes are a pair of searches.** The defect class here is "the thing is
|
||||
present and the thing that would give it meaning is absent", and one search can
|
||||
only show the first half.
|
||||
|
||||
---
|
||||
|
||||
## Delivery correctness
|
||||
|
||||
### MB-01 - Listener removed using the wrong channel key
|
||||
|
||||
**Mechanism.** A hierarchical broadcast walks from the sent channel up through its
|
||||
parents. Inside that walk, code that removes a listener addresses the removal by
|
||||
the *original broadcast channel* rather than the ancestor tag currently being
|
||||
visited. Handle IDs are allocated per channel, so the pair (channel, ID) is the
|
||||
only unique identity.
|
||||
|
||||
**Why it is silent.** On the first iteration of the walk the two variables hold
|
||||
the same value, so exact-match listeners — which is nearly all of them in most
|
||||
projects — are removed correctly. The bug requires a listener registered on a
|
||||
parent with partial matching *and* an expired payload type, simultaneously. Both
|
||||
conditions are rare, and neither is an error on its own.
|
||||
|
||||
**Why the obvious check misses it.** The line reads correctly in isolation:
|
||||
removing by "channel" is obviously right, and the variable is named `Channel`.
|
||||
The defect is that the enclosing loop rebinds the meaning of "the channel we are
|
||||
talking about" on every iteration, and the correct variable is three lines up in
|
||||
the `for` header. Worse, the error-log line a few lines below uses the loop
|
||||
variable correctly — so the file contains both the right and the wrong idiom, and
|
||||
the wrong one is the one that mutates state.
|
||||
|
||||
**Symptom.** Two outcomes, both bad and neither loud. Either the stale listener is
|
||||
never removed and a warning is emitted on every subsequent broadcast forever, or —
|
||||
worse — an unrelated listener that happens to hold the same channel-local ID in
|
||||
the broadcast channel's bucket is removed instead, and its owner silently stops
|
||||
receiving messages.
|
||||
|
||||
**Detect.** Compare the loop variable against the key used for removal:
|
||||
|
||||
```bash
|
||||
rg -n -A12 "for\s*\(.*RequestDirectParent" --glob "*.cpp" . \
|
||||
| rg "UnregisterListenerInternal|Remove\("
|
||||
```
|
||||
|
||||
Read the output next to the `for` header. If the removal uses the loop's starting
|
||||
value rather than the loop variable, that is this defect. In the measured
|
||||
reference the walk iterated over an ancestor tag while removal passed the
|
||||
broadcast channel.
|
||||
|
||||
**Guardrail.** When handle IDs are channel-local, make the removal API take the
|
||||
pair and nothing else, and assert inside it that the pair was found. A removal
|
||||
that silently finds nothing is how this defect stays invisible.
|
||||
|
||||
---
|
||||
|
||||
### MB-02 - Covariance in C++, equality in Blueprint
|
||||
|
||||
**Mechanism.** The C++ delivery path accepts a payload whose struct derives from
|
||||
the listener's expected struct. The Blueprint async listener compares types with
|
||||
strict equality.
|
||||
|
||||
**Why it is silent.** Both behaviours are individually reasonable and both are
|
||||
implemented correctly. A Blueprint listener that does not match simply does not
|
||||
fire, and not firing is the normal state of a listener most of the time.
|
||||
|
||||
**Why the obvious check misses it.** The two implementations live in different
|
||||
files, written for different consumers, and each is correct against its own local
|
||||
reasoning. There is no shared test matrix, because the two APIs are usually tested
|
||||
by different people — the C++ one by whoever wrote the bus, the Blueprint one by
|
||||
whoever first used the node. Nothing in either file mentions the other.
|
||||
|
||||
**Symptom.** A Blueprint listener subscribed to a base struct never fires for
|
||||
derived payloads, while a C++ listener on the same base receives them. The
|
||||
designer reports "the node never fires", which everyone investigates as a wrong
|
||||
tag — the one hypothesis that is cheap to test and wrong.
|
||||
|
||||
**Detect.** Find both type gates and compare the operators:
|
||||
|
||||
```bash
|
||||
rg -n "IsChildOf" --glob "*.cpp" . # covariant gate
|
||||
rg -n "StructType\s*==|== \w*StructType" --glob "*.cpp" . # equality gate
|
||||
```
|
||||
|
||||
Two different operators guarding the same conceptual check, in the same plugin,
|
||||
is the finding. In the measured reference the C++ path used covariance and the
|
||||
async Blueprint path used equality, on the same registration.
|
||||
|
||||
**Guardrail.** Pick one contract, write it down, and run one test matrix through
|
||||
both APIs. If they must differ, the difference belongs in the node's tooltip, not
|
||||
only in the source.
|
||||
|
||||
---
|
||||
|
||||
### MB-03 - Type check disabled by an empty type pin
|
||||
|
||||
**Mechanism.** Registering with a null expected struct type sets a flag that
|
||||
disables the type check entirely, so the listener receives any payload. The path
|
||||
is documented as internal — and is reachable from a Blueprint node whose payload
|
||||
type pin was left empty.
|
||||
|
||||
**Why it is silent.** Nothing fails. The listener receives messages, possibly
|
||||
several unrelated kinds, and the Blueprint graph downstream either uses the
|
||||
payload or does not. There is no log, because from the bus's point of view the
|
||||
listener asked for exactly this.
|
||||
|
||||
**Why the obvious check misses it.** The comment on the branch says "for internal
|
||||
use", and reviewers believe comments about intent. Confirming reachability means
|
||||
tracing a Blueprint pin's default value into a factory function into a private
|
||||
registration call — three hops across two modules, none of which look
|
||||
interesting.
|
||||
|
||||
**Symptom.** In Blueprint, usually nothing worse than a payload extraction that
|
||||
returns false. In C++, the same path combined with a templated callback would
|
||||
reinterpret unrelated bytes as the expected struct, which is undefined behaviour
|
||||
with no diagnostic.
|
||||
|
||||
**Detect.** Find the escape hatch and then find who can reach it:
|
||||
|
||||
```bash
|
||||
rg -n "bHadValidType|StructType\s*==\s*nullptr" --glob "*.cpp" --glob "*.h" .
|
||||
rg -n -B4 "RegisterListenerInternal\(" --glob "*.cpp" . | rg -i "Get\(\)|nullptr"
|
||||
```
|
||||
|
||||
Any caller that can pass a null type is a caller that can disable the check.
|
||||
|
||||
**Guardrail.** If a code path must exist for internal reasons, make it
|
||||
inaccessible rather than merely undocumented — a private overload, a passkey
|
||||
type, or a compile-time gate. Reject an unresolved wildcard payload at Blueprint
|
||||
compile time.
|
||||
|
||||
---
|
||||
|
||||
### MB-04 - Payload pointer retained after the callback returns
|
||||
|
||||
**Mechanism.** The bus passes a pointer to the sender's stack object rather than
|
||||
copying. A listener stores that pointer.
|
||||
|
||||
**Why it is silent.** The memory is still mapped and, for a short while, still
|
||||
holds the right bytes. Reading it immediately after the broadcast usually works.
|
||||
The failure requires the stack to be reused, which depends on what runs next.
|
||||
|
||||
**Why the obvious check misses it.** The callback signature hands you a reference,
|
||||
and references are normally safe to keep. Nothing in the type expresses "valid
|
||||
until this function returns". The one place the constraint is written down is the
|
||||
bus's own documentation, which the person writing the listener has no reason to
|
||||
open.
|
||||
|
||||
**Symptom.** Corrupted payload fields read at a later tick — values that are
|
||||
plausible, occasionally correct, and change with unrelated code. This is one of
|
||||
the few entries in this file that produces a crash, and the crash is far from the
|
||||
cause.
|
||||
|
||||
**Detect.** Find listeners that store rather than consume:
|
||||
|
||||
```bash
|
||||
rg -n -A8 "void .*\(FGameplayTag\s+\w+,\s*const\s+F\w+&\s*\w+\)" --glob "*.cpp" . \
|
||||
| rg "=\s*&|Ptr\s*=|AddRaw|CopyRef"
|
||||
```
|
||||
|
||||
Any assignment of the payload's address into a member is the finding. A copy of
|
||||
the payload by value is fine.
|
||||
|
||||
**Guardrail.** State the lifetime in the callback's own documentation, and copy at
|
||||
the boundary. Where the bus crosses into a scripting layer, copy by value — the
|
||||
measured reference does exactly this for its Blueprint path, and nulls the stored
|
||||
pointer immediately after the delegate returns.
|
||||
|
||||
---
|
||||
|
||||
## Ownership and lifetime
|
||||
|
||||
### MB-05 - The convenient overload cannot express the match type
|
||||
|
||||
**Mechanism.** Three registration overloads exist. The one that binds a UObject
|
||||
member weakly — the safest and by far the most used — delegates to the raw form
|
||||
without forwarding the match type, so it is always exact.
|
||||
|
||||
**Why it is silent.** Exact matching is the right default, so every listener
|
||||
registered this way behaves correctly. The feature that is unavailable is one
|
||||
nobody is currently trying to use, and its absence produces no error because you
|
||||
cannot pass the argument at all.
|
||||
|
||||
**Why the obvious check misses it.** The feature is fully implemented, documented
|
||||
in the enum, exposed in the Blueprint node, and covered by the broadcast walk. A
|
||||
review that asks "do we support hierarchical matching?" finds all of that and
|
||||
answers yes. The gap is one unforwarded default argument in a header.
|
||||
|
||||
**Symptom.** A project ships a complete hierarchical matching implementation that
|
||||
nothing in its own C++ ever uses, and nobody notices, because using it requires
|
||||
switching to a less convenient overload for reasons that are never stated.
|
||||
|
||||
**Detect.** Count the feature's declaration sites against its use sites, and
|
||||
exclude the implementation itself:
|
||||
|
||||
```bash
|
||||
rg -n "PartialMatch" --glob "*.h" --glob "*.cpp" . # declared
|
||||
rg -n "PartialMatch" --glob "*.cpp" . | rg -v "MessageRouter|MessageSubsystem"
|
||||
```
|
||||
|
||||
An empty second result with a rich first result is the finding. In the measured
|
||||
reference the game code used partial matching zero times; the only similarly
|
||||
named symbols elsewhere belonged to two unrelated subsystems that had copied the
|
||||
pattern.
|
||||
|
||||
**Guardrail.** Every overload of a registration API forwards every option, or the
|
||||
option does not exist. If an overload deliberately restricts, name it so —
|
||||
`RegisterExactListener` — rather than silently dropping an argument.
|
||||
|
||||
---
|
||||
|
||||
### MB-06 - Parameter struct with no bound callback returns an invalid handle silently
|
||||
|
||||
**Mechanism.** The options-struct registration form checks that a callback is
|
||||
bound and, if not, returns a default-constructed invalid handle without
|
||||
registering and without logging.
|
||||
|
||||
**Why it is silent.** The caller receives a handle-shaped value. Storing it,
|
||||
passing it around and eventually unregistering it all work. The only difference
|
||||
is that nothing was ever registered.
|
||||
|
||||
**Why the obvious check misses it.** The return type is not optional and not an
|
||||
error code; it is a handle, and handles look like success. Checking validity
|
||||
requires knowing that this overload can fail, which is stated nowhere at the call
|
||||
site.
|
||||
|
||||
**Symptom.** A listener that never receives anything, in code that reads as fully
|
||||
wired. Because unregistration of an invalid handle is also silent, no part of the
|
||||
lifecycle complains.
|
||||
|
||||
**Detect.** Find registrations whose returned handle is never validity-checked:
|
||||
|
||||
```bash
|
||||
rg -n "RegisterListener\(" --glob "*.cpp" . -A3 | rg -v "IsValid|ensure|check"
|
||||
```
|
||||
|
||||
**Guardrail.** Return an explicit failure, or log at warning level, or assert in
|
||||
development builds. A silent failure path on a registration API is a listener
|
||||
that cannot be debugged.
|
||||
|
||||
---
|
||||
|
||||
### MB-07 - Async listener outlives its owner until the next message
|
||||
|
||||
**Mechanism.** An async listen action detects its owner's death by noticing that
|
||||
its dynamic delegate is no longer bound — which it can only do *while handling a
|
||||
message*. Until the next message arrives, the action and its registration stay
|
||||
alive.
|
||||
|
||||
**Why it is silent.** The cleanup does eventually happen, and it happens before
|
||||
anything observable goes wrong. On a busy channel the window is a frame.
|
||||
|
||||
**Why the obvious check misses it.** There *is* cleanup, and reading it shows
|
||||
correct logic. The gap is a timing property — cleanup is driven by traffic rather
|
||||
than by the owner's destruction — and timing properties are invisible in a code
|
||||
review that asks "is cleanup implemented?".
|
||||
|
||||
**Symptom.** On a quiet channel, listener records accumulate for destroyed
|
||||
owners. On a shutdown path, the last message of a session may be delivered into
|
||||
an object graph that is already tearing down.
|
||||
|
||||
**Detect.** Find cleanup that is triggered by message handling rather than by
|
||||
destruction:
|
||||
|
||||
```bash
|
||||
rg -n -B6 "SetReadyToDestroy" --glob "*.cpp" . | rg -i "IsBound|HandleMessage"
|
||||
rg -n -i "//\s*@?TODO.*(proactive|cleanup)" --glob "*.cpp" .
|
||||
```
|
||||
|
||||
The second command matters: in the measured reference the authors had recorded
|
||||
exactly this limitation as a TODO with a tracker reference, which is the
|
||||
strongest possible confirmation that a finding is real and known.
|
||||
|
||||
**Guardrail.** Tie the async action's lifetime to its owner's destruction
|
||||
directly, not to the arrival of the next message.
|
||||
|
||||
---
|
||||
|
||||
### MB-08 - Listener map is not cleared on map travel
|
||||
|
||||
**Mechanism.** A GameInstance-scoped bus is cleared only at GameInstance
|
||||
teardown. Listeners registered by objects belonging to a world that has been
|
||||
travelled away from remain in the map.
|
||||
|
||||
**Why it is silent.** Weak binding means the dead objects are never called. The
|
||||
records are inert: they cost memory and a little iteration time, and produce no
|
||||
wrong behaviour.
|
||||
|
||||
**Why the obvious check misses it.** Deinitialisation exists and is correct for
|
||||
the scope it was written for. "Does the bus clean up?" is answered yes. The
|
||||
question that finds this is narrower — *at which lifecycle event?* — and the
|
||||
answer is one that only matters in a project that travels between maps, which a
|
||||
single-map test never does.
|
||||
|
||||
**Symptom.** Listener counts that grow monotonically across a session of map
|
||||
changes. Diagnosis is difficult because the leak is bounded by session length and
|
||||
the objects themselves are correctly garbage collected.
|
||||
|
||||
**Detect.** Find the scope and the reset point, and compare them:
|
||||
|
||||
```bash
|
||||
rg -n "public UGameInstanceSubsystem|public UWorldSubsystem" --glob "*.h" .
|
||||
rg -n -A4 "::Deinitialize\(\)" --glob "*.cpp" . | rg "Reset\(|Empty\(|Clear\("
|
||||
```
|
||||
|
||||
A GameInstance scope whose only reset is in `Deinitialize` leaks across travel by
|
||||
construction. Confirm at runtime: log the listener count, travel, log again.
|
||||
|
||||
**Guardrail.** Either scope the bus to the world, or hook map change and drop
|
||||
records whose weak owner has expired. A weak callback is not an unsubscription.
|
||||
|
||||
---
|
||||
|
||||
### MB-09 - Teardown calls an accessor that asserts
|
||||
|
||||
**Mechanism.** The static accessor resolves the world with an assert-on-failure
|
||||
mode and then asserts on both the world and the subsystem. Teardown code calls it
|
||||
to unregister.
|
||||
|
||||
**Why it is silent.** In every ordinary shutdown the world is still present when
|
||||
components tear down, so the asserts pass. The failure needs an unusual teardown
|
||||
order — a subsystem destroyed first, PIE cancelled mid-initialisation, a level
|
||||
unloaded during travel.
|
||||
|
||||
**Why the obvious check misses it.** Unregistering in `EndPlay` is the correct
|
||||
pattern and is what a reviewer is looking for. That the *accessor* is the
|
||||
strictest of the several available ways to reach the subsystem is a property of a
|
||||
different file.
|
||||
|
||||
**Symptom.** An assert during shutdown, in a build configuration and teardown
|
||||
order that nobody can reproduce on demand, at a call site that is doing the right
|
||||
thing.
|
||||
|
||||
**Detect.** Find teardown paths that use the asserting accessor:
|
||||
|
||||
```bash
|
||||
rg -n -B2 -A2 "::EndPlay|NativeDestruct|BeginDestroy" --glob "*.cpp" . \
|
||||
| rg "Subsystem::Get\(|::Get\(this\)"
|
||||
rg -n -A6 "static .*& Get\(" --glob "*.h" . | rg "Assert|check\("
|
||||
```
|
||||
|
||||
The pairing is the finding: an asserting accessor called from a destruction path.
|
||||
In the measured reference this pattern appeared in three separate consumer
|
||||
classes, all of which could have unregistered through the handle instead.
|
||||
|
||||
**Guardrail.** Unregister through the handle, which resolves the subsystem weakly
|
||||
and no-ops when it is gone. Provide a non-asserting accessor and use it in every
|
||||
teardown path.
|
||||
|
||||
---
|
||||
|
||||
## Contract and vocabulary
|
||||
|
||||
### MB-10 - The channel string is the contract, declared in several places
|
||||
|
||||
**Mechanism.** Publisher and each subscriber declare their own file-local tag
|
||||
constant for the same channel string. The engine deduplicates by string, so it
|
||||
works.
|
||||
|
||||
**Why it is silent.** It is correct. Every site resolves to the same tag, the join
|
||||
succeeds, and the arrangement has a real benefit: modules do not depend on each
|
||||
other's headers.
|
||||
|
||||
**Why the obvious check misses it.** Each declaration is locally idiomatic and
|
||||
each file reads well. Nothing links them. A rename in one file compiles, links,
|
||||
and passes review — and the reviewer of that file has no way to see the other
|
||||
three.
|
||||
|
||||
**Symptom.** Editing one declaration silently detaches one participant from the
|
||||
channel. This is the symmetric-typo failure from tag governance, arriving through
|
||||
a different door: correctness means all sites agree, not that any one is right.
|
||||
|
||||
**Detect.** Count independent declarations per channel string:
|
||||
|
||||
```bash
|
||||
rg -o -N 'UE_DEFINE_GAMEPLAY_TAG[_A-Z]*\([^,]+,\s*"([^"]+)"' -r '$1' \
|
||||
--glob "*.cpp" . | sort | uniq -c | sort -rn | head
|
||||
```
|
||||
|
||||
Any string with a count above one is a contract with several owners. In the
|
||||
measured reference one elimination channel was declared independently at four
|
||||
sites — the publisher and three separate processors in a feature plugin.
|
||||
|
||||
**Guardrail.** Shared channels live in one header owned by the publisher. Keep
|
||||
file-local declarations only for channels that never leave their file.
|
||||
|
||||
---
|
||||
|
||||
### MB-11 - Listener order assumed without a contract
|
||||
|
||||
**Mechanism.** Nothing guarantees callback order, and removal by swap-with-last
|
||||
actively reorders the array as a side effect of unrelated unsubscriptions.
|
||||
|
||||
**Why it is silent.** An order exists on every run and is stable as long as
|
||||
nothing unsubscribes. Code that depends on it works, and keeps working, until an
|
||||
unrelated feature adds a listener or removes one.
|
||||
|
||||
**Why the obvious check misses it.** The dependency is never written down —
|
||||
that is what makes it an assumption. No search finds "this listener must run
|
||||
first"; the knowledge lives in the fact that it currently does.
|
||||
|
||||
**Symptom.** Ordering-dependent behaviour that changes when an unrelated system
|
||||
subscribes, in a different module, in a different release.
|
||||
|
||||
**Detect.** Find the removal strategy and the documented guarantee, and check for
|
||||
listeners that mutate shared state:
|
||||
|
||||
```bash
|
||||
rg -n "RemoveAtSwap|RemoveSwap" --glob "*.cpp" .
|
||||
rg -n -i "order.*not guaranteed|call order" --glob "*.h" .
|
||||
```
|
||||
|
||||
If the second command finds an explicit warning from the bus authors, the
|
||||
guarantee does not exist and any dependence on order is yours to remove.
|
||||
|
||||
**Guardrail.** If ordering matters, it is not a bus concern — sequence the work in
|
||||
one owner and broadcast the outcome. Never introduce priority through
|
||||
registration order.
|
||||
|
||||
---
|
||||
|
||||
### MB-12 - Reentrancy with no depth limit
|
||||
|
||||
**Mechanism.** Broadcasting from inside a listener callback is legal and
|
||||
supported. Nothing bounds the recursion.
|
||||
|
||||
**Why it is silent.** It is a legitimate and useful pattern — an aggregator that
|
||||
consumes one channel and publishes a derived fact on another does exactly this.
|
||||
The dangerous case differs only in that the graph of channels contains a cycle.
|
||||
|
||||
**Why the obvious check misses it.** Each listener is individually correct and
|
||||
each broadcast is individually justified. The cycle exists in the graph formed by
|
||||
several modules, which no file shows, and which a feature plugin can complete by
|
||||
adding one processor.
|
||||
|
||||
**Symptom.** Stack overflow, or a hang, with a call stack that is one repeating
|
||||
pattern thousands of frames deep and no obvious origin.
|
||||
|
||||
**Detect.** Map the graph: which listeners publish, and on which channels?
|
||||
|
||||
```bash
|
||||
rg -n -B10 "BroadcastMessage\(" --glob "*.cpp" . \
|
||||
| rg -B2 "OnMessageReceived|::Handle\w*Message"
|
||||
```
|
||||
|
||||
Every hit is an edge from an input channel to an output channel. Draw them and
|
||||
look for a cycle. In the measured reference this pattern was present and
|
||||
deliberate — a processor consuming eliminations and publishing assists — with no
|
||||
cycle, which is the correct state and worth confirming rather than assuming.
|
||||
|
||||
**Guardrail.** Keep a broadcast-depth counter in development builds and assert
|
||||
past a small bound. Document, per processor, which channels it reads and writes.
|
||||
|
||||
---
|
||||
|
||||
## Scope and misuse
|
||||
|
||||
### MB-13 - A message used where state is required
|
||||
|
||||
**Mechanism.** A fact is published as an event only. A listener that subscribes
|
||||
after the event has no way to learn it.
|
||||
|
||||
**Why it is silent.** Every listener that existed at publication time is correct.
|
||||
Subscription usually happens during initialisation, before anything interesting
|
||||
has been published, so the whole system is correct for the entire development
|
||||
period.
|
||||
|
||||
**Why the obvious check misses it.** Testing subscribes early by construction —
|
||||
you start the game, the widget is created, then things happen. Reproducing
|
||||
requires a listener created *after* the fact, which means mid-match widget
|
||||
creation, a late-joining client, or a feature activated at runtime.
|
||||
|
||||
**Symptom.** A widget created mid-match shows a default value forever. A late
|
||||
joiner's UI is empty while everyone else's is correct.
|
||||
|
||||
**Detect.** For each channel, ask whether any subscriber can be created late:
|
||||
|
||||
```bash
|
||||
rg -n "RegisterListener\(" --glob "*.cpp" . -B6 | rg -i "NativeConstruct|BeginPlay|OnActivated"
|
||||
```
|
||||
|
||||
Widget construction and feature activation are the late-creation paths. For each,
|
||||
apply the test from the skill: if a listener subscribing one second late must know
|
||||
the current value, the fact is state and the bus is the wrong carrier.
|
||||
|
||||
**Guardrail.** Replicate or store the state; use the message purely as a change
|
||||
notification. On subscription, read the current value directly from its owner —
|
||||
which requires that the value have an owner, and that is the real design output.
|
||||
|
||||
---
|
||||
|
||||
### MB-14 - A command disguised as an event
|
||||
|
||||
**Mechanism.** A message is published to make something happen, rather than to
|
||||
report that it has happened.
|
||||
|
||||
**Why it is silent.** With exactly one listener it behaves identically to a
|
||||
function call. It works, and it looks decoupled.
|
||||
|
||||
**Why the obvious check misses it.** The mechanism is indistinguishable from
|
||||
correct use — same API, same payload shape. Only the *name* reveals it, and names
|
||||
in the imperative mood ("give", "apply", "spawn") pass review because they
|
||||
describe what the sender wants.
|
||||
|
||||
**Symptom.** Zero listeners means the command is silently dropped with no failure
|
||||
result. Two listeners means it happens twice. Neither is reported, because a bus
|
||||
has no concept of a required handler.
|
||||
|
||||
**Detect.** Grep the channel vocabulary for the imperative mood:
|
||||
|
||||
```bash
|
||||
rg -o -N '"([A-Za-z]+\.[A-Za-z.]*Message[A-Za-z.]*)"' -r '$1' --glob "*.cpp" . \
|
||||
| sort -u | rg -i "^\w+\.(Add|Give|Set|Apply|Spawn|Request|Please|Do)"
|
||||
```
|
||||
|
||||
A channel named for an action rather than for a completed fact is the finding.
|
||||
Past-tense names — "equipped", "eliminated", "changed" — are the correct shape.
|
||||
|
||||
**Guardrail.** Commands go to one accountable handler that can fail and say so.
|
||||
Events report facts in the past tense and tolerate zero listeners.
|
||||
|
||||
---
|
||||
|
||||
### MB-15 - Broadcast treated as replication
|
||||
|
||||
**Mechanism.** A message is published on the server and expected to reach
|
||||
clients. The bus is local to one process and has no networking at all.
|
||||
|
||||
**Why it is silent.** In a single-process test — the editor's default multiplayer
|
||||
mode — the host and its client share one process and, depending on the bus's
|
||||
scope, may share one instance. The message appears to cross the network.
|
||||
|
||||
**Why the obvious check misses it.** Everything about the code reads as
|
||||
transport: a channel, a payload, publish and subscribe. The absence of networking
|
||||
is a property of a module's dependency list, which is not where anyone looks when
|
||||
reasoning about a message flow.
|
||||
|
||||
**Symptom.** A feature that works in the editor and does nothing on a dedicated
|
||||
server. Alternatively, a listen server processes both the server-side publication
|
||||
and the client-side rebroadcast and everything happens twice.
|
||||
|
||||
**Detect.** Prove the absence, then find who assumed otherwise:
|
||||
|
||||
```bash
|
||||
rg -n "UFUNCTION\([^)]*(Server|Client|NetMulticast)|NetSerialize|Replicated" \
|
||||
--glob "*.cpp" --glob "*.h" <bus-module-path>/ # expect: nothing
|
||||
rg -n -B4 "BroadcastMessage\(" --glob "*.cpp" . | rg "HasAuthority|WITH_SERVER_CODE|NM_"
|
||||
```
|
||||
|
||||
The second command finds the correct pattern as well as the incorrect one: a
|
||||
net-mode check before a rebroadcast is the guard that prevents double processing
|
||||
on a listen server. Its absence next to a rebroadcast is the finding.
|
||||
|
||||
**Guardrail.** Replicate state or send an RPC; rebroadcast locally on arrival;
|
||||
guard the rebroadcast with a net-mode check so a listen server does not process
|
||||
both paths.
|
||||
|
||||
---
|
||||
|
||||
## Cost
|
||||
|
||||
### MB-16 - Per-broadcast copy of the listener array
|
||||
|
||||
**Mechanism.** For mutation safety the listener array of each non-empty bucket is
|
||||
copied before iteration. The copied element type holds a callable object, whose
|
||||
copy may allocate.
|
||||
|
||||
**Why it is silent.** It is correct, and correctness was the reason for it. The
|
||||
cost is proportional to broadcast frequency times listener count times tag depth,
|
||||
and all three are small early in a project.
|
||||
|
||||
**Why the obvious check misses it.** The line is a defensive copy with a comment
|
||||
explaining why it is needed, which is exactly what a reviewer wants to see. The
|
||||
cost is invisible unless you know that the element type is expensive to copy —
|
||||
which requires reading a different struct in a different header.
|
||||
|
||||
**Symptom.** Allocator churn proportional to message traffic, appearing in
|
||||
profiles as generic allocation cost rather than as bus cost. Discovered when a
|
||||
high-frequency channel — damage in a shooter — is added late.
|
||||
|
||||
**Detect.** Find the copy and the depth walk, then measure:
|
||||
|
||||
```bash
|
||||
rg -n -B2 "TArray<\w*ListenerData>\s*\w+\(" --glob "*.cpp" .
|
||||
rg -n "RequestDirectParent" --glob "*.cpp" .
|
||||
```
|
||||
|
||||
Both present means cost per broadcast scales with tag depth even when no
|
||||
hierarchical listener exists. Confirm with an allocation profile while
|
||||
broadcasting on the highest-frequency channel.
|
||||
|
||||
**Guardrail.** Copy handles rather than callables, or iterate an index with a
|
||||
generation counter, or skip the ancestor walk when the bucket count for parent
|
||||
tags is zero. Measure before optimising — this is a real cost, not a large one.
|
||||
|
||||
---
|
||||
|
||||
### MB-17 - A declared field nobody uses, and the search that hides it
|
||||
|
||||
**Mechanism.** A member is declared in a public struct, never written and never
|
||||
read. It survives because removing it feels risky.
|
||||
|
||||
**Why it is silent.** An unused field costs a few bytes and nothing else. It
|
||||
serialises, copies and compiles like any other member.
|
||||
|
||||
**Why the obvious check misses it.** This entry is here less for the dead field
|
||||
than for the search that fails to prove it dead. A naive recursive search of a
|
||||
built project matches compiled debug symbols, so a field with exactly one source
|
||||
occurrence returns several hits and reads as "in use". The instrument reports the
|
||||
build output as if it were code.
|
||||
|
||||
**Symptom.** Dead API surface that persists across refactors, plus — more
|
||||
expensively — a general loss of trust in the searches used to make deletion
|
||||
decisions.
|
||||
|
||||
**Detect.** Search source only, and say so explicitly:
|
||||
|
||||
```bash
|
||||
F='StateClearedHandle'
|
||||
rg -n "\b$F\b" --glob "*.h" --glob "*.cpp" . # source truth
|
||||
rg -n "\b$F\b" . # unfiltered, for contrast
|
||||
```
|
||||
|
||||
In the measured reference the filtered search returned exactly one line — the
|
||||
declaration — while the unfiltered one returned four, three of them debug-symbol
|
||||
files. Always compare the two before concluding either "dead" or "in use".
|
||||
|
||||
**Guardrail.** Restrict every deletion-decision search by file type. Delete dead
|
||||
fields in the same change that proves them dead, and record the search you ran.
|
||||
@@ -0,0 +1,300 @@
|
||||
# Patterns: a message bus read line by line
|
||||
|
||||
A worked reading of one real tag-addressed message bus and its use in the product
|
||||
that ships it. The runtime module is about a thousand lines and was read in full,
|
||||
which is why several claims below are **negative results stated with confidence**
|
||||
rather than hedged — "there is no networking in this module" is a conclusion from
|
||||
reading every line of it, not from a search that found nothing.
|
||||
|
||||
Individual defects are in [failure-modes.md](failure-modes.md), one entry each,
|
||||
with a recipe. This file is about the shapes: what the bus is, where its costs
|
||||
sit, and how the surrounding product worked around what it does not do.
|
||||
|
||||
Markers: **[measured]** — read in source; **[derived]** — conclusion from measured
|
||||
facts; **[open]** — not answerable from source, and left open.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the mechanism actually is
|
||||
|
||||
A publish/subscribe bus where the **channel is a tag** and the **message is an
|
||||
arbitrary struct**. Publisher and subscriber never reference each other; the
|
||||
entire contract is the pair (channel tag, struct type).
|
||||
|
||||
The implementation is one flat map from tag to listener list **[measured]**.
|
||||
There is no tag tree in the data structure — hierarchy is walked at broadcast
|
||||
time by asking each tag for its direct parent until the chain ends.
|
||||
|
||||
Two properties fall out of that design and explain most of this file:
|
||||
|
||||
1. **The join is a string and a reflection pointer.** Neither is checked by the
|
||||
compiler. Everything in the failure-mode list is a consequence.
|
||||
2. **Delivery is synchronous.** Callbacks run inside the broadcast call before it
|
||||
returns **[measured]**. Good for debugging — the stack is continuous — and the
|
||||
reason reentrancy is possible at all.
|
||||
|
||||
The module's dependencies are the engine core, the engine, and the tag system
|
||||
**[measured]**. Nothing from the game, nothing from the ability system. **[derived]**
|
||||
That self-containment is why this kind of plugin is worth lifting wholesale, and
|
||||
why its defects travel with it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Type safety is real, and has exactly one hole
|
||||
|
||||
The payload crosses the bus as an untyped pointer plus a reflected struct type.
|
||||
The gate is one line **[measured]**: deliver when the listener never had a valid
|
||||
type, **or** when the sent struct derives from the listener's expected struct.
|
||||
|
||||
That gives three branches, and they are not equally safe:
|
||||
|
||||
| Branch | Behaviour | Assessment |
|
||||
|---|---|---|
|
||||
| Sent type derives from expected type | delivered | correct, and makes exact equality a special case |
|
||||
| Types unrelated | logged as an error, listener skipped, broadcast continues | correct and loud |
|
||||
| Listener registered with no expected type | **check bypassed entirely** | documented "for internal use" — and reachable from a Blueprint node with an empty type pin **[measured]** |
|
||||
|
||||
The third row is the hole. What makes it interesting is not that it exists — an
|
||||
internal escape hatch is a reasonable thing to have — but that the comment
|
||||
asserting it is internal is the only thing protecting it **[derived]**. In the
|
||||
Blueprint path a second, stricter check downstream limits the damage to a failed
|
||||
payload extraction. In C++ the same registration combined with a templated
|
||||
callback would reinterpret unrelated bytes with no diagnostic at all. Recipe:
|
||||
MB-03.
|
||||
|
||||
There is also a defensive feature worth copying: the expected struct type is held
|
||||
weakly, with a flag recording that it was once valid, so a listener whose struct
|
||||
was garbage collected is detected and removed at broadcast time with a warning
|
||||
**[measured]**. Epic's own comment on those two fields says they were added in
|
||||
response to real problems. **[derived]** That is a project that was bitten and
|
||||
responded structurally — and the removal code it added is where MB-01 lives.
|
||||
|
||||
---
|
||||
|
||||
## 3. The defect worth reading twice
|
||||
|
||||
During the parent walk, a stale listener is removed using the **original
|
||||
broadcast channel** rather than the ancestor tag currently being visited
|
||||
**[measured]**. Handle IDs are unique only within a channel.
|
||||
|
||||
This entry earns its place because of *where* the mistake is, not what it does:
|
||||
|
||||
- the loop header three lines up rebinds what "the channel" means on every
|
||||
iteration;
|
||||
- the error-log statement a few lines **below** the bug uses the loop variable
|
||||
correctly **[measured]**;
|
||||
- so the same function contains both the right and the wrong idiom, and the wrong
|
||||
one is the one that mutates state.
|
||||
|
||||
**[derived]** Two outcomes, both quiet. Either nothing is found and the warning
|
||||
repeats on every subsequent broadcast forever, or an unrelated listener holding
|
||||
the same channel-local ID in the broadcast channel's bucket is removed instead,
|
||||
and its owner silently stops receiving.
|
||||
|
||||
It requires hierarchical matching plus an expired struct type simultaneously, so
|
||||
the audited project never hits it — its own usage has neither. **That is exactly
|
||||
why it matters to someone lifting the plugin**: the conditions that make it
|
||||
harmless are properties of the original product, not of the code. Recipe: MB-01.
|
||||
|
||||
---
|
||||
|
||||
## 4. A fully built feature that nothing uses
|
||||
|
||||
Hierarchical matching is implemented in the broadcast walk, exposed in the enum,
|
||||
and selectable in the Blueprint node **[measured]**. Game-code usage: **zero**
|
||||
**[measured]**.
|
||||
|
||||
The cause is one unforwarded default argument. The overload that binds a UObject
|
||||
member weakly — the safest form, and the one used almost everywhere — delegates to
|
||||
the raw form without passing the match type, so it is always exact **[measured]**.
|
||||
Reaching hierarchical matching from C++ requires switching to a lambda or an
|
||||
options struct, for reasons the API never states.
|
||||
|
||||
**[derived]** The general shape is worth more than the instance: **a feature can
|
||||
be complete, correct, tested and documented, and still be unreachable from the
|
||||
path everyone actually uses.** Auditing for "is it implemented?" finds it.
|
||||
Auditing for "who calls it?" finds the truth. Recipe: MB-05.
|
||||
|
||||
Two neighbouring subsystems in the same product had independently copied the
|
||||
channel-tag-plus-match-type pattern **[measured]** — evidence that the idea was
|
||||
considered good enough to reproduce, in a project where the original was never
|
||||
switched on.
|
||||
|
||||
---
|
||||
|
||||
## 5. Blueprint parity is where the asymmetries live
|
||||
|
||||
The scripting layer is a separate module, editor-only, with its own node
|
||||
**[measured]**. It does several things well: a wildcard output pin retyped from
|
||||
the selected payload struct, a compile-time error when a wildcard payload pin is
|
||||
connected but untyped, and a copy of the payload by value at the boundary so
|
||||
scripting never holds the sender's stack pointer **[measured]**.
|
||||
|
||||
And then the two paths disagree about types **[measured]**:
|
||||
|
||||
| | C++ | Blueprint |
|
||||
|---|---|---|
|
||||
| Type gate | derived accepted | strict equality |
|
||||
| Mismatch | logged as an error | **silent skip** |
|
||||
|
||||
**[derived]** A Blueprint listener on a base struct silently misses derived
|
||||
payloads that its C++ equivalent receives, and reports nothing. The observable is
|
||||
"the node never fires", which everyone investigates as a wrong tag first, because
|
||||
that hypothesis is cheap to test.
|
||||
|
||||
Neither behaviour is wrong on its own. They were written for different consumers
|
||||
and each is correct against its own local reasoning; nothing in either file
|
||||
mentions the other. **The rule that would have caught it is procedural, not
|
||||
technical: one test matrix, run through both APIs.** Recipe: MB-02.
|
||||
|
||||
---
|
||||
|
||||
## 6. Lifetime: three separate ways to leak
|
||||
|
||||
| Shape | Measured | Consequence **[derived]** |
|
||||
|---|---|---|
|
||||
| Listener map cleared only at GameInstance teardown | reset appears in `Deinitialize` and nowhere else | Records from a travelled-away world persist for the session. Weak binding stops the calls, not the accumulation. MB-08 |
|
||||
| Async node detects owner death only while handling a message | cleanup keyed on the delegate being unbound, inside the message handler; authors' own TODO records the limitation with a tracker ID | On a quiet channel, cleanup waits for traffic. MB-07 |
|
||||
| Options-struct registration with no bound callback | returns a default invalid handle, no log | A listener that never fires, in code that reads as wired. MB-06 |
|
||||
|
||||
The first row generalises past this plugin: **a weak callback is not an
|
||||
unsubscription.** It prevents a call into a dead object and leaves the record.
|
||||
Any bus that binds weakly and never sweeps will accumulate, and the accumulation
|
||||
is invisible precisely because weak binding made it harmless.
|
||||
|
||||
The second row is a good example of a finding you should *want* to discover:
|
||||
the authors documented the limitation themselves, in a comment, with a reference.
|
||||
A TODO that names the problem is the strongest confirmation available from
|
||||
reading source.
|
||||
|
||||
### And a teardown hazard on the consumer side
|
||||
|
||||
The static accessor resolves the world in assert-on-failure mode and then asserts
|
||||
on both the world and the subsystem **[measured]**. Three consumer classes in the
|
||||
product call it from destruction paths **[measured]** — where the world may already
|
||||
be gone — when unregistering through the handle would have resolved the subsystem
|
||||
weakly and no-opped. **[derived]** Correct in every ordinary shutdown; an assert
|
||||
in the unusual orders that nobody can reproduce on demand. Recipe: MB-09.
|
||||
|
||||
---
|
||||
|
||||
## 7. How the product worked around no networking
|
||||
|
||||
The bus has no networking at all: no server, client or multicast functions, no
|
||||
custom serialisation, no replicated properties **[measured]**, in a module small
|
||||
enough to have been read in full. The surrounding product needed networked facts
|
||||
anyway, and solved it three ways — worth comparing, because they are not equally
|
||||
good.
|
||||
|
||||
**1. RPC wrappers on game state and player state.** A multicast or client-targeted
|
||||
RPC receives the message struct and, on arrival, rebroadcasts it locally
|
||||
**[measured]**. The rebroadcast is guarded by a net-mode check so a listen server
|
||||
does not process both the server-side publication and the client-side
|
||||
rebroadcast **[measured]**. That guard is the transferable part; without it every
|
||||
listen-server host sees everything twice. The headers say plainly that these are
|
||||
for notifications that can be lost.
|
||||
|
||||
**2. A replicated fast array that rebroadcasts on arrival.** Fully implemented and
|
||||
**used nowhere in the project** — no member of that type exists anywhere
|
||||
**[measured]**. It is a worked example rather than working code, and it carries an
|
||||
empty removal callback (see the network-authority skill for why that matters at
|
||||
adoption time).
|
||||
|
||||
**3. Replicated state plus a local broadcast on the replication callback.** The
|
||||
cleanest and the most used **[measured]**. State replicates; the message is purely
|
||||
a local notification that it changed.
|
||||
|
||||
**[derived]** The third pattern is the one to copy, and the reason is the
|
||||
state-versus-event test: only in that arrangement is a late joiner correct,
|
||||
because the truth is in the replicated state and the message carries none of it.
|
||||
|
||||
---
|
||||
|
||||
## 8. What the bus does not do, stated plainly
|
||||
|
||||
Read as a list of things you will otherwise discover one at a time:
|
||||
|
||||
- **No sticky or replay messages.** A subscriber cannot ask for the last value.
|
||||
The product compensates by having new widgets read the owning component
|
||||
directly on construction **[measured]** — which only works because the state has
|
||||
an owner. MB-13.
|
||||
- **No ordering guarantee.** The authors say so in a header comment, and removal
|
||||
by swap-with-last actively reorders the array **[measured]**. MB-11.
|
||||
- **No per-player filtering.** Consumers filter inside the callback, comparing a
|
||||
target field against their own owner **[measured]**.
|
||||
- **No cancellation.** Every matching listener receives every message.
|
||||
- **No recursion guard.** Broadcasting from inside a callback is supported and
|
||||
used deliberately by an aggregator in the product **[measured]**; nothing bounds
|
||||
the depth if the channel graph ever contains a cycle. MB-12.
|
||||
- **No thread safety.** Game thread only **[measured]**.
|
||||
- **No networking.** §7.
|
||||
|
||||
---
|
||||
|
||||
## 9. The architecture it made possible
|
||||
|
||||
Worth stating, because the failure-mode list above is not an argument against the
|
||||
pattern:
|
||||
|
||||
```text
|
||||
ability system --damage / elimination-->
|
||||
processors in a feature plugin --assist / chain / streak-->
|
||||
scripted relay --notification-->
|
||||
UI widget
|
||||
```
|
||||
|
||||
No layer references the next **[measured]**. The processors live in a feature
|
||||
plugin that the base module has never heard of, and they are added and removed
|
||||
with the feature. **[derived]** That is the whole case for a bus: it let
|
||||
game-mode-specific logic move into a shippable plugin without creating a single
|
||||
reference from the base module.
|
||||
|
||||
Note also the vocabulary discipline that came with it: channels named so bus
|
||||
traffic is distinguishable from other tags, tags declared next to the publisher,
|
||||
payload fields initialised at declaration because a payload never passes through
|
||||
serialisation and gets no free defaults **[measured]**.
|
||||
|
||||
And the cost of that discipline, measured: one elimination channel string was
|
||||
declared independently at **four** sites — the publisher and three processors
|
||||
**[measured]**. It works because the engine deduplicates by string. **[derived]**
|
||||
It means the string is the real contract and a single-site edit detaches one
|
||||
participant silently. Recipe: MB-10.
|
||||
|
||||
---
|
||||
|
||||
## 10. What source reading could not settle
|
||||
|
||||
Stated because it bounds several claims above:
|
||||
|
||||
- **Blueprint call sites are invisible to text search.** Three RPC wrappers and
|
||||
one notification channel have zero C++ callers or publishers, and all are
|
||||
script-callable **[measured]**. Whether they are used, and by what, is
|
||||
**[open]**. They are recorded as open questions, not as dead code.
|
||||
- **The extent of scripted bus usage is unknown.** A search for the listen node
|
||||
across all source returns nothing **[measured]**, which means the entire
|
||||
scripted traffic of the bus lives in binary assets.
|
||||
- **The channel-key defect was found by reading, not by running.** It has not been
|
||||
reproduced at runtime **[open]**, and its trigger conditions do not occur in the
|
||||
audited project.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
Measured against the gameplay message router plugin and its consumers in Epic's
|
||||
Lyra Starter Game on Unreal Engine 5.6, read as source in a single workspace. The
|
||||
runtime module was read in full, line by line; the negative results in §7 and §8
|
||||
rest on that rather than on a search.
|
||||
|
||||
Source addresses stay in the research archive that produced this skill; each
|
||||
`MB-` identifier resolves back to the audited location there, so any specific
|
||||
claim above can be produced on request.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
One plugin, one engine version, one workspace. These are examples and failure
|
||||
evidence, not guarantees about other versions. Binary assets were not read, so
|
||||
every statement about scripted *usage* is bounded by what source could show and is
|
||||
marked open where it is open. Re-run the recipes in
|
||||
[failure-modes.md](failure-modes.md) against your own tree before acting on
|
||||
anything here.
|
||||
Reference in New Issue
Block a user