Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- `docs/class-registration.md` - ServiceCollection/ServiceProvider API: registration overloads, source generator requirements, lifecycle, aliases, multi-registration, parent/child provider callback merging and scoped lifecycles
- `docs/events.md` - Pixely.Events EventBus, event handlers, publishing, and DI auto-subscription
- `docs/observations.md` - Pixely.Observations: the log, its writer and its readers, trimming behind the slowest reader, capacity and stall detection, registration
- `docs/static-factory-methods.md` - Static Create() method pattern
- `docs/componentize.md` - Pixely.Componentize setup and usage
- `docs/components.md` - GameWorld, GameObject, GameComponent lifecycle, Services<T>, UpdateSystem
Expand Down
2 changes: 2 additions & 0 deletions Pixely.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<Project Path="src/Pixely.Audio/Pixely.Audio.csproj" />
<Project Path="src/Pixely.Collections/Pixely.Collections.csproj" />
<Project Path="src/Pixely.Core/Pixely.Core.csproj" />
<Project Path="src/Pixely.Observations/Pixely.Observations.csproj" />
<Project Path="src/Pixely.Componentize/Pixely.Componentize.csproj" />
<Project Path="src/Pixely.Events/Pixely.Events.csproj" />
<Project Path="src/Pixely.SdlangCompiler/Pixely.SdlangCompiler.csproj" />
Expand All @@ -27,6 +28,7 @@
<Folder Name="/tests/">
<Project Path="tests/Pixely.PathFinding.Tests/Pixely.PathFinding.Tests.csproj" />
<Project Path="tests/Pixely.Collections.Tests/Pixely.Collections.Tests.csproj" />
<Project Path="tests/Pixely.Observations.Tests/Pixely.Observations.Tests.csproj" />
<Project Path="tests/Pixely.Componentize.Tests/Pixely.Componentize.Tests.csproj" />
<Project Path="tests/Pixely.Content.Tests/Pixely.Content.Tests.csproj" />
<Project Path="tests/Pixely.SdlangBuildIntegration.Tests/Pixely.SdlangBuildIntegration.Tests.csproj" />
Expand Down
130 changes: 130 additions & 0 deletions docs/observations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Observations

`Pixely.Observations` is an append-only log of entries that readers drain at their own pace. Rules append;
nothing is called back. A reader holds its own position, reads when it suits its own point in the frame, and
the log drops an entry once every reader has passed it.

Use it for a transition that two reads of game state one frame apart cannot derive: a unit that walked a path
and arrived, one that appeared and was gone again, an action that resolved and was reversed inside the same
frame. State answers what is true now, and a rule can resolve many transitions between two reads, so any
"most recent transition" field is overwritten before a reader looks at it.

Three types, each with one job:

- `ObservationLog<TEntry>` is the storage. It is constructed and then handed to the other two; it has no other
public members.
- `ObservationWriter<TEntry>` appends. It cannot read.
- `ObservationReader<TEntry>` is one reader's position in the log. It cannot append.

So a rule holding a writer has no way to drain the log, and a reader has no way to record an observation no
rule produced.

The log belongs to one frame loop and is not thread safe. Appending, reading and constructing a reader all
happen on the same thread.

## The entry type

One log carries one entry type. To carry several kinds of entry in one order, make `TEntry` a tagged type; the
log never looks inside it. A value type is the one to reach for, because appending it costs no allocation.

```csharp
public readonly record struct UnitMovedEntry(UnitId Unit, TilePoint From, TilePoint To);
public readonly record struct UnitDiedEntry(UnitId Unit);

public enum ObservationKind { UnitMoved, UnitDied }

public readonly record struct Observation(ObservationKind Kind, ParticipantId Perceiver, UnitMovedEntry Moved, UnitDiedEntry Died);
```

Entries are past-tense records of ids and value types, never a live reference into game state. With a value
entry nothing allocates per entry: the log stores `TEntry` inline in an array, `Append` takes it by `in`, and
`TryRead` copies it out.

`TEntry` may be a class where that suits the game better, and the log releases each slot as it trims so a
drained entry is not held alive. It costs an allocation per append, so it does not belong on a path that
appends every frame.

## Writing

Inject `ObservationWriter<TEntry>` where rules record what happened:

```csharp
internal sealed class MoveMechanic
{
private readonly ObservationWriter<Observation> _observations;

internal MoveMechanic(ObservationWriter<Observation> observations) => _observations = observations;

internal void Move(UnitId unit, TilePoint destination)
{
// mutate state, then record what happened
_observations.Append(new Observation(ObservationKind.UnitMoved, perceiver, moved, default));
}
}
```

## Reading

Inject the log, construct a reader named after the consumer, and drain it in the consumer's own update. Dispose
the reader with the consumer:

