acdream/src/AcDream.Core/Physics/RetailObjectQuantumClock.cs
Erik f961d70023 feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates.

Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-20 09:10:31 +02:00

122 lines
3.7 KiB
C#

using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Allocation-free port of <c>CPhysicsObj::update_object</c>
/// (0x00515D10). One clock belongs to one logical physics object. It retains
/// sub-minimum elapsed time, subdivides long frames into complete object
/// updates, and discards stale gaps greater than retail's huge quantum.
/// </summary>
public sealed class RetailObjectQuantumClock
{
private double _pending;
public double PendingSeconds => _pending;
public bool IsActive { get; private set; } = true;
public RetailObjectQuantumBatch Advance(double elapsedSeconds)
{
if (double.IsNaN(elapsedSeconds) || elapsedSeconds < 0.0)
{
_pending = 0.0;
return new RetailObjectQuantumBatch(0, 0f, Discarded: true);
}
double elapsed = _pending + elapsedSeconds;
if (elapsed <= FrameEpsilon)
{
_pending = 0.0;
return default;
}
if (elapsed > PhysicsBody.HugeQuantum)
{
_pending = 0.0;
return new RetailObjectQuantumBatch(0, 0f, Discarded: true);
}
int fullSteps = 0;
while (elapsed > PhysicsBody.MaxQuantum)
{
fullSteps++;
elapsed -= PhysicsBody.MaxQuantum;
}
float remainder = 0f;
if (elapsed > PhysicsBody.MinQuantum)
{
remainder = (float)elapsed;
elapsed = 0.0;
}
_pending = elapsed;
return new RetailObjectQuantumBatch(fullSteps, remainder, Discarded: false);
}
/// <summary>
/// Retail <c>set_active(0)</c>: suppress the full object path without
/// advancing or rebasing <c>update_time</c>.
/// </summary>
public void Deactivate() => IsActive = false;
/// <summary>
/// Retail <c>set_active(1)</c>. An inactive-to-active edge rebases
/// <c>update_time</c> to the current timer, so the reactivation frame does
/// not catch up suppressed time.
/// </summary>
/// <returns>True only when this call performed the reactivation edge.</returns>
public bool Activate()
{
if (IsActive)
return false;
IsActive = true;
_pending = 0.0;
return true;
}
public void Reset() => _pending = 0.0;
/// <summary>
/// Retail <c>CPhysicsObj::prepare_to_enter_world</c> (0x00511FA0): rebase
/// <c>update_time</c> to the current timer and immediately set Active when
/// the object is not Static. A Static object retains its prior Active bit;
/// normal initial entry and re-entry arrive here inactive. The next elapsed
/// frame of a non-Static object is therefore eligible for ordinary object-
/// quantum admission; there is no second activation frame to discard.
/// </summary>
public void ResetForEnterWorld(bool isStatic = false)
{
_pending = 0.0;
if (!isStatic)
IsActive = true;
}
private const double FrameEpsilon = 0.000199999995;
}
/// <summary>
/// How the owning live-object lifecycle treats this render frame before
/// entering retail <c>CPhysicsObj::update_object</c>.
/// </summary>
public enum RetailObjectClockDisposition
{
Advance,
Suspend,
}
/// <summary>Compact result of one retail object-clock admission pass.</summary>
public readonly record struct RetailObjectQuantumBatch(
int FullSteps,
float Remainder,
bool Discarded)
{
public int Count => FullSteps + (Remainder > 0f ? 1 : 0);
public float GetQuantum(int index)
{
if ((uint)index >= (uint)Count)
throw new ArgumentOutOfRangeException(nameof(index));
return index < FullSteps ? PhysicsBody.MaxQuantum : Remainder;
}
}