feat(vfx): port retail hidden and teleport presentation

Preserve canonical live-object ownership across Hidden transitions and remote teleport placement so effects, collision, streaming, and targeting remain synchronized.
This commit is contained in:
Erik 2026-07-14 14:59:48 +02:00
parent a51ebc66e9
commit 1e98d81448
46 changed files with 4883 additions and 127 deletions

View file

@ -297,4 +297,22 @@ public sealed class TargetManager
foreach (var voyeur in _voyeurTable.Values.ToList())
SendVoyeurUpdate(voyeur, _host.Position, status);
}
/// <summary>
/// Broadcasts a terminal availability event and drops the watched-role
/// subscriptions after delivery. Retail normally removes the object from
/// cell lookup before its watchers process the event; clearing the local
/// table explicitly preserves that lifecycle boundary when the App uses
/// the target fan-out as the temporary bridge for an unavailable object.
/// The object's own watcher role (<see cref="TargetInfo"/>) is untouched.
/// </summary>
public void NotifyVoyeurOfEventAndClear(TargetStatus status)
{
if (_voyeurTable == null)
return;
foreach (var voyeur in _voyeurTable.Values.ToList())
SendVoyeurUpdate(voyeur, _host.Position, status);
_voyeurTable.Clear();
}
}

View file

@ -1388,7 +1388,11 @@ public sealed class PhysicsEngine
bool ok = transition.FindPlacementPos(this);
var sp = transition.SpherePath;
var ci = transition.CollisionInfo;
bool onGround = ci.ContactPlaneValid
bool inContact = ci.ContactPlaneValid;
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
inContact,
ci.ContactPlane.Normal);
bool onGround = inContact
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
return new ResolveResult(
@ -1397,6 +1401,12 @@ public sealed class PhysicsEngine
onGround,
ci.CollisionNormalValid,
ci.CollisionNormal,
ok);
ok,
Orientation: sp.CurOrientation,
InContact: inContact,
OnWalkable: onWalkable,
ContactPlane: ci.ContactPlane,
ContactPlaneCellId: ci.ContactPlaneCellId,
ContactPlaneIsWater: ci.ContactPlaneIsWater);
}
}

View file

@ -45,6 +45,70 @@ public static class PhysicsObjUpdate
body.calc_acceleration();
}
/// <summary>
/// Commits the contact/walkable/collision-response tail of retail
/// <c>CPhysicsObj::SetPositionInternal</c> (<c>0x00515330</c>) in its
/// original order. The pre-transition flags are explicit because a
/// deferred placement may temporarily park the body without contact while
/// still needing the source edge when its destination cell becomes ready.
/// </summary>
/// <remarks>
/// Retail writes Contact and recalculates acceleration, calls
/// <c>set_on_walkable</c> (which invokes HitGround/LeaveGround), then calls
/// <c>handle_all_collisions</c> at <c>0x005154FE</c>. Collision response
/// must therefore observe any velocity change made by the movement
/// callback; moving that callback after reflection changes the result.
/// </remarks>
public static void CommitSetPositionTransition(
PhysicsBody body,
bool inContact,
bool onWalkable,
bool collisionNormalValid,
Vector3 collisionNormal,
bool previousContact,
bool previousOnWalkable,
Action? hitGround = null,
Action? leaveGround = null)
{
ArgumentNullException.ThrowIfNull(body);
// SetPositionInternal replaces Contact first but retains the source
// OnWalkable bit through its first calc_acceleration call. A deferred
// teleport may have parked the live body with both bits cleared, so
// restore the captured source bit explicitly before reproducing that
// ordering.
if (previousOnWalkable)
body.TransientState |= TransientStateFlags.OnWalkable;
else
body.TransientState &= ~TransientStateFlags.OnWalkable;
if (inContact)
body.TransientState |= TransientStateFlags.Contact;
else
body.TransientState &= ~TransientStateFlags.Contact;
body.calc_acceleration();
bool finalOnWalkable = inContact && onWalkable;
if (finalOnWalkable)
body.TransientState |= TransientStateFlags.OnWalkable;
else
body.TransientState &= ~TransientStateFlags.OnWalkable;
if (!previousOnWalkable && finalOnWalkable)
hitGround?.Invoke();
else if (previousOnWalkable && !finalOnWalkable)
leaveGround?.Invoke();
body.calc_acceleration();
HandleAllCollisions(
body,
collisionNormalValid,
collisionNormal,
previousContact,
previousOnWalkable,
finalOnWalkable);
}
/// <summary>
/// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's
/// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on

View file

