Add revisioned retained Box3D queries

This commit is contained in:
Meta Vens 2026-07-14 22:04:22 +01:00
commit d854d9376f
6 changed files with 199 additions and 12 deletions

View file

@ -61,9 +61,10 @@ mutations, force and torque commands, revisioned receipts, retained stepping,
and typed Begin/Hit/End facts. It never treats omission as removal. The daemon
still lacks a named session registry and CultMesh command lowering. Aetheria
will name one session per run and zone through that daemon contract; its old
process-wide simulator objects are not session identifiers. Retained-world
queries, checkpoint reconstruction, and non-Windows RID artifacts remain
cutover work.
process-wide simulator objects are not session identifiers. Revision-checked
retained-session circle overlap and cast queries observe the current stable-id
body projection without mutating it. Checkpoint reconstruction and non-Windows
RID artifacts remain cutover work.
See [the architecture map](docs/architecture.md) and
[the executable parity contract](docs/box3d-parity.md). The

View file

@ -43,7 +43,7 @@ Begin/Hit/End facts with stable Ymir identities. Ordinary retained steps do not
accept complete body snapshots.
The current CLI still has no named session registry, CultMesh command
lowerings, retained-world queries, durable receipt ledger, or reconstruction
lowerings, durable receipt ledger, or reconstruction
checkpoint. Its CultCache and Eve output is a regenerated diagnostic
projection. The sections marked as target contract describe that daemon cut;
they are not advertisements of current CLI capability.
@ -119,10 +119,11 @@ infer retained identity from a body list.
Session identity must be explicit. A process-wide simulator object, zone index,
or repeated stable body id is not enough to join two calls to the same world.
Aetheria owns run and zone lifetime and will create, retain, and dispose the
corresponding Ymir session after daemon registry integration. Current projectile
sweep and stationary mine proximity integration can use Ymir's static
Box3D-backed circle-cast and overlap queries, but those calls do not join a
retained world or own contact lifecycle.
corresponding Ymir session after daemon registry integration. Projectile sweep
and stationary mine proximity integration can use Ymir's revision-checked
retained-session circle-cast and overlap queries. They observe the current
stable-id body projection; they do not invent a contact episode or mutate the
Box3D world.
Radial gravity and tractor attraction are typed force-generation inputs. They
are applied to Box3D before stepping. Pickup collection remains an Aetheria

View file

@ -43,9 +43,11 @@ The retained port accepts narrow operations with explicit meaning:
- step with a fixed delta, substep count, and typed fields;
- dispose the session.
The current public slice implements those mutation and step operations.
Retained-world overlap and cast queries at a named revision are the next
library cut; the static compatibility queries do not satisfy that contract.
The current public slice implements those mutation and step operations plus
revision-checked circle overlap and cast queries. A query observes the current
retained body projection under the session lock and accepts an explicit set of
stable candidate body ids. The candidate set is product-owned eligibility;
Box3D still owns the overlap/cast geometry and numeric result.
Bullet, collision-filter, and field-participation profiles are currently
spawn/restore definitions. Retained `Configure` changes radius, mass,
static/kinematic/dynamic type, and restitution only. Changing the profile
@ -130,7 +132,7 @@ is derived process state: it never appears in a public fact or checkpoint.
1. Typed native mutation operations and step-owned lifecycle buffers: done.
2. Public managed retained session with no ordinary full synchronization:
done.
3. Add retained-world queries and revision checks.
3. Revision-checked retained-session overlap and cast queries: done.
4. Add the daemon session registry, generation-bearing routed commands,
idempotent Create, bounded live dedupe backed by durable receipts, and
CultMesh command lowering.

View file

