Reworks Campaign CH slice CH2 per the REJECT-review findings doc (docs/research/2026-08-09-ch2-review-findings.md). BLOCKER 1 — SpewBoxController never rendered a line and leaked its pending queue. LinesProvider only ran through UiText.OnDraw, which gates on Visible — and the box started invisible, so the provider (the sole caller of SpewBoxState.Tick) never ran. Gave the controller an explicit per-frame Tick(now) driven by UiRoot's global-message-3 broadcast (a zero-size GlobalTimeSink child, the same pattern VendorUiController.DragOverGlobalTimeSink already uses), matching retail's gmSpewBoxUI::Update. LinesProvider now only returns the cache. Tests rewritten to drive root.Tick(...) instead of calling the provider directly, plus new coverage for visibility-without-a-draw, queue-drain-without-a-draw, and bounded-queue-across-many-ticks. BLOCKER 2 — re-derived the HandleFailureEvent routing table from the PDB-paired binary instead of the pseudo-C's ~33-char string previews. tools/pdb-extract/sweep_weenie_strings.py sweeps every push imm32 in VA 0x571990-0x575480, dereferences into .rdata/.data, and decodes the full UTF-16LE literal. Added the 5 ids dispatched via else-if (missed by case-label enumeration), resolved 0x4F8 (previously excluded), fixed 18 wrong strings (16 the review flagged + 2 more — 0x4E9 and 0x518 — an automated diff between every swept literal and the landed table found). Every changed row cross-checked against ACE's WeenieError/WeenieErrorWithString enum doc comments; both oracles agreed on every row, including a case where the review's own proposed text for the new 0x4E8 row was itself wrong (it was 0x4E9's text) — corrected via the else-if block's own instruction address plus the ACE cross-check. Pinned table count: 344 (338 + 5 + 0x4F8). SHOULD-FIX 1 — RuntimeCommunicationState.ResetSpewBox was dead code; folded into the ChatIdentity generation-reset stage (same lifetime boundary), with a reset assertion added to the existing populated-reset test. SHOULD-FIX 2 — AddText trimmed only the trailing end and invented an empty-string early return; retail's AddTextToScroll trims both ends (trim(&str, 1, 1, ws)) and has no empty guard. Both retired. SHOULD-FIX 3 — ShowWeenieError bypassed the AddText chokepoint via ChatLog.OnWeenieError (hardcoded LogTextType 0x00); routed through Communication.AddText(Resolve(code, param)) instead, and ChatLog.OnWeenieError is deleted — GameEventWiring's legacy no-router fallback now resolves + calls OnSystemMessage directly. SHOULD-FIX 4 — retail's HandleFailureEvent switch has no default case; an unmapped id now resolves to a null Text (silence toward the player) instead of the invented "WeenieError 0xNNNN" hex fallback, with a diagnostics-only console log line for the id. NITs — AP-TBD placeholders corrected to their real register rows (AP-178, not the unrelated AP-177 lifetime row); filed AP-180 for the windowId dual-destination gap and corrected three stale "lands with CH2" comments; extended SpewBoxLayoutDumpDiagnostic from dats.Portal to dats.Local and found the SpewBox element for real — LayoutDesc 0x21000011, element 0x10000048, size 450x72, MaxConcurrentItems (ListBox property 0x10000028) = 4, not retail's code default of 1. AP-178 narrowed accordingly; SpewBoxState.MaxConcurrentItems and SpewBoxController's extent/anchor/OneLine are now authored rather than placeholder (absolute screen position and colour remain open); fixed the "19 ids... lists 18" miscount by retiring the stale paragraph in the class doc rewrite; aligned the UseDone handler's silent-status check with the other two WeenieError handlers. Full Release suite: 11,914 passed / 4 skipped / 0 failed (build 0 errors). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
14 KiB
C#
373 lines
14 KiB
C#
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.Runtime.Tests;
|
|
|
|
public sealed class RuntimeGenerationResetTests
|
|
{
|
|
[Fact]
|
|
public void PopulatedResetConvergesEveryCanonicalOwnerAndStampsRetiringGeneration()
|
|
{
|
|
using var runtime = Create();
|
|
const uint player = 0x50000001u;
|
|
const uint creature = 0x70000001u;
|
|
const uint item = 0x80000001u;
|
|
runtime.PlayerIdentity.ServerGuid = player;
|
|
runtime.EntityObjects.RegisterEntity(Spawn(creature, 3));
|
|
runtime.EntityObjects.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = item,
|
|
Name = "item",
|
|
ContainerId = player,
|
|
});
|
|
runtime.InventoryOwner.ExternalContainers.RequestOpen(creature);
|
|
runtime.InventoryOwner.ExternalContainers.ApplyViewContents(creature);
|
|
runtime.InventoryOwner.ItemMana.OnQueryItemManaResponse(
|
|
item,
|
|
0.5f,
|
|
valid: true);
|
|
runtime.CharacterOwner.Spellbook.OnSpellLearned(123u, 1f);
|
|
runtime.CharacterOwner.LocalPlayer.OnVitalUpdate(
|
|
7u,
|
|
1u,
|
|
100u,
|
|
5u,
|
|
80u);
|
|
runtime.ActionOwner.Selection.Select(
|
|
creature,
|
|
SelectionChangeSource.System);
|
|
runtime.ActionOwner.Combat.SetCombatMode(CombatMode.Missile);
|
|
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid(player);
|
|
runtime.CommunicationOwner.Chat.OnSystemMessage("retained", 1u);
|
|
runtime.CommunicationOwner.SpewBox.Enqueue("about to be torn down");
|
|
_ = runtime.MovementOwner.Execute(
|
|
RuntimeMovementCommand.ToggleRunLock);
|
|
var observer = new RecordingObserver();
|
|
using IDisposable subscription = runtime.Subscribe(observer);
|
|
var host = new RecordingResetHost(runtime);
|
|
var retiring = new RuntimeGenerationToken(9);
|
|
|
|
runtime.ResetGeneration(retiring, host);
|
|
|
|
Assert.Single(host.Retired);
|
|
Assert.Equal(creature, host.Retired[0].ServerGuid);
|
|
Assert.Equal(1, host.DrainCalls);
|
|
Assert.Equal(1, host.CompleteCalls);
|
|
Assert.All(
|
|
observer.Combat
|
|
.Concat(observer.Entity)
|
|
.Concat(observer.Inventory),
|
|
stamp => Assert.Equal(retiring, stamp.Generation));
|
|
Assert.Equal(
|
|
[1UL, 2UL, 3UL, 4UL, 5UL],
|
|
observer.Combat
|
|
.Concat(observer.Inventory)
|
|
.Concat(observer.Entity)
|
|
.OrderBy(static stamp => stamp.Sequence)
|
|
.Select(static stamp => stamp.Sequence));
|
|
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
|
|
Assert.Equal(0, runtime.Entities.Count);
|
|
Assert.Equal(0, runtime.Inventory.ObjectCount);
|
|
Assert.Equal(0, runtime.CharacterOwner.Spellbook.LearnedCount);
|
|
Assert.Null(runtime.CharacterOwner.LocalPlayer.Get(
|
|
AcDream.Core.Player.LocalPlayerState.VitalKind.Health));
|
|
Assert.Equal(0u, runtime.Actions.Snapshot.SelectedObjectId);
|
|
Assert.Equal(CombatMode.NonCombat, runtime.Actions.Snapshot.CombatMode);
|
|
Assert.False(runtime.MovementOwner.AutoRunActive);
|
|
Assert.Equal(1, runtime.CommunicationOwner.Chat.Count);
|
|
// SHOULD-FIX 1 (docs/research/2026-08-09-ch2-review-findings.md):
|
|
// ResetSpewBox was dead code — a fresh generation must not
|
|
// resurrect a stale refusal line. Assert BOTH that the pending
|
|
// enqueue never surfaces (no leftover Tick drains it into
|
|
// visibility) and that Reset itself converges Count to zero.
|
|
runtime.CommunicationOwner.SpewBox.Tick(0d);
|
|
Assert.Equal(0, runtime.CommunicationOwner.SpewBox.Count);
|
|
Assert.Null(
|
|
runtime.CommunicationOwner.CommandTargets.LastIncomingTellSender);
|
|
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
|
|
|
|
runtime.ResetGeneration(retiring, host);
|
|
|
|
Assert.Single(host.Retired);
|
|
Assert.Equal(1, host.DrainCalls);
|
|
Assert.Equal(1, host.CompleteCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public void FailedHostDrainRetriesOnlyTheExactUnfinishedSuffix()
|
|
{
|
|
using var runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
|
|
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
|
|
runtime.EntityObjects.RegisterEntity(Spawn(0x70000002u, 2));
|
|
var host = new RecordingResetHost(runtime)
|
|
{
|
|
FailDrainOnce = true,
|
|
};
|
|
var retiring = new RuntimeGenerationToken(12);
|
|
|
|
RuntimeGenerationResetStageException failure =
|
|
Assert.Throws<RuntimeGenerationResetStageException>(
|
|
() => runtime.ResetGeneration(retiring, host));
|
|
|
|
Assert.Equal(
|
|
RuntimeGenerationResetStage.DrainHostProjection,
|
|
failure.Stage);
|
|
Assert.Equal(2, host.Retired.Count);
|
|
Assert.Equal(1, host.DrainCalls);
|
|
Assert.Equal(0, host.CompleteCalls);
|
|
Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount);
|
|
Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid);
|
|
RuntimeGenerationResetSnapshot pending =
|
|
runtime.GenerationReset.CaptureSnapshot();
|
|
Assert.True(pending.IsActive);
|
|
Assert.Equal(2, pending.RetirementCursor);
|
|
|
|
runtime.ResetGeneration(retiring, host);
|
|
|
|
Assert.Equal(2, host.Retired.Count);
|
|
Assert.Equal(2, host.DrainCalls);
|
|
Assert.Equal(1, host.CompleteCalls);
|
|
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
|
|
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
|
|
}
|
|
|
|
[Fact]
|
|
public void FailedHostCompletionKeepsOldIdentityAndDoesNotReplayDrainOrEntities()
|
|
{
|
|
using var runtime = Create();
|
|
const uint player = 0x50000001u;
|
|
runtime.PlayerIdentity.ServerGuid = player;
|
|
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
|
|
var host = new RecordingResetHost(runtime)
|
|
{
|
|
FailCompleteOnce = true,
|
|
};
|
|
var retiring = new RuntimeGenerationToken(17);
|
|
|
|
RuntimeGenerationResetStageException failure =
|
|
Assert.Throws<RuntimeGenerationResetStageException>(
|
|
() => runtime.ResetGeneration(retiring, host));
|
|
|
|
Assert.Equal(
|
|
RuntimeGenerationResetStage.CompleteHostProjection,
|
|
failure.Stage);
|
|
Assert.Equal(player, runtime.PlayerIdentity.ServerGuid);
|
|
Assert.Equal(0, runtime.EntityObjects.Entities.Count);
|
|
Assert.Equal(0, runtime.EntityObjects.Entities.PendingTeardownCount);
|
|
Assert.Single(host.Retired);
|
|
Assert.Equal(1, host.DrainCalls);
|
|
Assert.Equal(1, host.CompleteCalls);
|
|
|
|
runtime.ResetGeneration(retiring, host);
|
|
|
|
Assert.Single(host.Retired);
|
|
Assert.Equal(1, host.DrainCalls);
|
|
Assert.Equal(2, host.CompleteCalls);
|
|
Assert.Equal(0u, runtime.PlayerIdentity.ServerGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public void PendingResetRejectsHostReplacementAndReentrantReset()
|
|
{
|
|
using var runtime = Create();
|
|
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
|
|
runtime.EntityObjects.RegisterEntity(Spawn(0x70000001u, 1));
|
|
var retiring = new RuntimeGenerationToken(4);
|
|
var host = new RecordingResetHost(runtime)
|
|
{
|
|
Reenter = true,
|
|
FailDrainOnce = true,
|
|
};
|
|
|
|
Assert.Throws<RuntimeGenerationResetStageException>(
|
|
() => runtime.ResetGeneration(retiring, host));
|
|
Assert.IsType<InvalidOperationException>(host.ReentrantFailure);
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
runtime.ResetGeneration(
|
|
retiring,
|
|
new RecordingResetHost(runtime)));
|
|
|
|
runtime.ResetGeneration(retiring, host);
|
|
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
|
|
}
|
|
|
|
private static GameRuntime Create()
|
|
{
|
|
var operations = new Operations();
|
|
return new GameRuntime(new GameRuntimeDependencies(
|
|
operations,
|
|
operations,
|
|
operations,
|
|
operations));
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn Spawn(
|
|
uint guid,
|
|
ushort incarnation)
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
0x01010001u,
|
|
10f,
|
|
10f,
|
|
5f,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
0,
|
|
1,
|
|
0,
|
|
1,
|
|
incarnation);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: null,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
guid,
|
|
position,
|
|
0x02000001u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
null,
|
|
null,
|
|
guid.ToString("X8"),
|
|
null,
|
|
null,
|
|
null,
|
|
PhysicsState: physics.RawState,
|
|
InstanceSequence: incarnation,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
|
|
private sealed class RecordingResetHost(GameRuntime runtime)
|
|
: IRuntimeGenerationResetHost
|
|
{
|
|
public List<RuntimeEntityRecord> Retired { get; } = [];
|
|
public int DrainCalls { get; private set; }
|
|
public int CompleteCalls { get; private set; }
|
|
public bool FailDrainOnce { get; set; }
|
|
public bool FailCompleteOnce { get; set; }
|
|
public bool Reenter { get; set; }
|
|
public Exception? ReentrantFailure { get; private set; }
|
|
|
|
public void RetireEntityProjection(RuntimeEntityRecord entity)
|
|
{
|
|
Retired.Add(entity);
|
|
if (!Reenter)
|
|
return;
|
|
Reenter = false;
|
|
ReentrantFailure = Record.Exception(() =>
|
|
runtime.ResetGeneration(
|
|
runtime.GenerationReset
|
|
.CaptureSnapshot()
|
|
.RetiringGeneration,
|
|
this));
|
|
}
|
|
|
|
public void DrainEntityProjectionBoundary()
|
|
{
|
|
DrainCalls++;
|
|
if (!FailDrainOnce)
|
|
return;
|
|
FailDrainOnce = false;
|
|
throw new InvalidOperationException("injected drain failure");
|
|
}
|
|
|
|
public void CompleteEntityProjectionRetirement()
|
|
{
|
|
CompleteCalls++;
|
|
Assert.NotEqual(0u, runtime.PlayerIdentity.ServerGuid);
|
|
if (!FailCompleteOnce)
|
|
return;
|
|
FailCompleteOnce = false;
|
|
throw new InvalidOperationException("injected completion failure");
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingObserver : IRuntimeEventObserver
|
|
{
|
|
public List<RuntimeEventStamp> Entity { get; } = [];
|
|
public List<RuntimeEventStamp> Inventory { get; } = [];
|
|
public List<RuntimeEventStamp> Combat { get; } = [];
|
|
|
|
public void OnLifecycle(in RuntimeLifecycleDelta delta) { }
|
|
public void OnCommand(in RuntimeCommandDelta delta) { }
|
|
public void OnEntity(in RuntimeEntityDelta delta) =>
|
|
Entity.Add(delta.Stamp);
|
|
public void OnInventory(in RuntimeInventoryDelta delta) =>
|
|
Inventory.Add(delta.Stamp);
|
|
public void OnChat(in RuntimeChatDelta delta) { }
|
|
public void OnMovement(in RuntimeMovementDelta delta) { }
|
|
public void OnPortal(in RuntimePortalDelta delta) { }
|
|
public void OnCombat(in RuntimeCombatDelta delta) =>
|
|
Combat.Add(delta.Stamp);
|
|
}
|
|
|
|
private sealed class Operations :
|
|
IRuntimeCombatAttackOperations,
|
|
IRuntimeCombatTargetOperations,
|
|
IRuntimeCombatModeOperations,
|
|
IRuntimeSpellCastOperations
|
|
{
|
|
public bool CanStartAttack() => false;
|
|
public void PrepareAttackRequest() { }
|
|
public bool SendAttack(AttackHeight height, float power) => false;
|
|
public void SendCancelAttack() { }
|
|
public bool IsDualWield => false;
|
|
public bool PlayerReadyForAttack => false;
|
|
public bool AutoRepeatAttack => false;
|
|
public bool AutoTarget => false;
|
|
public uint? SelectClosestTarget() => null;
|
|
public bool IsInWorld => false;
|
|
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
|
public void NotifyExplicitCombatModeRequest() { }
|
|
public void SendChangeCombatMode(CombatMode mode) { }
|
|
public uint LocalPlayerId => 0u;
|
|
public bool CanSend => false;
|
|
public bool HasRequiredComponents(uint spellId) => false;
|
|
public bool IsTargetCompatible(
|
|
uint targetId,
|
|
SpellMetadata spell,
|
|
bool showMessage) => false;
|
|
public void StopCompletely() { }
|
|
public void SendUntargeted(uint spellId) { }
|
|
public void SendTargeted(uint targetId, uint spellId) { }
|
|
public void DisplayMessage(string message) { }
|
|
public void IncrementBusy() { }
|
|
}
|
|
}
|