acdream never parsed retail's Sound event, so every server-driven cue was silent: melee hits and wounds, wield/unwield, pickup/drop, lockpicking, lifestone bind, spell resist, trap triggers, item mana depletion. SoundEvent parses the 16-byte message (guid, SoundType, f32 volume) whose layout three oracles agree on: retail CM_Physics::DispatchSB_SoundEvent @0x006AC760 reading buf+4/+8/+0xC, ACE's GameMessageSound at declared length 16, and holtburger's PlaySoundData. Playback reuses EntityEffectController's existing per-guid queue rather than adding a second one, because retail routes sounds through the SAME CObjectMaint blob queue as F754/F755: an event for a guid the client does not know yet is parked and drained by HandleCreateObject, so a creature that spawns and immediately grunts still grunts. Dropping it — the obvious alternative — would silently lose the cue. Sound joins Direct and Typed as a third PendingEffect kind so one readiness edge releases the whole mixed stream in order. AudioHookSink.PlayServerSound reproduces two decoded asymmetries with the animation-hook path: the sound plays at the WIRE volume and the SoundTable entry's volume is ignored (the hook path does the opposite), while the entry's probability still gates it and its priority still drives eviction. An object with no SoundTable plays nothing, matching CPhysicsObj::play_sound @0x0050F460's early return. The no-window host parses and discards, exactly as it does for F754/F755 — sound is presentation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
205 lines
8.5 KiB
C#
205 lines
8.5 KiB
C#
using System.Net;
|
|
using AcDream.App.Net;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Social;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.App.Tests.Net;
|
|
|
|
/// <summary>
|
|
/// C3c-F1 (2026-08-02): the movement-stats application seam driven through
|
|
/// the REAL inbound chain that crashed the connected lifecycle gate —
|
|
/// <c>ClientObjectTable.Ingest</c> → <c>ObjectAdded/ObjectUpdated</c> →
|
|
/// <c>LiveSessionEventRouter.RecomputePlayerQualities</c> →
|
|
/// <c>OnMovementStatsUpdated</c> → <see cref="LiveMovementStatsApplier"/> →
|
|
/// <see cref="RuntimeLocalPlayerMovementState.ApplyCharacterMovementStats"/>.
|
|
/// </summary>
|
|
public sealed class LiveMovementStatsApplierTests
|
|
{
|
|
private const uint PlayerGuid = 0x5000000Au;
|
|
|
|
private sealed class Harness : IDisposable
|
|
{
|
|
public WorldSession Session { get; }
|
|
public LiveSessionEventRouter Router { get; }
|
|
public ClientObjectTable Objects { get; } = new();
|
|
public RuntimeCharacterState Character { get; } = new();
|
|
public RuntimeLocalPlayerMovementState Movement { get; } = new();
|
|
public LiveMovementStatsApplier Applier { get; }
|
|
public List<string> Log { get; } = [];
|
|
|
|
public Harness()
|
|
{
|
|
Session = new WorldSession(new IPEndPoint(IPAddress.Loopback, 9));
|
|
// The REAL applier the factory binds (LiveSessionRuntimeFactory
|
|
// constructs the same class over the same owner pair) with the
|
|
// REAL character-bindings recompute callback shape.
|
|
Applier = new LiveMovementStatsApplier(
|
|
Movement,
|
|
Character.MovementSkills,
|
|
Log.Add);
|
|
Router = new LiveSessionEventRouter(
|
|
Session,
|
|
new LiveEntitySessionSink(
|
|
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
|
|
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
|
|
_ => { }),
|
|
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
|
|
new LiveInventorySessionBindings(
|
|
Objects,
|
|
PlayerGuid: () => PlayerGuid,
|
|
OnShortcuts: null,
|
|
OnUseDone: null,
|
|
ItemMana: new ItemManaState(),
|
|
ExternalContainers: new ExternalContainerState()),
|
|
new LiveCharacterSessionBindings(
|
|
new CombatState(),
|
|
Character,
|
|
ResolveSkillFormulaBonus: null,
|
|
OnSkillsUpdated: (_, _) => Applier.Apply("skills"),
|
|
OnConfirmationRequest: null,
|
|
OnConfirmationDone: null,
|
|
ClientTime: () => 0d,
|
|
OnMovementStatsUpdated: () => Applier.Apply("stats")),
|
|
new LiveSocialSessionBindings(
|
|
new ChatLog(),
|
|
new TurbineChatState(),
|
|
new FriendsState(),
|
|
new SquelchState()));
|
|
Router.Attach();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ingests the player's own object row — the exact post-logout
|
|
/// inbound-Create edge (<c>ApplyAcceptedSpawn</c> →
|
|
/// <c>ClientObjectTable.Ingest</c>) that fired the crashing
|
|
/// recompute in logs/connected-world-gate-20260802-122749.
|
|
/// </summary>
|
|
public void IngestPlayerRow(uint? pwdBitfield = null) =>
|
|
Objects.Ingest(new WeenieData(
|
|
Guid: PlayerGuid, Name: "+Acdream", Type: ItemType.Creature,
|
|
WeenieClassId: 1u, IconId: 0, IconOverlayId: 0,
|
|
IconUnderlayId: 0, Effects: 0,
|
|
Value: null, StackSize: null, StackSizeMax: null, Burden: null,
|
|
ContainerId: null, WielderId: null, ValidLocations: null,
|
|
CurrentWieldedLocation: null, Priority: null,
|
|
ItemsCapacity: null, ContainersCapacity: null,
|
|
Structure: null, MaxStructure: null, Workmanship: null,
|
|
PublicWeenieBitfield: pwdBitfield));
|
|
|
|
public void Dispose()
|
|
{
|
|
Router.Dispose();
|
|
Session.Dispose();
|
|
Movement.Dispose();
|
|
Character.Dispose();
|
|
}
|
|
}
|
|
|
|
private static PlayerMovementController NewDormantRuntimeController()
|
|
{
|
|
PlayerMovementController controller =
|
|
PlayerMovementController.CreatePublicationCandidate(
|
|
new PhysicsEngine(),
|
|
PlayerMovementConstructionOptions.Fallback);
|
|
controller.SealPublicationCandidate();
|
|
controller.CommitRuntimeOwnership(new RetailObjectQuantumClock());
|
|
return controller;
|
|
}
|
|
|
|
[Fact]
|
|
public void PostTeardownIngestRecomputeReportsTypedDropInsteadOfCrashing()
|
|
{
|
|
using var harness = new Harness();
|
|
// The session's authoritative skills were complete before teardown.
|
|
harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180);
|
|
|
|
// Post-teardown transient truth: a retired controller still
|
|
// reachable through the displaced recompute callback — the state the
|
|
// old direct path crashed on ("A sealed, retired, or discarded
|
|
// Runtime movement controller cannot be mutated").
|
|
PlayerMovementController controller = NewDormantRuntimeController();
|
|
harness.Movement.Controller = controller;
|
|
controller.ActivateRuntimePublication();
|
|
controller.RetireRuntimePublication();
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => controller.SetCharacterSkills(1, 1));
|
|
|
|
// The exact crash edge: a post-logout inbound Create's object-table
|
|
// ingest fires the quality recompute through the real router.
|
|
harness.IngestPlayerRow();
|
|
|
|
Assert.Contains(
|
|
harness.Log,
|
|
line => line.StartsWith(
|
|
"player: dropped displaced movement stats",
|
|
StringComparison.Ordinal));
|
|
Assert.DoesNotContain(
|
|
harness.Log,
|
|
line => line.StartsWith(
|
|
"player: applied server movement",
|
|
StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void DormantWindowIngestRecomputeAppliesToTheControllerThatGoesLive()
|
|
{
|
|
using var harness = new Harness();
|
|
harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180);
|
|
harness.Character.MovementSkills.UpdateStamina(37);
|
|
|
|
// The committed-but-unactivated first-entry window (activation
|
|
// deferred on cell streaming) with inbound ingests still pumping.
|
|
PlayerMovementController controller = NewDormantRuntimeController();
|
|
harness.Movement.Controller = controller;
|
|
Assert.True(controller.IsRuntimeOwnedDormant);
|
|
|
|
// BF_PLAYER (0x8) + BF_PLAYER_KILLER (0x20) on the player's own row
|
|
// exercises the full stat set through the recompute.
|
|
harness.IngestPlayerRow(pwdBitfield: 0x28u);
|
|
|
|
Assert.Contains(
|
|
harness.Log,
|
|
line => line.StartsWith(
|
|
"player: applied server movement stats",
|
|
StringComparison.Ordinal));
|
|
|
|
// The same instance goes live at activation with the values already
|
|
// current.
|
|
controller.ActivateRuntimePublication();
|
|
IWeenieObject weenie = controller.Motion.WeenieObj!;
|
|
Assert.True(weenie.InqRunRate(out float runRate));
|
|
Assert.True(runRate > 0f);
|
|
Assert.Equal(
|
|
ObjectInfoState.IsPK,
|
|
controller.OwnPvpFlags & ObjectInfoState.IsPK);
|
|
}
|
|
|
|
[Fact]
|
|
public void AbsentControllerAndIncompleteSnapshotStaySilent()
|
|
{
|
|
using var harness = new Harness();
|
|
|
|
// Incomplete snapshot (no PlayerDescription yet) with no controller:
|
|
// byte-identical to the pre-F1 silent skip — no log line at all.
|
|
harness.IngestPlayerRow();
|
|
Assert.DoesNotContain(
|
|
harness.Log,
|
|
line => line.StartsWith("player:", StringComparison.Ordinal));
|
|
|
|
// Complete snapshot but still no controller (pre-first-entry):
|
|
// still the silent skip.
|
|
harness.Character.MovementSkills.Update(runSkill: 240, jumpSkill: 180);
|
|
Assert.Equal(
|
|
RuntimeMovementStatsApplication.DroppedNoController,
|
|
harness.Applier.Apply("stats"));
|
|
Assert.DoesNotContain(
|
|
harness.Log,
|
|
line => line.StartsWith("player:", StringComparison.Ordinal));
|
|
}
|
|
}
|