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:
+384
@@ -0,0 +1,384 @@
|
||||
# Failure modes: runtime allocation and caching
|
||||
|
||||
Ten ways a running game allocates more each minute, keeps what it should drop, or
|
||||
dereferences memory that moved.
|
||||
|
||||
The shared property splits this list in two, and the split is worth stating
|
||||
because it changes how you look for each half.
|
||||
|
||||
**The dangling-pointer half is data-dependent.** It needs a second key inserted
|
||||
while a first entry is still live, or a new statistic to appear mid-session.
|
||||
Neither happens in a short test, both happen in a match, and the crash lands far
|
||||
from the cause. A null check never catches it, because the pointer is not null.
|
||||
|
||||
**The growth half is time-dependent.** Nothing is wrong at any instant; the
|
||||
defect is a slope. A single memory sample cannot show it and a single frame
|
||||
cannot contain it, which is why every recipe below asks for two measurements
|
||||
rather than one.
|
||||
|
||||
Recipes use `rg` from a project source root, and were executed against the
|
||||
audited project while this file was written.
|
||||
|
||||
---
|
||||
|
||||
## Pointers into moving storage
|
||||
|
||||
### RC-01 - A raw pointer into a map value, held across frames
|
||||
|
||||
**Mechanism.** A reference to a map value is taken, its address is stored in a
|
||||
long-lived entry, and the map later gains a key. The map reallocates; the stored
|
||||
address points into freed memory.
|
||||
|
||||
**Why it is silent.** Until a second key arrives, the address stays valid and
|
||||
everything works. The window is narrow and specific — a live entry plus an insert
|
||||
— so ordinary play produces it and ordinary testing does not.
|
||||
|
||||
**Why the obvious check misses it.** The code often *has* a check, and the check
|
||||
passes: the pointer is not null, it is non-null garbage. Reading the storing line
|
||||
shows a reference to a pool, which is exactly what the entry needs. The defect is
|
||||
a property of the container's reallocation behaviour, which is nowhere in view.
|
||||
|
||||
**Symptom.** A crash inside the pooled object's own method, under load, with a
|
||||
stack that looks like the pool is corrupt. In the audited project the window is a
|
||||
one-second component lifespan plus a second visual style — trivially produced by
|
||||
mixed normal and critical damage in the same second.
|
||||
|
||||
**Detect.** Find addresses taken from containers and stored:
|
||||
|
||||
```bash
|
||||
rg -n -B2 -A4 "FindOrAdd\(|\.Find\(" --glob "*.cpp" . | rg "&\w+|Emplace\(|= &"
|
||||
rg -n "\w+\*\s+\w+ = nullptr;" --glob "*.h" . | rg -i "pool|cache|list|entry"
|
||||
```
|
||||
|
||||
The second search finds the *declaration* side — a bare pointer member whose name
|
||||
suggests it points at a collection — which is often easier to spot than the
|
||||
assignment.
|
||||
|
||||
**Guardrail.** Store a key or an index and resolve at use. If a stable address is
|
||||
genuinely required, use a container that guarantees stable element addresses and
|
||||
say so at the declaration. When you suspect one, run under the allocator's stomp
|
||||
mode: it converts a probabilistic corruption into a deterministic crash on the
|
||||
right line, and costs one run.
|
||||
|
||||
---
|
||||
|
||||
### RC-02 - A cache pointer handed out and stored by a widget
|
||||
|
||||
**Mechanism.** A subsystem returns a pointer into its own map so a widget can
|
||||
read samples cheaply. The widget stores it. The map grows a key when a new data
|
||||
source appears — a network connection, an optional module — and reallocates.
|
||||
|
||||
**Why it is silent.** The optimization is real and the pointer is valid at hand-
|
||||
out. Growth is lazy and event-driven: the map gains keys minutes into a session,
|
||||
when a connection is established or a feature is enabled, long after the widget
|
||||
cached its pointer.
|
||||
|
||||
**Why the obvious check misses it.** Both sides are idiomatic. Returning a
|
||||
pointer to avoid copying a sample buffer is good practice; caching it to avoid a
|
||||
lookup per frame is also good practice. Neither side knows the map is still
|
||||
growing, and nothing in either signature says the pointer has a lifetime.
|
||||
|
||||
**Symptom.** A crash in the widget's paint path, appearing only after the session
|
||||
has been running long enough to acquire a new statistic — and therefore never in
|
||||
a short repro. Compounded when the subsystem's teardown resets the tracker while
|
||||
the widget still holds the pointer.
|
||||
|
||||
**Detect.** Find getters returning interior pointers, then find who stores them:
|
||||
|
||||
```bash
|
||||
rg -n "const \w+\* \w+::Get\w*Data\(" --glob "*.cpp" . -A4 | rg "\.Find\("
|
||||
rg -n "const \w+\* \w+ = nullptr;" --glob "*.h" . | rg -i "widget|graph|view"
|
||||
```
|
||||
|
||||
A getter returning `Find` on a member map, plus a member pointer of that type in
|
||||
a widget, is the pair.
|
||||
|
||||
**Guardrail.** Return a copy of the small value, or a handle the subsystem can
|
||||
invalidate, or require the caller to re-fetch each frame. If a pointer must
|
||||
escape, give the subsystem an invalidation broadcast and make subscribing
|
||||
mandatory.
|
||||
|
||||
---
|
||||
|
||||
## Unbounded growth
|
||||
|
||||
### RC-03 - Read, append, write back — with no clear
|
||||
|
||||
**Mechanism.** Each event fetches an entire collection from another system,
|
||||
appends one element, and writes the whole thing back. Nothing ever clears it.
|
||||
|
||||
**Why it is silent.** Every individual call is correct and fast. The array is a
|
||||
legitimate data channel to the effects system, and its contents are consumed
|
||||
visually, so nobody looks for a removal path.
|
||||
|
||||
**Why the obvious check misses it.** The two lines read as an idiomatic
|
||||
accessor pair. The cost — two full copies per event — is invisible at small sizes,
|
||||
and the growth is invisible without a second measurement. Reviewing the function
|
||||
shows three lines of correct code.
|
||||
|
||||
**Symptom.** Cost per event rising through a match, total work quadratic in event
|
||||
count, and memory that never returns. In a damage-number path this means the
|
||||
hundredth hit costs measurably more than the first.
|
||||
|
||||
**Detect.** Find get-append-set triples on the same key:
|
||||
|
||||
```bash
|
||||
rg -n -A3 "= \w*::Get\w*Array\w*\(" --glob "*.cpp" . | rg "Add\(|SetNiagara|Set\w*Array"
|
||||
```
|
||||
|
||||
Then, for each hit, search the file for any clear:
|
||||
|
||||
```bash
|
||||
rg -n "Empty\(\)|Reset\(\)|SetNum\(0\)|Clear" <that-file>
|
||||
```
|
||||
|
||||
An append path with no clear anywhere in the file is the finding.
|
||||
|
||||
**Guardrail.** Give the collection an owner and an eviction rule — a cap, a
|
||||
time-out, or a clear on the event that logically ends its contents. If the
|
||||
consuming system owns the lifetime, ask it for a removal API rather than
|
||||
assuming one.
|
||||
|
||||
---
|
||||
|
||||
### RC-04 - Re-append every previous element on every trigger
|
||||
|
||||
**Mechanism.** A component that spawns audio and effect components copies all
|
||||
previously active ones into temporary arrays, appends the new ones, and writes
|
||||
the union back — with no removal of finished components.
|
||||
|
||||
**Why it is silent.** Effects play and stop correctly, because stopping is the
|
||||
component's own business. What accumulates is the *references*, and those are
|
||||
invisible until something counts them.
|
||||
|
||||
**Why the obvious check misses it.** The function reads as careful state
|
||||
management: gather, combine, store. The absent operation is a filter for finished
|
||||
components, and absence in a function that is otherwise thorough is the hardest
|
||||
kind to see.
|
||||
|
||||
**Symptom.** Thousands of retained effect components after a match. Because the
|
||||
arrays are strong references, none of them can be collected, so this is
|
||||
simultaneously a memory leak and a quadratic allocation profile in the hottest
|
||||
cosmetic path — footsteps, at a few per second.
|
||||
|
||||
**Detect.** Find union-rebuild patterns and check for a prune:
|
||||
|
||||
```bash
|
||||
rg -n -B4 -A6 "\.Append\(" --glob "*.cpp" . | rg "Empty\(\)|Reset\(\)"
|
||||
rg -n "UPROPERTY\(Transient\)" -A2 --glob "*.h" . | rg "TArray<TObjectPtr<U\w*(Audio|Niagara|Particle)"
|
||||
```
|
||||
|
||||
The second search alone is worth running in any project: a strong transient array
|
||||
of effect components is a retention decision, and it needs a removal path.
|
||||
|
||||
**Guardrail.** Filter finished components on each pass, or use weak references
|
||||
and let the components own their own lifetime. Prefer the reset-without-freeing
|
||||
operation to the one that releases the buffer — see RC-06.
|
||||
|
||||
---
|
||||
|
||||
### RC-05 - Rebuilding descriptors on a timer instead of diffing
|
||||
|
||||
**Mechanism.** A periodic scan removes all its descriptor objects and creates new
|
||||
ones for the same targets, several times a second.
|
||||
|
||||
**Why it is silent.** The result is correct: the right descriptors exist
|
||||
afterwards. Object creation is fast enough that no single frame stands out.
|
||||
|
||||
**Why the obvious check misses it.** The function reads as a clean rebuild —
|
||||
clear, then populate — which is a legitimate and common shape. The cost is in the
|
||||
frequency, which is set elsewhere as a scan rate, and in the downstream churn:
|
||||
each create and destroy propagates through observers into widget pooling and
|
||||
canvas slot management.
|
||||
|
||||
**Symptom.** Steady allocation and observer churn while the player stands still
|
||||
in a crowd of interactive objects. Profiles show constant low-level cost with no
|
||||
obvious owner.
|
||||
|
||||
**Detect.** Find object creation inside timer-driven update functions:
|
||||
|
||||
```bash
|
||||
rg -n -B10 "NewObject<" --glob "*.cpp" . | rg "Update\w+\(|OnTimer|ScanRate"
|
||||
rg -n "ScanRate|Interval|SetTimer" --glob "*.h" --glob "*.cpp" . | rg -i "0\.[0-9]"
|
||||
```
|
||||
|
||||
Also check the change-detection that gates the rebuild: comparison logic that
|
||||
sorts only when sizes match will report a change on any reorder, making the
|
||||
"only when changed" guard ineffective.
|
||||
|
||||
**Guardrail.** Diff against a stable key and reuse descriptors. If a rebuild is
|
||||
genuinely simpler, measure it at the worst-case target count before accepting the
|
||||
scan rate.
|
||||
|
||||
---
|
||||
|
||||
### RC-06 - Freeing the buffer in a hot path
|
||||
|
||||
**Mechanism.** The container operation that releases memory is used where the one
|
||||
that keeps capacity was intended, in code that runs many times per second.
|
||||
|
||||
**Why it is silent.** Both operations leave the container empty and both are
|
||||
correct. The difference is whether the next fill reallocates.
|
||||
|
||||
**Why the obvious check misses it.** The two calls are one word apart and read
|
||||
identically at a glance. Nothing distinguishes a hot path from a cold one in the
|
||||
call itself, and in teardown code the freeing version is the right choice — so the
|
||||
same line is correct in one file and wrong in another.
|
||||
|
||||
**Symptom.** Allocator churn proportional to event frequency, showing up as
|
||||
generic allocation cost rather than attributable to the system causing it.
|
||||
|
||||
**Detect.** Find the freeing operation and classify each site by frequency:
|
||||
|
||||
```bash
|
||||
rg -n "\.Empty\(\)" --glob "*.cpp" . -B6 | rg -i "tick|paint|onhit|effect|update"
|
||||
```
|
||||
|
||||
Teardown sites are fine. Anything reached more than once a second is the finding.
|
||||
In the audited project the two hottest cosmetic arrays are freed and refilled on
|
||||
every footstep.
|
||||
|
||||
**Guardrail.** Reset in hot paths, free at teardown, and write the reason in a
|
||||
comment when it is not obvious which one a site is.
|
||||
|
||||
---
|
||||
|
||||
### RC-07 - Allocating in a paint or arrange path
|
||||
|
||||
**Mechanism.** A per-frame layout or paint function builds a local array, without
|
||||
reserving, and lets it reallocate as it fills.
|
||||
|
||||
**Why it is silent.** It is correct, local and easy to read. The cost is a
|
||||
handful of small allocations per frame, which no single profile sample
|
||||
attributes.
|
||||
|
||||
**Why the obvious check misses it.** A local array in a function is the most
|
||||
ordinary thing in the file. Knowing it matters requires knowing the function is
|
||||
called once or more per frame — which is a property of the caller.
|
||||
|
||||
**Symptom.** A frame-time floor that nothing accounts for, and allocator pressure
|
||||
that scales with the number of on-screen elements.
|
||||
|
||||
**Detect.** Find containers declared in per-frame widget paths:
|
||||
|
||||
```bash
|
||||
rg -n -A12 "::OnPaint\(|::OnArrangeChildren\(|::Tick\(" --glob "*.cpp" . \
|
||||
| rg "TArray<|TMap<"
|
||||
rg -n "static TArray|static TMap" --glob "*.cpp" . | rg -i "widget|slate|paint"
|
||||
```
|
||||
|
||||
The second search finds the wrong fix for the first: a static container avoids the
|
||||
allocation and introduces sharing between every instance of the widget, plus a
|
||||
thread-safety assumption nobody wrote down. In the audited project both appear —
|
||||
one path allocates per frame, another uses a static.
|
||||
|
||||
**Guardrail.** A mutable member with a reset at the top of the function. Not a
|
||||
local, not a static.
|
||||
|
||||
---
|
||||
|
||||
## Semantics
|
||||
|
||||
### RC-08 - A prefilled ring buffer reporting statistics
|
||||
|
||||
**Mechanism.** A fixed-capacity sample cache is filled with zeroes at
|
||||
construction. Minimum, average and related statistics include the prefix until
|
||||
the buffer wraps.
|
||||
|
||||
**Why it is silent.** Every value returned is a real number computed correctly
|
||||
from the buffer's contents. There is no error state for "not enough samples yet",
|
||||
because the buffer is always full.
|
||||
|
||||
**Why the obvious check misses it.** The implementation is correct for a full
|
||||
buffer, and a reviewer checks the arithmetic rather than the initial condition.
|
||||
The distortion disappears after a few seconds, so it is gone before anyone
|
||||
investigates.
|
||||
|
||||
**Symptom.** A minimum of zero and a depressed average on any graph for the first
|
||||
seconds after it appears — which is exactly when someone is looking at it.
|
||||
|
||||
**Detect.** Find prefilled buffers and check whether the statistics account for
|
||||
fill level:
|
||||
|
||||
```bash
|
||||
rg -n "AddZeroed\(|SetNumZeroed\(" --glob "*.h" --glob "*.cpp" .
|
||||
rg -n -A6 "GetMin\(\)|GetAverage\(\)" --glob "*.h" . | rg "Num\(\)|SampleSize|NumValid"
|
||||
```
|
||||
|
||||
Statistics dividing by capacity rather than by a fill count is the finding.
|
||||
|
||||
**Guardrail.** Track the number of valid samples and compute over that window.
|
||||
Also check the accessor names: an index-based "current" that returns the *next
|
||||
write position* is the oldest sample, not the newest — a naming trap worth
|
||||
checking in any ring buffer.
|
||||
|
||||
---
|
||||
|
||||
### RC-09 - An identifier that is a count, at three different widths
|
||||
|
||||
**Mechanism.** A batch identifier is assigned from the number of in-flight
|
||||
batches rather than a monotonic sequence, so identifiers are reused as batches
|
||||
complete. Separately, the same value is one width at the source, another in the
|
||||
message, and a third in storage.
|
||||
|
||||
**Why it is silent.** With one or two batches in flight, counts and sequences are
|
||||
indistinguishable. The width mismatch is invisible until the value exceeds the
|
||||
smallest type — which needs sustained fire and a degraded connection.
|
||||
|
||||
**Why the obvious check misses it.** Each half is defensible alone: a count is a
|
||||
plausible identifier when everything completes promptly, and each width is
|
||||
reasonable in its own context. Confirming means reading a declaration, a
|
||||
constructor and a message signature that are not adjacent — in the audited project
|
||||
the storage width and the message width are sixteen lines apart in the same
|
||||
header, which is exactly far enough.
|
||||
|
||||
**Symptom.** Confirmations applied to the wrong batch when identifiers are
|
||||
reused, and — once the counter passes the narrow type's range — confirmations that
|
||||
match nothing at all, so the pending list grows without bound.
|
||||
|
||||
**Detect.** Trace one identifier through the whole path and compare widths:
|
||||
|
||||
```bash
|
||||
I='UniqueId'
|
||||
rg -n "\b$I\b" --glob "*.h" --glob "*.cpp" .
|
||||
```
|
||||
|
||||
Read the output as a table of types. Then check the assignment: an identifier
|
||||
taken from a `Num()` or a count getter is a reused identifier.
|
||||
|
||||
**Guardrail.** One monotonic sequence, one width, asserted at every boundary.
|
||||
Define wrap behaviour. Give the pending list a timeout and a drain, so a matching
|
||||
failure degrades instead of accumulating.
|
||||
|
||||
---
|
||||
|
||||
### RC-10 - Broadcasting before the state is consistent
|
||||
|
||||
**Mechanism.** A registry broadcasts an "added" event before inserting the item
|
||||
into its own collection. A listener that responds by enumerating the collection
|
||||
sees a state that does not include the item it was just told about.
|
||||
|
||||
**Why it is silent.** For a listener that only uses the event payload, the order
|
||||
is irrelevant and everything works. The defect needs a listener that also reads
|
||||
the collection — typically one doing late binding, catching up on existing items.
|
||||
|
||||
**Why the obvious check misses it.** Two adjacent lines, both correct, in an
|
||||
order that reads naturally: announce, then record. Reversing them looks like a
|
||||
stylistic preference until you know a listener enumerates.
|
||||
|
||||
**Symptom.** An item lost or added twice, depending on which listener ran when.
|
||||
In the audited project a canvas both handles the event and enumerates existing
|
||||
items on attach, and it adds to its own list without a duplicate check.
|
||||
|
||||
**Detect.** Find broadcasts that precede the mutation they announce:
|
||||
|
||||
```bash
|
||||
rg -n -B2 -A2 "\.Broadcast\(" --glob "*.cpp" . | rg "Add\(|Remove\(|Emplace\("
|
||||
```
|
||||
|
||||
A broadcast on the line *before* the insert is the finding. Then check the
|
||||
listener for a duplicate guard.
|
||||
|
||||
**Guardrail.** Mutate, then broadcast. Listeners must observe a consistent
|
||||
container, and any listener that also enumerates on attach needs an idempotent
|
||||
add.
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
# Patterns: runtime allocation and caching in a measured product
|
||||
|
||||
A worked reading of the runtime allocation, pooling, caching and replication-cost
|
||||
surface of one shipped project.
|
||||
|
||||
This file differs from the others in one respect worth flagging: **two of its
|
||||
findings are memory-safety defects, not design smells.** Everywhere else in this
|
||||
bundle the failures are silent-but-benign until adopted. Here, two of them are
|
||||
dangling pointers in shipping paths, and they are silent for the ordinary reason —
|
||||
freed memory usually still contains the right bytes.
|
||||
|
||||
Markers: **[measured]** — read in source; **[derived]** — conclusion from measured
|
||||
facts; **[open]** — requires a runtime experiment.
|
||||
|
||||
**Nothing here was profiled.** Every claim is structural. That is a limitation
|
||||
and also the point: both critical findings were found by reading, and both are
|
||||
confirmable in one run under an allocator that poisons freed pages.
|
||||
|
||||
---
|
||||
|
||||
## 1. The same defect twice, in two unrelated subsystems
|
||||
|
||||
A raw pointer taken from a container's storage and held across frames. It appears
|
||||
independently in a cosmetic feedback pool and in a statistics cache
|
||||
**[measured]**.
|
||||
|
||||
**Case one — the pool.** A map from mesh to a pooled component list. Code takes a
|
||||
reference to a map value, stores its address in a live-entry struct, and
|
||||
dereferences it up to a second later when the entry expires **[measured]**.
|
||||
|
||||
**Case two — the statistics cache.** A map from statistic type to a fixed-size
|
||||
sample cache. A getter returns the address of a map value; a Slate widget stores
|
||||
that pointer as a member and dereferences it **every frame during paint**
|
||||
**[measured]**.
|
||||
|
||||
**[derived]** Both are correct until the map rehashes, and both maps grow after
|
||||
the pointer is taken:
|
||||
|
||||
- the pool map gains a key the first time a second visual style appears — mixed
|
||||
normal and critical damage within one second is enough;
|
||||
- the statistics map is **populated lazily**, gaining keys as a game state
|
||||
appears, a player state appears, a network connection appears, and a latency
|
||||
module is enabled **[measured]**. A widget that cached a pointer before any of
|
||||
those events is holding a stale address afterwards.
|
||||
|
||||
The second case is worse in a specific way: the dereference happens in a paint
|
||||
path that the widget declares as always volatile, so it runs every frame with no
|
||||
invalidation mechanism **[measured]**.
|
||||
|
||||
**[derived]** The general rule this yields is worth more than the two instances:
|
||||
**a pointer obtained from a container and stored beyond the current scope is a
|
||||
review blocker, regardless of how the container is used.** Store a key or an
|
||||
index and resolve at use. The audited code even guards one of them with a
|
||||
non-null check, which cannot detect this failure — the pointer is non-null
|
||||
garbage. Recipes: RC-01, RC-02.
|
||||
|
||||
---
|
||||
|
||||
## 2. Unbounded growth, three shapes
|
||||
|
||||
| Shape **[measured]** | Where | What it costs |
|
||||
|---|---|---|
|
||||
| Read whole array, append one, write whole array back — never cleared | damage number effect data | Linear per event, quadratic per session, two full copies per hit |
|
||||
| Re-append every previously active component on each trigger, never prune finished ones | footstep and impact effects | Strong references pin every one-shot sound and effect for the whole match |
|
||||
| Pending protocol entries removed only on a matching confirmation | hit-marker batches | Grows without bound once matching stops working (§4) |
|
||||
|
||||
**[derived]** The first two share a structure that is easy to miss in review: the
|
||||
code that grows the collection is also the code that appears to manage it. Reading
|
||||
`Empty()` followed by `Append()` looks like a refresh. It is a refresh that
|
||||
re-adds everything, and the array is a strong property, so nothing is ever
|
||||
released.
|
||||
|
||||
The second one also uses `Empty()` rather than `Reset()` in a path that runs at
|
||||
footstep frequency **[measured]**, which frees the buffer and guarantees a
|
||||
reallocation on the next call — a smaller problem sitting inside a larger one.
|
||||
Recipes: RC-03, RC-04, RC-06.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pools without lifecycle
|
||||
|
||||
The component pool grows to the peak concurrent count and **never shrinks**
|
||||
**[measured]**. One burst of two hundred simultaneous hits leaves two hundred
|
||||
components allocated for the rest of the match.
|
||||
|
||||
A second pool in the indicator system is fixed at ten entries with surplus
|
||||
requests simply hidden **[measured]** — which is a legitimate policy, stated
|
||||
nowhere.
|
||||
|
||||
**[derived]** The contrast between the two is the lesson: one pool has no
|
||||
high-water policy and the other has an undocumented one. Both are the same
|
||||
omission — **a pool is not correct until its exhaustion and trim behaviour are
|
||||
written down.** Neither project reader can answer "what happens at peak?" without
|
||||
reading the implementation.
|
||||
|
||||
Related and cheaper to fix: the live queue removes from the front of an array,
|
||||
shifting the tail on every timer tick **[measured]**, where a ring buffer would
|
||||
not move anything.
|
||||
|
||||
---
|
||||
|
||||
## 4. An identifier that is a count
|
||||
|
||||
The clearest single defect in the audit, and a good example of why protocol
|
||||
identifiers deserve their own review rule.
|
||||
|
||||
A hit-marker batch is identified by **the current number of unconfirmed batches**
|
||||
rather than by a monotonically increasing sequence **[measured]**.
|
||||
|
||||
**[derived]** The failure needs no packet loss to reason about. Batch A takes
|
||||
identifier 0. Batch B takes 1. A is confirmed and removed. C now takes 1 — the
|
||||
same identifier B is still holding. The confirmation handler matches on the first
|
||||
entry with that identifier, so a confirmation for one batch can be applied to
|
||||
another.
|
||||
|
||||
Compounding it, the same value is carried at **three different widths** along one
|
||||
path: signed 32-bit at the source, unsigned 16-bit in the network call, and
|
||||
unsigned 8-bit in storage **[measured]** — the last two declared sixteen lines
|
||||
apart in the same header. Past 255 unconfirmed entries the storage wraps and the
|
||||
network value does not, so matching stops entirely and the pending list grows
|
||||
without bound.
|
||||
|
||||
Recipe: RC-09. The rules that fall out: identifiers are sequences, one width for
|
||||
the whole path, and pending lists get a timeout and a drain policy.
|
||||
|
||||
---
|
||||
|
||||
## 5. Allocation in paint and arrange paths
|
||||
|
||||
Three instances, all in per-frame Slate code **[measured]**:
|
||||
|
||||
- a local array built and sorted on every arrange, without reserving;
|
||||
- a full array copy returned by value from a getter called during paint;
|
||||
- a **static** array used as scratch space in a draw helper.
|
||||
|
||||
**[derived]** The third is the interesting one, because it is an optimisation. It
|
||||
avoids per-call allocation and introduces two new problems: the buffer is shared
|
||||
by every instance of the widget in every window, and it is not thread-safe in a
|
||||
context where paint is not guaranteed to be on one thread. The correct form —
|
||||
a mutable member reset per call — is the same cost and none of the risk.
|
||||
|
||||
Recipe: RC-07. Also visible in the same code: a component lookup by class
|
||||
performed both in paint and in tick, twice per frame, uncached **[measured]**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rebuild instead of diff
|
||||
|
||||
An interaction system destroys every descriptor object and creates new ones on a
|
||||
tenth-of-a-second timer, for the same targets **[measured]**. Each cycle
|
||||
allocates objects, invalidates observers, and drives a full add/remove cycle
|
||||
through the indicator canvas.
|
||||
|
||||
**[derived]** The change-detection that gates this is itself unreliable: the
|
||||
comparison sorts the candidate list only when the two lists have equal length
|
||||
**[measured]**, so an equal-length reordering reports a change that did not
|
||||
happen. In a crowd of interactable objects the rebuild runs ten times a second.
|
||||
|
||||
The fix is the general one: compute the desired set, diff against the current
|
||||
set, and add and remove the difference. Recipe: RC-05.
|
||||
|
||||
A related ordering defect sits in the same subsystem: the add notification is
|
||||
broadcast **before** the element is inserted **[measured]**, so a listener that
|
||||
enumerates existing elements during that window either misses it or receives it
|
||||
twice — and the receiving side adds without a duplicate check. Recipe: RC-10.
|
||||
|
||||
---
|
||||
|
||||
## 7. Statistics that are wrong before the window fills
|
||||
|
||||
The sample cache is pre-filled with zeroes at construction **[measured]**. Until
|
||||
the ring wraps — which takes as many frames as its capacity — the minimum reads
|
||||
zero and the average is divided by the full capacity rather than by the number of
|
||||
real samples **[measured]**.
|
||||
|
||||
**[derived]** So the first seconds of every session report a minimum of zero and
|
||||
an artificially low average, on a performance overlay whose entire purpose is to
|
||||
be read during those seconds.
|
||||
|
||||
There is a second, quieter trap in the same class: a method named for the current
|
||||
sample returns the **oldest** one, because the index points at the next write
|
||||
position **[measured]**. The correctly-named sibling exists and is what the
|
||||
project uses, which is why nobody noticed. Recipe: RC-08.
|
||||
|
||||
The cache itself is well bounded — capacity times sample size times statistic
|
||||
count is on the order of tens of kilobytes **[derived]** — so this is a
|
||||
correctness finding, not a memory one. Both matter; conflating them is how
|
||||
"performance work" produces no performance.
|
||||
|
||||
---
|
||||
|
||||
## 8. Replication: name the axis
|
||||
|
||||
The audit's replication half produced no new defects and one clarification worth
|
||||
carrying, because it is routinely got backwards:
|
||||
|
||||
| Mechanism | Memory | CPU | Bandwidth |
|
||||
|---|---|---|---|
|
||||
| Excluding presentation on a dedicated server | down | down | **unchanged** |
|
||||
| Delta-serialised arrays | **up** (per-item key, per-connection state) | **up** (delta walk) | down |
|
||||
| Subobject replication | down | down | down |
|
||||
| Dormancy, relevancy, replication graph | — | down | down |
|
||||
|
||||
**[derived]** Two of these surprise people. Excluding cosmetics on a server saves
|
||||
memory and CPU and changes bandwidth by exactly zero, because the intent still
|
||||
replicates. And delta serialisation is a **bandwidth** optimisation that costs
|
||||
memory and CPU — adopting it to "reduce replication cost" without naming the axis
|
||||
can make the measured problem worse.
|
||||
|
||||
The genuine object-count reducer is subobject replication: many owned items, zero
|
||||
additional actors and channels.
|
||||
|
||||
Before claiming any replication optimisation: state which axis improves and which
|
||||
regresses, measure that axis specifically, and **confirm the mechanism is
|
||||
actually enabled** — see `ue-multiplayer-authority`, NA-15, for a replication
|
||||
graph that ships disabled with configured routing.
|
||||
|
||||
---
|
||||
|
||||
## 9. What to do with this list
|
||||
|
||||
In order of ratio of certainty to effort:
|
||||
|
||||
1. **Run once under an allocator that poisons freed memory**, with a mixed-style
|
||||
damage burst and with a latency module toggled on while the performance
|
||||
overlay is open. Those two scenarios turn RC-01 and RC-02 from arguments into
|
||||
crashes at the right line. **[open]** — this is the one experiment that
|
||||
settles the two critical findings.
|
||||
2. **Add five counters to the existing profiling category** — pool size, live
|
||||
entries, pending protocol entries, active effect components, indicator count.
|
||||
The category and an example already exist in the audited project
|
||||
**[measured]**; extending it is three lines each and produces boundedness
|
||||
curves over a match, which is what actually distinguishes a cache from a leak.
|
||||
3. **Grep for the two structural patterns** — stored container pointers, and
|
||||
`Empty()` in hot paths. Both are one command and both found real instances
|
||||
here.
|
||||
4. Everything else is ordinary optimisation and can wait for a profile.
|
||||
|
||||
**[derived]** Note the shape of that list: the cheapest actions are the ones that
|
||||
convert reading into evidence. A structural audit without step 1 produces a
|
||||
plausible argument; with it, a defect report.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
Measured against Epic's Lyra Starter Game on Unreal Engine 5.6, read as source in
|
||||
a single workspace: feedback and effect components, the indicator system, the
|
||||
performance statistics subsystem, weapon state and targeting, equipment and
|
||||
inventory, and the replication surface of each. Source addresses stay in the
|
||||
research archive that produced this skill; each `RC-` identifier resolves back to
|
||||
the audited location there.
|
||||
|
||||
## Evidence boundary
|
||||
|
||||
No profiler was run and no allocator instrumentation was used. Every finding is
|
||||
structural, derived from reading; the growth characterisations are arguments
|
||||
about code shape, not measurements. Quantities such as pool capacities and buffer
|
||||
sizes are declared values read from source. Re-run the recipes and the two
|
||||
scenarios above against your own tree before acting on any of it.
|
||||
Reference in New Issue
Block a user