```csharp
internal sealed class UnitSpritePresenter : IUpdatable, IDisposable
{
private readonly ObservationReader<Observation> _observations;

internal UnitSpritePresenter(ObservationLog<Observation> log)
{
_observations = new ObservationReader<Observation>(log, nameof(UnitSpritePresenter));
}

public void Update()
{
while (_observations.TryRead(out Observation observation))
{
// skip what another participant perceived, then switch on the tag
}
}

public void Dispose() => _observations.Dispose();
}
```

Each consumer constructs its own reader rather than being handed one, because every reader of a given log is
the same closed type and the container resolves by type. Constructing it also lets the consumer name it.

A reader starts positioned after the last appended entry, so a consumer created part-way through a run sees
only what is appended from then on. Readers drain independently: entries appended this frame may be drained by
one reader now and by another several frames later.

Addressing an entry to a subset of readers is the game's business, not the log's. Carry the perceiver in
`TEntry` and have the reader skip what it did not perceive. A consumer bound to one participant should wrap its
reader once rather than repeat the check in every place that drains.

## Bounds and stalls

The buffer starts small and doubles towards the maximum capacity given to the constructor. It never shrinks,
so it settles at the high-water mark of its bursts and is reclaimed when the log's scope dies.

Maximum capacity is a stall detector, not a working size. Pick a number far past any legitimate burst, knowing
a slot costs the size of `TEntry`. A reader that stops draining holds the trim point where it stopped; once the
log fills, appending throws and names the reader that stopped and how far behind it is. Dropping the oldest
entry instead would hide exactly that failure.

```text
Observation log reached its maximum capacity of 4096 entries. Reader 'UnitSpritePresenter' stopped draining
4096 entries ago.
```

## Registration

Construct the log, then register it alongside a writer over it:

```csharp
ObservationLog<Observation> log = new ObservationLog<Observation>(4096);
services.AddSingleton(log);
services.AddSingleton(new ObservationWriter<Observation>(log));
```

Register it in the scope it belongs to. A log registered in a stage's child provider dies with that stage, and
nothing carries into the next one.
1 change: 1 addition & 0 deletions packaging/Pixely/Pixely.Package.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
<ProjectReference Include="..\..\src\Pixely.DependencyInjection\Pixely.DependencyInjection.csproj" ReferenceOutputAssembly="false" PrivateAssets="all" />
<ProjectReference Include="..\..\src\Pixely.Events\Pixely.Events.csproj" ReferenceOutputAssembly="false" PrivateAssets="all" />
<ProjectReference Include="..\..\src\Pixely.Logging\Pixely.Logging.csproj" ReferenceOutputAssembly="false" PrivateAssets="all" />
<ProjectReference Include="..\..\src\Pixely.Observations\Pixely.Observations.csproj" ReferenceOutputAssembly="false" PrivateAssets="all" />
<ProjectReference Include="..\..\src\Pixely.Pencuil\Pixely.Pencuil.csproj" ReferenceOutputAssembly="false" PrivateAssets="all" />
<ProjectReference Include="..\..\src\Pixely.ShaderCommon\Pixely.ShaderCommon.csproj" ReferenceOutputAssembly="false" PrivateAssets="all" />
<ProjectReference Include="..\..\src\Pixely.Ui\Pixely.Ui.csproj" ReferenceOutputAssembly="false" PrivateAssets="all" />
Expand Down
169 changes: 169 additions & 0 deletions src/Pixely.Observations/ObservationLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;

namespace Pixely.Observations;

/// <summary>
/// An append-only log of entries that readers drain at their own pace. Appending never calls a reader. An entry
/// is dropped once every reader has passed it, so the log is bounded by the slowest reader rather than by how
/// long the run lasts.
/// </summary>
/// <remarks>
/// The log is storage and nothing else: it is reached through an <see cref="ObservationWriter{TEntry}"/> or an
/// <see cref="ObservationReader{TEntry}"/>, so neither role can do the other's job. It belongs to one frame
/// loop and is not thread safe: appending, reading and constructing a reader must all happen on the same thread.
/// </remarks>
/// <typeparam name="TEntry">
/// The single entry type of this log; the log never looks inside it. Carry several kinds of entry in one log
/// by making it a tagged type. A value type keeps appending free of allocation, which is why the entries a
/// game appends every frame should be one.
/// </typeparam>
public sealed class ObservationLog<TEntry>
{
private const int InitialCapacity = 16;

private readonly int _maximumCapacity;
private readonly List<ObservationReader<TEntry>> _readers = new();
private TEntry[] _entries;
private int _head;
private int _count;
private long _firstSequence;
private long _nextSequence;

/// <param name="maximumCapacity">
/// How many entries the log may retain before appending throws. The buffer starts small and grows towards
/// this bound, so it is a stall detector rather than a working size: pick a number far past any legitimate
/// burst, knowing a slot costs the size of <typeparamref name="TEntry"/>.
/// </param>
public ObservationLog(int maximumCapacity)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCapacity, 1);
_maximumCapacity = maximumCapacity;
_entries = new TEntry[Math.Min(InitialCapacity, maximumCapacity)];
}