@ -112,6 +112,50 @@ public sealed record YmirSessionStepResult(
long StepIndex,
IReadOnlyList<YmirContactFact> ContactFacts);
public sealed record YmirSessionCircleCastQuery(
string QueryId,
long ExpectedRevision,
Vec2 Origin,
Vec2 Direction,
float Distance,
float Radius,
IReadOnlyList<string> CandidateBodyIds);
public sealed record YmirSessionCircleOverlapQuery(
string QueryId,
long ExpectedRevision,
Vec2 Center,
float Radius,
IReadOnlyList<string> CandidateBodyIds);
public enum YmirQueryError
{
None,
StaleRevision,
UnknownBody,
InvalidQuery,
SessionDisposed,
SessionFaulted
}
public sealed record YmirSessionCircleCastResult(
string QueryId,
string SessionId,
string SessionGeneration,
long ObservedRevision,
long ObservedStepIndex,
YmirQueryError Error,
IReadOnlyList<CircleCastHit> Hits);
public sealed record YmirSessionCircleOverlapResult(
string QueryId,
string SessionId,
string SessionGeneration,
long ObservedRevision,
long ObservedStepIndex,
YmirQueryError Error,
IReadOnlyList<CircleOverlapHit> Hits);
public sealed record YmirSessionInfo(
string SessionId,
string SessionGeneration,

View file

@ -409,6 +409,59 @@ public sealed class YmirSession : IDisposable
}
}
public YmirSessionCircleCastResult CastCircle(YmirSessionCircleCastQuery query)
{
ArgumentNullException.ThrowIfNull(query);
lock (_gate)
{
var error = QueryPreflight(query.QueryId, query.ExpectedRevision, query.CandidateBodyIds, out var bodies);
if (error != YmirQueryError.None)
{
return new YmirSessionCircleCastResult(
query.QueryId ?? "", _sessionId, _sessionGeneration, _revision, _stepIndex, error, []);
}
try
{
var result = YmirQueries.CastCircle(new CircleCastQueryRequest(
query.Origin, query.Direction, query.Distance, query.Radius, bodies));
return new YmirSessionCircleCastResult(
query.QueryId, _sessionId, _sessionGeneration, _revision, _stepIndex, YmirQueryError.None, result.Hits);
}
catch (ArgumentException)
{
return new YmirSessionCircleCastResult(
query.QueryId ?? "", _sessionId, _sessionGeneration, _revision, _stepIndex, YmirQueryError.InvalidQuery, []);
}
}
}
public YmirSessionCircleOverlapResult OverlapCircle(YmirSessionCircleOverlapQuery query)
{
ArgumentNullException.ThrowIfNull(query);
lock (_gate)
{
var error = QueryPreflight(query.QueryId, query.ExpectedRevision, query.CandidateBodyIds, out var bodies);
if (error != YmirQueryError.None)
{
return new YmirSessionCircleOverlapResult(
query.QueryId ?? "", _sessionId, _sessionGeneration, _revision, _stepIndex, error, []);
}
try
{
var result = YmirQueries.OverlapCircle(new CircleOverlapQueryRequest(query.Center, query.Radius, bodies));
return new YmirSessionCircleOverlapResult(
query.QueryId, _sessionId, _sessionGeneration, _revision, _stepIndex, YmirQueryError.None, result.Hits);
}
catch (ArgumentException)
{
return new YmirSessionCircleOverlapResult(
query.QueryId ?? "", _sessionId, _sessionGeneration, _revision, _stepIndex, YmirQueryError.InvalidQuery, []);
}
}
}
public YmirCommandReceipt Dispose(YmirDisposeSessionCommand command)
{
ArgumentNullException.ThrowIfNull(command);
@ -458,6 +511,48 @@ public sealed class YmirSession : IDisposable
});
}
private YmirQueryError QueryPreflight(
string? queryId,
long expectedRevision,
IReadOnlyList<string>? candidateBodyIds,
out PhysicsBody[] bodies)
{
bodies = [];
if (_faulted)
{
return YmirQueryError.SessionFaulted;
}
if (_disposed)
{
return YmirQueryError.SessionDisposed;
}
if (string.IsNullOrWhiteSpace(queryId) || expectedRevision < 0 || candidateBodyIds == null)
{
return YmirQueryError.InvalidQuery;
}
if (expectedRevision != _revision)
{
return YmirQueryError.StaleRevision;
}
var seen = new HashSet<string>(StringComparer.Ordinal);
var selected = new List<PhysicsBody>(candidateBodyIds.Count);
foreach (var bodyId in candidateBodyIds)
{
if (string.IsNullOrWhiteSpace(bodyId) || !seen.Add(bodyId))
{
return YmirQueryError.InvalidQuery;
}
if (!_bodies.TryGetValue(bodyId, out var body))
{
return YmirQueryError.UnknownBody;
}
selected.Add(body);
}
bodies = selected.ToArray();
return YmirQueryError.None;
}
private IReadOnlyList<YmirContactFact> ProjectFacts(IReadOnlyList<Box3DContactFact> nativeFacts)
{
var projected = new List<ProjectedFact>(nativeFacts.Count);

View file

@ -258,6 +258,50 @@ public sealed class YmirSessionTests
Assert.Equal(Vec2.Zero, target.Position);
}
[Fact]
public void RetainedCastObservesNamedRevisionAndExplicitCandidates()
{
using var session = YmirSession.Create(new YmirSessionCreateRequest(
"retained-cast",
[Body("source", positionX: -2.0f), Body("target", positionX: 2.0f), Body("ignored", positionX: 0.0f)]));
var result = session.CastCircle(new YmirSessionCircleCastQuery(
"payload-sweep",
0,
new Vec2(-5.0f, 0.0f),
new Vec2(1.0f, 0.0f),
10.0f,
0.25f,
["target"]));
Assert.Equal(YmirQueryError.None, result.Error);
Assert.Equal(0, result.ObservedRevision);
Assert.Equal(0, result.ObservedStepIndex);
Assert.Equal("target", Assert.Single(result.Hits).BodyId);
Assert.Equal(0, session.Info.Revision);
}
[Fact]
public void RetainedQueriesFailClosedForStaleUnknownAndDisposedSessions()
{
var session = YmirSession.Create(new YmirSessionCreateRequest("query-failures", [Body("target")]));
var stale = session.OverlapCircle(new YmirSessionCircleOverlapQuery(
"stale", 1, Vec2.Zero, 1.0f, ["target"]));
var unknown = session.OverlapCircle(new YmirSessionCircleOverlapQuery(
"unknown", 0, Vec2.Zero, 1.0f, ["missing"]));
session.Dispose();
var disposed = session.OverlapCircle(new YmirSessionCircleOverlapQuery(
"disposed", 0, Vec2.Zero, 1.0f, ["target"]));
Assert.Equal(YmirQueryError.StaleRevision, stale.Error);
Assert.Equal(YmirQueryError.UnknownBody, unknown.Error);
Assert.Equal(YmirQueryError.SessionDisposed, disposed.Error);
Assert.Empty(stale.Hits);
Assert.Empty(unknown.Hits);
Assert.Empty(disposed.Hits);
}
private static YmirCommandHeader Header(string id, long revision) => new(id, revision);
private static long BodyGeneration(YmirContactFact fact, string bodyId) =>