refactor(streaming): compose landblock presentation transaction
Own render, physics, DAT-static presentation, and retained static-resource rebinds behind retryable receipts so a failed publication resumes its exact unfinished suffix without replaying retail create-time defaults. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
e1110f7e54
commit
ea0da8c8ae
15 changed files with 1744 additions and 89 deletions
|
|
@ -0,0 +1,346 @@
|
|||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.Plugins;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Retained receipt for the DAT-static presentation stages of one landblock
|
||||
/// publication. The physics publisher calls the light/translucency stage at
|
||||
/// the existing per-object point immediately before ordinary collision.
|
||||
/// </summary>
|
||||
public sealed class LandblockStaticPresentationPublication
|
||||
{
|
||||
private readonly IReadOnlyDictionary<uint, WorldEntity> _entities;
|
||||
private readonly IReadOnlyDictionary<uint, WorldEntitySnapshot> _snapshots;
|
||||
|
||||
internal LandblockStaticPresentationPublication(
|
||||
object owner,
|
||||
LandblockPhysicsPublication physicsPublication,
|
||||
Dictionary<uint, WorldEntity> entities,
|
||||
Dictionary<uint, WorldEntitySnapshot> snapshots,
|
||||
HashSet<uint> previouslyActiveIds)
|
||||
{
|
||||
Owner = owner;
|
||||
PhysicsPublication = physicsPublication;
|
||||
_entities = entities;
|
||||
_snapshots = snapshots;
|
||||
PreviouslyActiveIds = previouslyActiveIds;
|
||||
}
|
||||
|
||||
internal object Owner { get; }
|
||||
internal LandblockPhysicsPublication PhysicsPublication { get; }
|
||||
internal HashSet<uint> PreviouslyActiveIds { get; }
|
||||
internal int PriorCleanupCursor { get; set; }
|
||||
internal int PluginCursor { get; set; }
|
||||
internal bool BeginCommitted { get; set; }
|
||||
internal bool CompletionCommitted { get; set; }
|
||||
|
||||
public uint LandblockId => PhysicsPublication.LandblockId;
|
||||
public IReadOnlyDictionary<uint, WorldEntity> Entities => _entities;
|
||||
public IReadOnlyDictionary<uint, WorldEntitySnapshot> Snapshots => _snapshots;
|
||||
}
|
||||
|
||||
public readonly record struct LandblockStaticPresentationDiagnostics(
|
||||
long BeginCount,
|
||||
long CompleteCount,
|
||||
long LightReplacementCount,
|
||||
long PluginSpawnCount,
|
||||
long PluginRefreshCount,
|
||||
long PluginRemovalCount,
|
||||
int ActiveLandblockCount,
|
||||
int ActiveEntityCount);
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread owner of DAT-static lights, translucency lifetime, and plugin
|
||||
/// projection. Setup defaults remain activated exactly once at the existing
|
||||
/// post-spatial render-pin boundary owned by <see cref="GpuWorldState"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail constructs each static <c>CPhysicsObj</c> from Setup defaults before
|
||||
/// <c>CObjCell::init_objects</c> (0x0052B420) inserts/refloods it. The light
|
||||
/// state follows <c>CPhysicsObj::set_lights</c> (0x0050FCF0); translucency
|
||||
/// starts from the new object's Setup/material state before later FPHooks.
|
||||
/// Plugin projection is an acdream adaptation and therefore emits at most one
|
||||
/// logical spawn per retained static ID, while reapply only refreshes current
|
||||
/// state.
|
||||
/// </remarks>
|
||||
public sealed class LandblockStaticPresentationPublisher
|
||||
{
|
||||
private readonly object _receiptOwner = new();
|
||||
private readonly LightingHookSink _lighting;
|
||||
private readonly TranslucencyFadeManager _translucency;
|
||||
private readonly WorldGameState _worldState;
|
||||
private readonly WorldEvents _worldEvents;
|
||||
private readonly Dictionary<uint, Dictionary<uint, WorldEntitySnapshot>>
|
||||
_activeByLandblock = new();
|
||||
private readonly Dictionary<uint, uint> _landblockByEntityId = new();
|
||||
|
||||
private long _beginCount;
|
||||
private long _completeCount;
|
||||
private long _lightReplacementCount;
|
||||
private long _pluginSpawnCount;
|
||||
private long _pluginRefreshCount;
|
||||
private long _pluginRemovalCount;
|
||||
|
||||
public LandblockStaticPresentationPublisher(
|
||||
LightingHookSink lighting,
|
||||
TranslucencyFadeManager translucency,
|
||||
WorldGameState worldState,
|
||||
WorldEvents worldEvents)
|
||||
{
|
||||
_lighting = lighting ?? throw new ArgumentNullException(nameof(lighting));
|
||||
_translucency = translucency
|
||||
?? throw new ArgumentNullException(nameof(translucency));
|
||||
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
|
||||
_worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents));
|
||||
}
|
||||
|
||||
public LandblockStaticPresentationDiagnostics Diagnostics => new(
|
||||
_beginCount,
|
||||
_completeCount,
|
||||
_lightReplacementCount,
|
||||
_pluginSpawnCount,
|
||||
_pluginRefreshCount,
|
||||
_pluginRemovalCount,
|
||||
_activeByLandblock.Count,
|
||||
_landblockByEntityId.Count);
|
||||
|
||||
public LandblockStaticPresentationPublication PreparePublication(
|
||||
LandblockPhysicsPublication physicsPublication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(physicsPublication);
|
||||
|
||||
uint canonical = Canonicalize(physicsPublication.LandblockId);
|
||||
_activeByLandblock.TryGetValue(
|
||||
canonical,
|
||||
out Dictionary<uint, WorldEntitySnapshot>? active);
|
||||
var entities = new Dictionary<uint, WorldEntity>();
|
||||
var snapshots = new Dictionary<uint, WorldEntitySnapshot>();
|
||||
foreach (WorldEntity entity in physicsPublication.Build.Landblock.Entities)
|
||||
{
|
||||
if (entity.ServerGuid != 0)
|
||||
continue;
|
||||
if (!entities.TryAdd(entity.Id, entity))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Landblock 0x{canonical:X8} contains duplicate DAT-static ID " +
|
||||
$"0x{entity.Id:X8}.");
|
||||
}
|
||||
if (_landblockByEntityId.TryGetValue(entity.Id, out uint owner)
|
||||
&& owner != canonical)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"DAT-static ID 0x{entity.Id:X8} is already owned by " +
|
||||
$"landblock 0x{owner:X8}.");
|
||||
}
|
||||
if (active is not null
|
||||
&& active.TryGetValue(entity.Id, out WorldEntitySnapshot retained)
|
||||
&& retained.SourceId != entity.SourceGfxObjOrSetupId)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Retained DAT-static ID 0x{entity.Id:X8} changed source " +
|
||||
$"from 0x{retained.SourceId:X8} to " +
|
||||
$"0x{entity.SourceGfxObjOrSetupId:X8}.");
|
||||
}
|
||||
|
||||
snapshots.Add(entity.Id, Snapshot(entity));
|
||||
}
|
||||
|
||||
HashSet<uint> previous = active is not null
|
||||
? active.Keys.ToHashSet()
|
||||
: new HashSet<uint>();
|
||||
return new LandblockStaticPresentationPublication(
|
||||
_receiptOwner,
|
||||
physicsPublication,
|
||||
entities,
|
||||
snapshots,
|
||||
previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retires omitted prior projections and detaches prior concrete lights
|
||||
/// before replacement collision. Retained IDs keep their SetLight latch
|
||||
/// and FPHook translucency state because rehydrate is not a logical create.
|
||||
/// </summary>
|
||||
public void BeginPublication(LandblockStaticPresentationPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (publication.BeginCommitted)
|
||||
return;
|
||||
|
||||
uint[] priorIds = publication.PreviouslyActiveIds.Order().ToArray();
|
||||
while (publication.PriorCleanupCursor < priorIds.Length)
|
||||
{
|
||||
uint id = priorIds[publication.PriorCleanupCursor];
|
||||
bool retained = publication.Entities.ContainsKey(id);
|
||||
_lighting.UnregisterOwner(id, forgetState: !retained);
|
||||
if (!retained)
|
||||
{
|
||||
_translucency.ClearEntity(id);
|
||||
_worldState.RemoveById(id);
|
||||
_worldEvents.ForgetEntity(id);
|
||||
_landblockByEntityId.Remove(id);
|
||||
_pluginRemovalCount++;
|
||||
}
|
||||
publication.PriorCleanupCursor++;
|
||||
}
|
||||
|
||||
publication.BeginCommitted = true;
|
||||
_beginCount++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exact replacement called by <see cref="LandblockPhysicsPublisher"/>
|
||||
/// immediately before registering this object's ordinary collision.
|
||||
/// </summary>
|
||||
public void PrepareEntityBeforeCollision(
|
||||
LandblockStaticPresentationPublication publication,
|
||||
WorldEntity entity)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (!publication.BeginCommitted)
|
||||
throw new InvalidOperationException(
|
||||
"Static presentation cannot prepare an entity before begin commits.");
|
||||
if (entity.ServerGuid != 0)
|
||||
return;
|
||||
if (!publication.Entities.TryGetValue(entity.Id, out WorldEntity? expected)
|
||||
|| !ReferenceEquals(expected, entity))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"DAT-static entity 0x{entity.Id:X8} does not belong to this publication.");
|
||||
}
|
||||
|
||||
bool retained = publication.PreviouslyActiveIds.Contains(entity.Id);
|
||||
_lighting.UnregisterOwner(entity.Id, forgetState: !retained);
|
||||
if (!retained)
|
||||
_translucency.ClearEntity(entity.Id);
|
||||
|
||||
PhysicsDatBundle datBundle = publication.PhysicsPublication.Build.Landblock.PhysicsDats
|
||||
?? PhysicsDatBundle.Empty;
|
||||
uint sourceId = entity.SourceGfxObjOrSetupId;
|
||||
if ((sourceId & 0xFF000000u) == 0x02000000u
|
||||
&& datBundle.Setups.TryGetValue(sourceId, out var setup))
|
||||
{
|
||||
IReadOnlyList<LightSource> lights = LightInfoLoader.Load(
|
||||
setup,
|
||||
ownerId: entity.Id,
|
||||
entityPosition: entity.Position,
|
||||
entityRotation: entity.Rotation,
|
||||
cellId: entity.ParentCellId ?? 0u);
|
||||
for (int i = 0; i < lights.Count; i++)
|
||||
_lighting.RegisterOwnedLight(lights[i]);
|
||||
}
|
||||
_lightReplacementCount++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the exact current plugin snapshot after collision/reflood.
|
||||
/// Existing IDs refresh replay state without emitting another logical
|
||||
/// spawn; new IDs emit once.
|
||||
/// </summary>
|
||||
public void CompletePublication(
|
||||
LandblockStaticPresentationPublication publication)
|
||||
{
|
||||
ValidateReceipt(publication);
|
||||
if (!publication.BeginCommitted
|
||||
|| !publication.PhysicsPublication.CompletionCommitted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Static presentation requires completed physics publication.");
|
||||
}
|
||||
if (publication.CompletionCommitted)
|
||||
return;
|
||||
|
||||
KeyValuePair<uint, WorldEntitySnapshot>[] snapshots = publication.Snapshots
|
||||
.OrderBy(pair => pair.Key)
|
||||
.ToArray();
|
||||
while (publication.PluginCursor < snapshots.Length)
|
||||
{
|
||||
(uint id, WorldEntitySnapshot snapshot) = snapshots[publication.PluginCursor];
|
||||
_worldState.Add(snapshot);
|
||||
if (publication.PreviouslyActiveIds.Contains(id))
|
||||
{
|
||||
_worldEvents.UpsertCurrent(snapshot);
|
||||
_pluginRefreshCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_worldEvents.FireEntitySpawned(snapshot);
|
||||
_pluginSpawnCount++;
|
||||
}
|
||||
_landblockByEntityId[id] = Canonicalize(publication.LandblockId);
|
||||
publication.PluginCursor++;
|
||||
}
|
||||
|
||||
uint canonical = Canonicalize(publication.LandblockId);
|
||||
if (publication.Snapshots.Count == 0)
|
||||
{
|
||||
_activeByLandblock.Remove(canonical);
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeByLandblock[canonical] = publication.Snapshots
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value);
|
||||
}
|
||||
publication.CompletionCommitted = true;
|
||||
_completeCount++;
|
||||
}
|
||||
|
||||
public void RemoveLighting(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (entity.ServerGuid == 0)
|
||||
_lighting.UnregisterOwner(entity.Id);
|
||||
}
|
||||
|
||||
public void RemoveTranslucency(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (entity.ServerGuid == 0)
|
||||
_translucency.ClearEntity(entity.Id);
|
||||
}
|
||||
|
||||
public void RemovePluginProjection(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
if (entity.ServerGuid != 0)
|
||||
return;
|
||||
|
||||
_worldState.RemoveById(entity.Id);
|
||||
_worldEvents.ForgetEntity(entity.Id);
|
||||
if (_landblockByEntityId.Remove(entity.Id, out uint landblockId)
|
||||
&& _activeByLandblock.TryGetValue(
|
||||
landblockId,
|
||||
out Dictionary<uint, WorldEntitySnapshot>? active))
|
||||
{
|
||||
active.Remove(entity.Id);
|
||||
if (active.Count == 0)
|
||||
_activeByLandblock.Remove(landblockId);
|
||||
}
|
||||
_pluginRemovalCount++;
|
||||
}
|
||||
|
||||
private void ValidateReceipt(LandblockStaticPresentationPublication publication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publication);
|
||||
if (!ReferenceEquals(publication.Owner, _receiptOwner))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The static presentation receipt belongs to another publisher.",
|
||||
nameof(publication));
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldEntitySnapshot Snapshot(WorldEntity entity) => new(
|
||||
Id: entity.Id,
|
||||
SourceId: entity.SourceGfxObjOrSetupId,
|
||||
Position: entity.Position,
|
||||
Rotation: entity.Rotation);
|
||||
|
||||
private static uint Canonicalize(uint landblockId) =>
|
||||
(landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue