fix(opl): refuse property list invalidation raised from inside GetProperties

Any property getter reached from GetProperties that calls InvalidateProperties
takes the tooltip build down with it:

  System.ArgumentNullException: Value cannot be null. (Parameter 'array')
    at Server.ObjectPropertyList.AppendStringDirect(String value)
    at Server.Mobiles.PlayerMobile.GetProperties(IPropertyList list)

InvalidateProperties rebuilds in place -- Reset() then GetProperties() again on
the same instance -- and Reset() does two destructive things to a build already
in flight. It returns the pooled interpolation scratch buffer, which the compiler
rents in the interpolated-string handler ctor and returns in the closing Add, so
every hole is evaluated while that buffer is live; the next Append* then spans a
null array. It also rewinds the packet cursor, so properties already written are
overwritten by the nested pass.

There is no correct recovery, and retrying the build would only hide the defect,
so the engine refuses: the nested call logs an error with a stack trace, throws
in DEBUG so it gets found and fixed, and in RELEASE returns without touching the
list -- a possibly stale tooltip, but no crash, no corrupted packet, and nothing
leaked back to the pool. Getters that genuinely must invalidate should defer with
Timer.DelayCall(InvalidateProperties).

The guard flag lives on the ObjectPropertyList rather than on the entity: it is
that list's own lifecycle, it costs nothing (both Item and ObjectPropertyList
absorb it in existing padding, and the list is allocated lazily), and it stays
correct when builds for different entities nest.

Factions PlayerState was the getter that surfaced this, and it is now maintained
rather than lazily computed:

- Rank is a plain field read. The lazy `if (m_InvalidateRank)` recompute is gone
  along with the flag itself; UpdateRank() recomputes at each point an input
  actually changes (the RankIndex setter, the end of the KillPoints setter once
  the swap bookkeeping and ZeroRankOffset have settled, Faction.AddMember after
  the member is inserted, and FactionState after a load once the ordering is
  final). All six readers of Rank were checked; none relied on the old side
  effect.
- Both constructors seed the lowest rank. Nothing recomputes on read any more, so
  Rank must be usable immediately -- including for members that never get a
  RankIndex assigned, which is every member with no kill points.
- Rank always resolves. Ranks are ordered by Required descending ending at 0, so
  a negative percent (RankIndex out of sync with ZeroRankOffset) matched nothing
  and left m_Rank null -- an NRE on Rank.Title. It no longer divides by a zero
  ZeroRankOffset either.
- Fixes a pre-existing staleness bug: the KillPoints setter writes m_RankIndex
  directly in two places, bypassing the property setter, so the cached rank was
  never refreshed when a player crossed zero kill points.

PropertyList also publishes the list into m_PropertyList before building it
rather than assigning through `??=` afterwards, so a nested InvalidateProperties
sees the build in progress instead of recursing into a second throwaway list.

ObjectPropertyList re-rents its scratch buffer instead of spanning a null array,
so a stray Reset() from any other caller degrades rather than aborting
GetProperties.

Documents the rule as audit rule 19 in CLAUDE.md, a new section in
dev-docs/property-lists.md, and the property-lists and code-audit skills.
This commit is contained in:
Kamron Batman 2026-07-28 19:41:16 -07:00
parent 967ddf48fa
commit ca338f023c
12 changed files with 531 additions and 45 deletions

View file

@ -191,8 +191,17 @@ mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" not "Gold"
**See**: `dev-docs/string-handling.md` § "Interpolation Anti-Patterns" for the full reference with detailed before/after examples.
### 19. No InvalidateProperties From Inside GetProperties
**Check**: Any property read by a `GetProperties` override — including through helpers — must be a pure read. Flag getters that call `InvalidateProperties()` (or a wrapper like `Invalidate()`) as a side effect.
**Bad**: a `Rank` getter that lazily recomputes and then calls `Invalidate()`; reading it from `GetProperties` re-enters the build.
**Good**: invalidate in the setter that actually changes the value, or defer with `Timer.DelayCall(InvalidateProperties)`.
**Why**: `InvalidateProperties()` rebuilds the list in place (`Reset()` + rebuild). `Reset()` returns the pooled interpolation buffer — which the compiler rents for the whole `$"..."` expression, so every hole is evaluated while it is live — and rewinds the packet cursor. Re-entering mid-build throws `ArgumentNullException` (parameter `"array"`) out of `GetProperties` from a line unrelated to the offending getter, or silently corrupts the tooltip. The engine refuses and logs an error, and `DEBUG` throws, so this shows up as a crash in development.
**Note**: Lazy recomputation inside a getter is fine. It is the notification that must not happen there.
**See**: `dev-docs/property-lists.md` § "Never Invalidate From Inside `GetProperties`".
## Severity Levels
- **ERROR**: Rules 3, 9, 10, 13 (will cause bugs, build failures, or client-side leaks)
- **ERROR**: Rules 3, 9, 10, 13, 19 (will cause bugs, build failures, or client-side leaks)
- **WARNING**: Rules 1 (Tier 3 LINQ), 2, 4, 5, 6, 7, 8, 12, 14, 15, 17 (performance/convention issues)
- **INFO**: Rules 1 (Tier 2 LINQ on warm paths — note it but don't flag as violation), 16 (switch patterns — suggest but don't flag)
- **ASK**: Rule 11 (need user input)