@ -51,4 +51,10 @@ public readonly record struct ResolveResult(
/// Exact retail walkability result after testing the contact-plane normal
/// against <see cref="PhysicsGlobals.FloorZ"/>.
/// </summary>
bool OnWalkable = false);
bool OnWalkable = false,
/// <summary>The accepted SetPosition contact plane, when present.</summary>
Plane ContactPlane = default,
/// <summary>Full cell that owns <see cref="ContactPlane"/>.</summary>
uint ContactPlaneCellId = 0,
/// <summary>Whether the accepted contact plane is water.</summary>
bool ContactPlaneIsWater = false);

View file

@ -0,0 +1,89 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Result of retail <c>CPhysicsObj::set_state</c> (<c>0x00514DD0</c>) and
/// the nested <c>set_hidden</c> call (<c>0x00514C60</c>).
/// </summary>
public readonly record struct RetailPhysicsStateTransition(
PhysicsStateFlags PreviousState,
PhysicsStateFlags RequestedState,
PhysicsStateFlags FinalState,
bool LightingChanged,
bool NoDrawChanged,
RetailHiddenTransition HiddenTransition)
{
public bool HasChanges => PreviousState != FinalState;
}
public enum RetailHiddenTransition
{
None,
BecameHidden,
BecameVisible,
}
/// <summary>
/// Pure port of retail's PhysicsState side-effect ordering. The server's raw
/// state is installed first; Lighting and NoDraw are processed next; Hidden is
/// processed last and may rewrite ReportCollisions/IgnoreCollisions.
/// </summary>
public static class RetailPhysicsStateTransitions
{
/// <summary>
/// Retail <c>CPhysicsObj</c> constructor state at <c>0x00512508</c>.
/// This deliberately does not contain Hidden: a normal visible
/// CreateObject must not manufacture an UnHide script.
/// </summary>
public const PhysicsStateFlags ConstructorState =
PhysicsStateFlags.EdgeSlide
| PhysicsStateFlags.Lighting
| PhysicsStateFlags.Gravity
| PhysicsStateFlags.ReportCollisions;
public static RetailPhysicsStateTransition Apply(
PhysicsStateFlags previousState,
PhysicsStateFlags requestedState)
{
// CPhysicsObj::set_state compares only the low 16 bits. Every state
// with a set_state side effect (Lighting, NoDraw, Hidden) lives there.
uint changedLow = ((uint)previousState ^ (uint)requestedState) & 0xFFFFu;
bool lightingChanged =
(changedLow & (uint)PhysicsStateFlags.Lighting) != 0;
bool noDrawChanged =
(changedLow & (uint)PhysicsStateFlags.NoDraw) != 0;
bool hiddenChanged =
(changedLow & (uint)PhysicsStateFlags.Hidden) != 0;
PhysicsStateFlags finalState = requestedState;
RetailHiddenTransition hiddenTransition = RetailHiddenTransition.None;
if (hiddenChanged)
{
if ((requestedState & PhysicsStateFlags.Hidden) != 0)
{
// set_hidden(true): report_collision_end, clear reporting,
// then enable collision-ignore before hiding from the cell.
finalState &= ~PhysicsStateFlags.ReportCollisions;
finalState |= PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions;
hiddenTransition = RetailHiddenTransition.BecameHidden;
}
else
{
// set_hidden(false): clear ignore and restore collision
// reporting even when the incoming raw state omitted it.
finalState &= ~(PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions);
finalState |= PhysicsStateFlags.ReportCollisions;
hiddenTransition = RetailHiddenTransition.BecameVisible;
}
}
return new RetailPhysicsStateTransition(
previousState,
requestedState,
finalState,
lightingChanged,
noDrawChanged,
hiddenTransition);
}
}

View file

@ -30,6 +30,22 @@ public sealed class WorldEntity
/// </summary>
public required IReadOnlyList<MeshRef> MeshRefs { get; set; }
/// <summary>
/// Whether the root PartArray participates in mesh drawing. Live-object
/// PhysicsState NoDraw/Hidden transitions change this without destroying
/// the entity or its scripts, particles, lights, cell identity, or cached
/// mesh resources. Dat/static entities remain visible by default.
/// </summary>
public bool IsDrawVisible { get; set; } = true;
/// <summary>
/// Retained-tree visibility inherited from attached ancestors. Retail
/// draws child physics objects through their parent hierarchy; acdream
/// flattens them into independent draw entries, so this separate bit keeps
/// ancestor suppression out of the child's own PhysicsState.
/// </summary>
public bool IsAncestorDrawVisible { get; set; } = true;
/// <summary>
/// Stable Setup-part-indexed poses used by attachment and effect hooks.
/// Unlike <see cref="MeshRefs"/>, this array never compacts around a DAT