internal void Append(in TEntry entry)
{
if (_count == _maximumCapacity)
{
throw new InvalidOperationException(DescribeOverflow());
}

EnsureCapacity(_count + 1);
_entries[PhysicalIndex(_count)] = entry;
_count++;
_nextSequence++;
Trim();
}

internal bool TryRead(ObservationReader<TEntry> reader, [MaybeNullWhen(false)] out TEntry entry)
{
int offset = checked((int)(reader.NextSequence - _firstSequence));
if (offset >= _count)
{
entry = default;
return false;
}

entry = _entries[PhysicalIndex(offset)];
reader.NextSequence++;
Trim();
return true;
}

// A reader starts after the last appended entry, so it sees only what is appended from now on.
internal void AddReader(ObservationReader<TEntry> reader)
{
reader.NextSequence = _nextSequence;
_readers.Add(reader);
}

internal void RemoveReader(ObservationReader<TEntry> reader)
{
_readers.Remove(reader);
Trim();
}

private void Trim()
{
if (_count == 0)
{
return;
}

int removeCount = checked((int)(SlowestSequence() - _firstSequence));
if (removeCount == 0)
{
return;
}

// Only worth clearing when a slot can keep an object alive; the check folds away for the rest. A cleared
// slot is past every reader, so nothing reads it back before an append overwrites it.
if (RuntimeHelpers.IsReferenceOrContainsReferences<TEntry>())
{
for (int i = 0; i < removeCount; i++)
{
_entries[PhysicalIndex(i)] = default!;
}
}

_head = PhysicalIndex(removeCount);
_count -= removeCount;
_firstSequence += removeCount;
}

private long SlowestSequence()
{
long slowest = _nextSequence;
foreach (ObservationReader<TEntry> reader in _readers)
{
if (reader.NextSequence < slowest)
{
slowest = reader.NextSequence;
}
}

return slowest;
}

private void EnsureCapacity(int requiredCapacity)
{
if (requiredCapacity <= _entries.Length)
{
return;
}

// Doubling only while it stays under the bound, so the last step lands on the bound instead of overflowing.
int newCapacity = _entries.Length <= _maximumCapacity / 2 ? _entries.Length * 2 : _maximumCapacity;
TEntry[] newEntries = new TEntry[newCapacity];
int untilWrap = Math.Min(_count, _entries.Length - _head);
Array.Copy(_entries, _head, newEntries, 0, untilWrap);
Array.Copy(_entries, 0, newEntries, untilWrap, _count - untilWrap);
_entries = newEntries;
_head = 0;
}

// Wrapping by subtraction rather than by modulo, so a buffer whose head plus offset would pass int range
// still indexes correctly, and the hot path costs a compare instead of a division.
private int PhysicalIndex(int offset)
{
int untilWrap = _entries.Length - _head;
return offset < untilWrap ? _head + offset : offset - untilWrap;
}

private string DescribeOverflow()
{
long slowest = SlowestSequence();
List<string> stalled = new();
foreach (ObservationReader<TEntry> reader in _readers)
{
if (reader.NextSequence == slowest)
{
stalled.Add(reader.Name);
}
}

return $"Observation log reached its maximum capacity of {_maximumCapacity} entries. "
+ $"Reader '{string.Join("', '", stalled)}' stopped draining {_nextSequence - slowest} entries ago.";
}
}
45 changes: 45 additions & 0 deletions src/Pixely.Observations/ObservationReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Diagnostics.CodeAnalysis;

namespace Pixely.Observations;

/// <summary>
/// One reader's own position in an <see cref="ObservationLog{TEntry}"/>. Several readers drain the same log at
/// their own pace, each through its own instance. Dispose it when the reader goes away, otherwise it holds the
/// log's trim point where it stopped and the log fills.
/// </summary>
public sealed class ObservationReader<TEntry> : IDisposable
{
private readonly ObservationLog<TEntry> _log;
private bool _disposed;

/// <param name="name">
/// Identifies this reader when it stops draining and fills the log, so give it the reader's own name.
/// </param>
public ObservationReader(ObservationLog<TEntry> log, string name = "unnamed")
{
_log = log;
Name = name;
log.AddReader(this);
}

public string Name { get; }

internal long NextSequence { get; set; }

public bool TryRead([MaybeNullWhen(false)] out TEntry entry)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _log.TryRead(this, out entry);
}

public void Dispose()
{
if (_disposed)
{
return;
}

_disposed = true;
_log.RemoveReader(this);
}
}
20 changes: 20 additions & 0 deletions src/Pixely.Observations/ObservationWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Pixely.Observations;

/// <summary>
/// Appends to an <see cref="ObservationLog{TEntry}"/>. Inject it where rules record what happened, so nothing
/// that writes can also read.
/// </summary>
public sealed class ObservationWriter<TEntry>
{
private readonly ObservationLog<TEntry> _log;

public ObservationWriter(ObservationLog<TEntry> log)
{
_log = log;
}

public void Append(in TEntry entry)
{
_log.Append(entry);
}
}
Loading