Struct ObservedBuffEntry
One buff on another character, as the SERVER has chosen to show it to observers. Display-only: nothing on the client applies an effect from this.
public struct ObservedBuffEntry : IEquatable<ObservedBuffEntry>
- Implements
- Inherited Members
Remarks
This is deliberately not Buff. A Buff is simulation state — it
carries expiry in the owner's replicate-tick domain, a stack count that drives attribute
modifiers, and a template whose OnApply/OnRemove mutate the character. Handing
that to an observer would either desynchronise the observer's own prediction domain (ticks
mean different things on different clients) or, worse, let a client run buff effects on a
character it does not own.
Duration travels as SECONDS remaining at the moment the server sent it, not as an absolute tick, precisely because the receiving client's tick domain is its own. The observer counts down locally from receipt. That drifts by the one-way latency, which for a bar a few pixels tall on someone else's nameplate is not worth a tick-domain translation.
The struct is wider than the wire. WriteTo(Writer) sends seven bytes; the
in-memory fields stay at their natural widths because every consumer — the FX diff, the
target frame, the party frame — indexes templates by int and draws bars from
float. Narrowing the fields themselves would ripple through all of that and save
nothing, since the wire form is where the bytes are spent.
Fields
MaxEncodableSeconds
Largest remaining/total duration the wire form can carry, in seconds.
public const float MaxEncodableSeconds = 6553.5
Field Value
Remarks
RemainingSeconds travels as deciseconds in a ushort. Just over 109
minutes, against authored buff durations measured in seconds to minutes; anything longer
clamps and displays as a bar that starts full and stays there slightly too long, which is
the same thing a permanent buff already does.
MaxEncodableStacks
Largest stack count the wire form can carry.
public const int MaxEncodableStacks = 255
Field Value
Remarks
BaseBuffTemplate.MaxStacks is authored well below this; the clamp exists so a
runaway stack cannot corrupt the stream, not because 255 stacks is expected.
RemainingSeconds
Seconds remaining when the server sent this, or 0 for a permanent buff.
public float RemainingSeconds
Field Value
Stacks
Stack count above the base application: 0 means one application, 2 means three.
public int Stacks
Field Value
Remarks
MaxStacks is the TOTAL number of applications, so this counts up to
MaxStacks - 1: the base application is the first, and each stack is one more.
Both halves of that were wrong until the 2026-08-29 audit, in the same direction. A
freshly applied stacking buff ran the new-buff branch AND the stack branch, so it arrived
here reporting 1 while having applied its modifier twice; and the cap tested
Stacks < MaxStacks, which let a MaxStacks=3 buff reach four applications.
The first was fixed on 2026-08-28, the second on 2026-08-29 — see
BuffController.ApplyResolved.
TemplateID
The buff template's cached ID.
public int TemplateID
Field Value
TotalSeconds
The buff's full authored duration in seconds, straight from the template — including for a
permanent buff, whose authored Duration is reported as-is. Only RemainingSeconds is
0 for a permanent buff.
public float TotalSeconds
Field Value
Remarks
Not sent. This is BaseBuffTemplate.Duration — authored content the receiver
already holds, not runtime state — so ReadFrom(Reader) resolves it from the
template rather than paying for it once per entry per observer. A template the receiver
cannot resolve yields 0, which every consumer already treats as "no bar to fill".
Methods
DecodeSeconds(ushort)
Deciseconds back to seconds.
public static float DecodeSeconds(ushort deciseconds)
Parameters
decisecondsushort
Returns
EncodeSeconds(float)
Seconds to deciseconds, clamped to the encodable range.
public static ushort EncodeSeconds(float seconds)
Parameters
secondsfloat
Returns
Equals(ObservedBuffEntry)
public bool Equals(ObservedBuffEntry other)
Parameters
otherObservedBuffEntry
Returns
Equals(object)
public override bool Equals(object obj)
Parameters
objobject
Returns
GetHashCode()
public override int GetHashCode()
Returns
ReadFrom(Reader)
Reads one entry, resolving TotalSeconds from the template.
public static ObservedBuffEntry ReadFrom(Reader reader)
Parameters
readerReader
Returns
StructurallyEquals(ObservedBuffEntry)
Structural equality: the fields that make this a DIFFERENT buff rather than the same buff a moment later.
public bool StructurallyEquals(ObservedBuffEntry other)
Parameters
otherObservedBuffEntry
Returns
Remarks
RemainingSeconds is deliberately excluded. It moves every tick on every
buff, so including it would mark the whole list changed on every tick and collapse the
delta back into a full resend. Ordinary countdown needs no message at all: the receiver
counts its own bars down. The one case that does is a buff RENEWED after the receiver was
last told about it, which would otherwise run out locally and be deleted while the
character still holds it — BuffController.ObservedBuffWillLapse catches exactly
that, and answers it with a full set.
WriteTo(Writer)
Writes one entry — seven bytes.
public void WriteTo(Writer writer)
Parameters
writerWriter
Remarks
WriteInt32Unpacked rather than WriteInt32: template ids are a deterministic
32-bit hash (CachedScriptableObject.AddToCache), so they occupy the whole range
and FishNet's signed-packed form would spend FIVE bytes on one. Unpacked is exactly four.