ModernUO/Projects/Server/PropertyList/ObjectPropertyList.cs
Kamron Batman ca338f023c 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.
2026-07-28 21:20:35 -07:00

654 lines
19 KiB
C#

/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: ObjectPropertyList.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
#nullable enable
using System;
using System.Buffers;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using Server.Buffers;
using Server.Logging;
using Server.Network;
using Server.Text;
namespace Server;
public sealed class ObjectPropertyList : IPropertyList, IDisposable
{
// Each of these are localized to "~1_NOTHING~" which allows the string argument to be used
private static readonly int[] _stringNumbers =
{
1042971,
1070722,
1114057, // ~1_val~
1114778, // ~1_val~
1114779 // ~1_val~
};
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ObjectPropertyList));
// Max characters for a SINGLE OPL property argument. The legacy 2D client copies each
// property's text into a fixed ~512-char (1024-byte) buffer; exceeding it corrupts the heap
// (smashes an adjacent world object's vtable -> client crash). 504 = multiple of 8, safely
// under the empirically confirmed ~510-char ceiling. For multi-line content use AddChunked().
public const int MaxArgumentLength = 504;
private int _hash;
private int _stringNumbersIndex;
private byte[] _buffer;
private int _bufferPos;
// For string interpolation
private int _pos;
private char[]? _arrayToReturnToPool;
/// <summary>
/// True while GetProperties is populating this list. Set by the owning entity so a nested
/// InvalidateProperties can be refused instead of Reset()ing a build already in flight.
/// </summary>
internal bool IsBuilding { get; set; }
public ObjectPropertyList(IEntity? e)
{
Entity = e;
_buffer = GC.AllocateUninitializedArray<byte>(64);
var writer = new SpanWriter(_buffer);
writer.Write((byte)0xD6); // Packet ID
writer.Seek(2, SeekOrigin.Current);
writer.Write((ushort)1);
writer.Write(e?.Serial ?? Serial.Zero);
writer.Write((ushort)0);
_bufferPos = writer.Position + 4; // Hash
}
public IEntity? Entity { get; }
public int Hash => 0x40000000 + _hash;
public int Header { get; set; }
public string HeaderArgs { get; set; }
public static bool Enabled { get; set; }
public byte[] Buffer => _buffer;
public void Reset()
{
_bufferPos = 15;
_hash = 0;
_stringNumbersIndex = 0;
Header = 0;
HeaderArgs = null;
_pos = 0;
Dispose();
}
private void Flush()
{
Resize(_buffer.Length * 2);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void Resize(int amount)
{
var newBuffer = GC.AllocateUninitializedArray<byte>(amount);
_buffer.AsSpan(0, Math.Min(amount, _buffer.Length)).CopyTo(newBuffer);
_buffer = newBuffer;
}
public void Terminate()
{
var length = _bufferPos + 4;
if (length != _buffer.Length)
{
Resize(length);
}
var writer = new SpanWriter(_buffer);
writer.Seek(_bufferPos, SeekOrigin.Begin);
writer.Write(0);
writer.Seek(11, SeekOrigin.Begin);
writer.Write(_hash);
writer.WritePacketLength();
}
private void AddHash(int val)
{
_hash ^= val & 0x3FFFFFF;
_hash ^= (val >> 26) & 0x3F;
}
public void Add(int number)
{
if (number == 0)
{
return;
}
if (Header == 0)
{
Header = number;
HeaderArgs = "";
}
AddHash(number);
var length = _bufferPos + 6;
while (length > _buffer.Length)
{
Flush();
}
var writer = new SpanWriter(_buffer.AsSpan(_bufferPos));
writer.Write(number);
writer.Write((ushort)0);
_bufferPos += 6;
}
public void Add(int number, string? arguments) => InternalAdd(number, $"{arguments}");
public void Add(int number, int value) => InternalAdd(number, $"{value}");
public void AddLocalized(int value) => InternalAdd(GetStringNumber(), $"{value:#}");
public void AddLocalized(int number, int value) => InternalAdd(number, $"{value:#}");
public void Add(ReadOnlySpan<char> argument) => InternalAdd(GetStringNumber(), argument);
public void Add(int number, ReadOnlySpan<char> argument) => InternalAdd(number, argument);
public OplTextBlock TextBlock() => new(this);
// Emits newline-joined text across as many OPL properties as needed, breaking ONLY at '\n',
// so no single property exceeds MaxArgumentLength characters. Each chunk goes through the
// passthrough-cliloc rotation. Use for variable-length multi-line content instead of one
// Add(joined) call, which would overflow the legacy 2D-client per-property tooltip buffer.
public void AddChunked(ReadOnlySpan<char> text)
{
if (text.IsEmpty)
{
return;
}
var chunkStart = 0;
var searchFrom = 0;
while (true)
{
var nl = text[searchFrom..].IndexOf('\n');
var lineEnd = nl < 0 ? text.Length : searchFrom + nl;
// If appending this line would push the current chunk past the cap, flush the chunk
// up to the end of the previous line (excluding its '\n') first.
if (lineEnd - chunkStart > MaxArgumentLength && searchFrom > chunkStart)
{
Add(text[chunkStart..(searchFrom - 1)]);
chunkStart = searchFrom;
continue;
}
if (nl < 0)
{
Add(text[chunkStart..]); // remainder (a single over-cap line is clamped by Add)
return;
}
searchFrom = lineEnd + 1;
}
}
// Hard backstop: never let a single property exceed the legacy client's per-property buffer.
// Callers with multi-line content should use AddChunked(); this truncates anything that slips
// through and surfaces the offending entity/cliloc.
private ReadOnlySpan<char> ClampArgument(int number, ReadOnlySpan<char> chars)
{
if (chars.Length <= MaxArgumentLength)
{
return chars;
}
logger.Warning(
"OPL property on {Entity} (cliloc {Cliloc}) is {Length} chars; truncating to {Max} to avoid legacy 2D-client tooltip-buffer overflow. Use AddChunked for multi-line text.",
Entity,
number,
chars.Length,
MaxArgumentLength
);
return chars[..MaxArgumentLength];
}
private void InternalAdd(int number, ReadOnlySpan<char> chars)
{
if (number == 0)
{
return;
}
chars = ClampArgument(number, chars);
if (Header == 0)
{
Header = number;
HeaderArgs = chars.ToString();
}
AddHash(number);
AddHash(string.GetHashCode(chars, StringComparison.Ordinal));
var strLength = chars.Length * 2;
var length = _bufferPos + 6 + strLength;
while (length > _buffer.Length)
{
Flush();
}
var writer = new SpanWriter(_buffer.AsSpan(_bufferPos));
writer.Write(number);
writer.Write((ushort)strLength);
writer.Write(chars, TextEncoding.UnicodeLE);
_bufferPos += writer.BytesWritten;
}
private int GetStringNumber() => _stringNumbers[_stringNumbersIndex++ % _stringNumbers.Length];
// String Interpolation
public void Add(
[InterpolatedStringHandlerArgument("")]
ref IPropertyList.InterpolatedStringHandler handler
) => InternalAdd(GetStringNumber(), ref handler);
public void Add(
int number,
[InterpolatedStringHandlerArgument("")]
ref IPropertyList.InterpolatedStringHandler handler
) => InternalAdd(number, ref handler);
private void InternalAdd(
int number,
[InterpolatedStringHandlerArgument("")]
ref IPropertyList.InterpolatedStringHandler handler)
{
if (number == 0)
{
return;
}
var chars = ClampArgument(number, _arrayToReturnToPool.AsSpan(0, _pos));
if (Header == 0)
{
Header = number;
HeaderArgs = chars.ToString();
}
AddHash(number);
AddHash(string.GetHashCode(chars, StringComparison.Ordinal));
var strLength = chars.Length * 2;
var length = _bufferPos + 6 + strLength;
while (length > _buffer.Length)
{
Flush();
}
var writer = new SpanWriter(_buffer.AsSpan(_bufferPos));
writer.Write(number);
writer.Write((ushort)strLength);
writer.Write(chars, TextEncoding.UnicodeLE);
_bufferPos += writer.BytesWritten;
}
public void InitializeInterpolation(int literalLength, int formattedCount)
{
_arrayToReturnToPool ??= STArrayPool<char>.Shared.Rent(GetDefaultLength(literalLength, formattedCount));
_pos = 0;
}
// Copied from RawInterpolatedStringHandler
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int GetDefaultLength(int literalLength, int formattedCount) =>
Math.Max(256, literalLength + formattedCount * 11);
// Reset()/Dispose() return the scratch buffer to the pool. If either lands while a `$"..."`
// handler is still appending, re-rent rather than spanning a null array and throwing out of
// GetProperties. Mobile/Item hold the primary guard; this covers any other caller.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void EnsureInterpolationBuffer()
{
if (_arrayToReturnToPool == null)
{
_arrayToReturnToPool = STArrayPool<char>.Shared.Rent(256);
_pos = 0;
}
}
public void AppendLiteral(string value)
{
EnsureInterpolationBuffer();
if (value.Length == 1)
{
var chars = _arrayToReturnToPool.AsSpan();
var pos = _pos;
if ((uint)pos < (uint)chars.Length)
{
chars[pos] = value[0];
_pos = pos + 1;
}
else
{
GrowThenCopyString(value);
}
return;
}
AppendStringDirect(value);
}
private void AppendStringDirect(string value)
{
if (value.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)))
{
_pos += value.Length;
}
else
{
GrowThenCopyString(value);
}
}
public void AppendFormatted<T>(T value)
{
EnsureInterpolationBuffer();
string? s;
if (value is IFormattable)
{
if (value is ISpanFormattable)
{
int charsWritten;
while (!((ISpanFormattable)value).TryFormat(_arrayToReturnToPool.AsSpan(_pos..), out charsWritten, default, null))
{
Grow();
}
_pos += charsWritten;
return;
}
s = ((IFormattable)value).ToString(format: null, null);
}
else
{
s = value?.ToString();
}
if (s is not null)
{
AppendStringDirect(s);
}
}
public void AppendFormatted<T>(T value, string? format)
{
EnsureInterpolationBuffer();
// '#' marks an integer argument as a cliloc ("#<value>"). Integers only -- a float/double/decimal
// '#' is the standard numeric format, not a cliloc marker.
if (format == "#" && value is int or uint or long or ulong or short or ushort or byte or sbyte)
{
AppendLiteral("#");
format = null;
}
string? s;
if (value is IFormattable)
{
if (value is ISpanFormattable)
{
int charsWritten;
while (!((ISpanFormattable)value).TryFormat(_arrayToReturnToPool.AsSpan(_pos..), out charsWritten, format, null))
{
Grow();
}
_pos += charsWritten;
return;
}
s = ((IFormattable)value).ToString(format, null);
}
else
{
s = value?.ToString();
}
if (s is not null)
{
AppendStringDirect(s);
}
}
public void AppendFormatted<T>(T value, int alignment)
{
var startingPos = _pos;
AppendFormatted(value);
if (alignment != 0)
{
AppendOrInsertAlignmentIfNeeded(startingPos, alignment);
}
}
public void AppendFormatted<T>(T value, int alignment, string? format)
{
var startingPos = _pos;
AppendFormatted(value, format);
if (alignment != 0)
{
AppendOrInsertAlignmentIfNeeded(startingPos, alignment);
}
}
public void AppendFormatted(ReadOnlySpan<char> value)
{
EnsureInterpolationBuffer();
if (value.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)))
{
_pos += value.Length;
}
else
{
GrowThenCopySpan(value);
}
}
public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null)
{
EnsureInterpolationBuffer();
var leftAlign = false;
if (alignment < 0)
{
leftAlign = true;
alignment = -alignment;
}
var paddingRequired = alignment - value.Length;
if (paddingRequired <= 0)
{
AppendFormatted(value);
return;
}
EnsureCapacityForAdditionalChars(value.Length + paddingRequired);
var chars = _arrayToReturnToPool.AsSpan();
if (leftAlign)
{
value.CopyTo(chars[_pos..]);
_pos += value.Length;
chars.Slice(_pos, paddingRequired).Fill(' ');
_pos += paddingRequired;
}
else
{
chars.Slice(_pos, paddingRequired).Fill(' ');
_pos += paddingRequired;
value.CopyTo(chars[_pos..]);
_pos += value.Length;
}
}
public void AppendFormatted(string? value)
{
EnsureInterpolationBuffer();
if (value?.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)) == true)
{
_pos += value.Length;
}
else
{
AppendFormattedSlow(value);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void AppendFormattedSlow(string? value)
{
if (value is not null)
{
EnsureCapacityForAdditionalChars(value.Length);
value.CopyTo(_arrayToReturnToPool.AsSpan(_pos..));
_pos += value.Length;
}
}
public void AppendFormatted(string? value, int alignment = 0, string? format = null) =>
AppendFormatted<string?>(value, alignment, format);
public void AppendFormatted(object? value, int alignment = 0, string? format = null) =>
AppendFormatted<object?>(value, alignment, format);
private void AppendOrInsertAlignmentIfNeeded(int startingPos, int alignment)
{
Debug.Assert(startingPos >= 0 && startingPos <= _pos);
Debug.Assert(alignment != 0);
var charsWritten = _pos - startingPos;
var leftAlign = false;
if (alignment < 0)
{
leftAlign = true;
alignment = -alignment;
}
var paddingNeeded = alignment - charsWritten;
if (paddingNeeded > 0)
{
EnsureCapacityForAdditionalChars(paddingNeeded);
var chars = _arrayToReturnToPool.AsSpan();
if (leftAlign)
{
chars.Slice(_pos, paddingNeeded).Fill(' ');
}
else
{
chars.Slice(startingPos, charsWritten).CopyTo(chars[(startingPos + paddingNeeded)..]);
chars.Slice(startingPos, paddingNeeded).Fill(' ');
}
_pos += paddingNeeded;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void EnsureCapacityForAdditionalChars(int additionalChars)
{
if (_arrayToReturnToPool.Length - _pos < additionalChars)
{
Grow(additionalChars);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void GrowThenCopyString(string value)
{
Grow(value.Length);
value.CopyTo(_arrayToReturnToPool.AsSpan(_pos..));
_pos += value.Length;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void GrowThenCopySpan(ReadOnlySpan<char> value)
{
Grow(value.Length);
value.CopyTo(_arrayToReturnToPool.AsSpan(_pos..));
_pos += value.Length;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void Grow(int additionalChars)
{
Debug.Assert(additionalChars > _arrayToReturnToPool.Length - _pos);
GrowCore((uint)_pos + (uint)additionalChars);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private void Grow()
{
GrowCore((uint)_arrayToReturnToPool.Length + 1);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void GrowCore(uint requiredMinCapacity)
{
var newCapacity = Math.Max(requiredMinCapacity, Math.Min((uint)_arrayToReturnToPool.Length * 2, 0x3FFFFFDF));
var arraySize = (int)Math.Clamp(newCapacity, 256, int.MaxValue);
var newArray = STArrayPool<char>.Shared.Rent(arraySize);
_arrayToReturnToPool.AsSpan(.._pos).CopyTo(newArray);
var toReturn = _arrayToReturnToPool;
_arrayToReturnToPool = newArray;
STArrayPool<char>.Shared.Return(toReturn);
}
public void Dispose()
{
if (_arrayToReturnToPool != null)
{
STArrayPool<char>.Shared.Return(_arrayToReturnToPool);
_arrayToReturnToPool = null;
}
}
~ObjectPropertyList()
{
if (_arrayToReturnToPool != null)
{
STArrayPool<char>.Shared.Return(_arrayToReturnToPool);
_arrayToReturnToPool = null;
}
}
}