View file

@ -20,6 +20,12 @@ description: >
3. **String interpolation** works with `IPropertyList` -- use `$"..."` syntax
4. **`[InvalidateProperties]`** on `[SerializableField]` auto-refreshes tooltip on change
5. **Call `InvalidateProperties()`** manually when non-serialized state changes tooltip
6. **Never invalidate from inside `GetProperties`** -- every property a `GetProperties` override
reads must be a pure read. `InvalidateProperties()` rebuilds in place (`Reset()` + rebuild), so a
getter with that side effect tears down the list mid-build: it returns the pooled interpolation
buffer under an in-flight `$"..."` handler (`ArgumentNullException`, parameter `"array"`) and
rewinds the packet cursor. The engine refuses and logs an error; `DEBUG` throws. Defer instead:
`Timer.DelayCall(InvalidateProperties)`
## IPropertyList Interface
@ -235,6 +241,7 @@ block.Add("Cannot be repaired".AsSpan()); // plain span, no string alloc
- **Excessive rebuilds**: Don't call `InvalidateProperties()` in tight loops
- **Assuming tooltip support**: Check `ObjectPropertyList.Enabled` if needed
- **One giant `Add()` for multi-line text**: A property over ~512 chars crashes the legacy 2D client. Use `AddChunked`/`OplTextBlock` for variable-length free text
- **Side-effecting property getters**: A getter reached from `GetProperties` that calls `InvalidateProperties()` (directly or via a helper like `Invalidate()`) re-enters the build and is refused — error logged, `DEBUG` throws. Lazy recomputation in a getter is fine; the *notification* is not. Invalidate where the value changes, or `Timer.DelayCall(InvalidateProperties)`
## Real Examples
- Item properties: `Projects/Server/Items/Item.cs` (AddNameProperties, GetProperties)

View file

@ -301,6 +301,66 @@ public void UseCharge()
}
```
### Never Invalidate From Inside `GetProperties` (CRITICAL)
`InvalidateProperties()` rebuilds the list **in place**`Reset()`, then `GetProperties()` again on
the same instance. Calling it from a property getter that the build itself reaches is therefore
re-entrant, and `Reset()` does two destructive things to the build in flight:
1. It returns the pooled interpolation scratch buffer. The compiler rents that buffer in the
interpolated-string handler's constructor and returns it in the closing `Add`, so **every hole is
evaluated while the buffer is live**. Pulling it out mid-append makes the next `Append*` span a
null array — `ArgumentNullException: Value cannot be null. (Parameter 'array')` thrown out of
`GetProperties`, from a line that looks unrelated to the getter that caused it.
2. It rewinds the packet cursor, so properties already written are overwritten by the nested pass.
This is always a defect in the property getter, so the engine refuses rather than trying to recover:
a nested call logs an error with a stack trace, throws in `DEBUG`, and in `RELEASE` returns without
touching the list — leaving a possibly stale tooltip, but never a crash, a corrupted packet, or a
leaked pool buffer. Retrying the build would only hide the bug.
```csharp
// BAD -- a getter with a side effect. Reading it from GetProperties re-enters the build.
public RankDefinition Rank
{
get
{
if (_invalidateRank)
{
_rank = Recompute();
_invalidateRank = false;
Invalidate(); // -> InvalidateProperties() -> Reset() on the list being built
}
return _rank;
}
}
// GOOD -- getters stay side-effect free; invalidate where the value actually changes.
public int RankIndex
{
get => _rankIndex;
set
{
if (_rankIndex != value)
{
_rankIndex = value;
_invalidateRank = true;
Invalidate();
}
}
}
```
Lazy recomputation inside a getter is fine — it is the *notification* that must not happen there. If
something genuinely must invalidate in response to a read, defer it off the build:
```csharp
Timer.DelayCall(InvalidateProperties);
```
**Check when writing a `GetProperties` override**: every property it reads must be a pure read.
## ObjectPropertyList Internals
Defined in `Projects/Server/PropertyList/ObjectPropertyList.cs`: