fix(world): preserve portal materialization effects

Route accepted Hidden and UnHide transitions through retail's internal typed-script path so their DAT scripts queue while the owner is cell-less and resume at the destination. Consume ACE's successful teleport control statuses silently, matching retail HandleFailureEvent.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-16 08:14:48 +02:00
parent bfb4b26dff
commit 4b1bceefbb
12 changed files with 142 additions and 8 deletions

View file

@ -153,6 +153,20 @@ public sealed class RetailParticleGeometryClassifierTests
}
}
[Fact]
public void InstalledHumanEffectTable_MapsPortalVisibilityScripts()
{
string? datDir = ResolveDatDir();
if (datDir is null)
throw SkipException.ForSkip("Installed client_portal.dat is required.");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var resolver = new PhysicsScriptTableResolver(id => dats.Get<PhysicsScriptTable>(id));
Assert.Equal(0x33000332u, resolver.Resolve(0x34000004u, 0x74u, 1f));
Assert.Equal(0x3300032Fu, resolver.Resolve(0x34000004u, 0x75u, 1f));
Assert.Equal(0x33000331u, resolver.Resolve(0x34000004u, 0x76u, 1f));
}
private static string? ResolveDatDir()
{
string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");

View file

@ -222,6 +222,30 @@ public sealed class EntityEffectControllerTests
Assert.Equal([1u], EmitterIds(fixture.Sink));
}
[Fact]
public void CelllessOwner_HiddenTransitionQueuesTypedScriptUntilReentry()
{
var fixture = new Fixture();
WorldEntity entity = fixture.ReadyLive();
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(Guid));
// set_hidden resolves the table and calls play_script_internal
// directly; it does not use CPhysicsObj::play_script's cell gate.
Assert.True(fixture.Controller.PlayTypedFromHiddenTransition(
entity.Id,
RawType,
0.5f));
Assert.Equal(1, fixture.Runner.ActiveScriptCount);
fixture.Runner.Tick(1.0);
Assert.Empty(fixture.Sink.Calls);
Assert.True(fixture.Runtime.RebucketLiveEntity(Guid, 0x01010001u));
fixture.Runner.Tick(1.0);
Assert.Equal([2u], EmitterIds(fixture.Sink));
}
[Fact]
public void FrozenOwnerPausesQueueUntilFreshStateUnfreezesIt()
{

View file

@ -518,6 +518,21 @@ public sealed class GameEventWiringTests
Assert.Contains("0x009C", e.Text);
}
[Theory]
[InlineData(0x003Bu)] // ILeftTheWorld
[InlineData(0x003Cu)] // ITeleported
public void WireAll_RetailSilentClientControlStatus_DoesNotReachChat(uint code)
{
var (dispatcher, _, _, _, chat) = MakeAll();
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, code);
dispatcher.Dispatch(GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.WeenieError, payload))!.Value);
Assert.Equal(0, chat.Count);
}
[Fact]
public void WireAll_WeenieErrorWithString_RoutesToChatLogWithInterpolation()
{

View file

@ -152,6 +152,18 @@ public sealed class ChatLogTests
Assert.Contains("Mana Stone", e.Text);
}
[Theory]
[InlineData(0x003Bu)] // ILeftTheWorld
[InlineData(0x003Cu)] // ITeleported
public void OnWeenieError_RetailSilentClientControlStatus_DoesNotAppend(uint code)
{
var log = new ChatLog();
log.OnWeenieError(code, param: null);
Assert.Empty(log.Snapshot());
}
[Fact]
public void OnLocalSpeech_EmptySender_SubstitutesYou()
{